mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(medications,coach,admin,perf): era corrections, a truthful weekly status and one card language everywhere
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
This commit is contained in:
1 parent
a4b3d2fcd0
commit
40f7196c20
115 files changed
+3014
-1084
No files matched your search
@@ -18,6 +18,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AiQualitySection } from "@/components/admin/ai-quality-section";
|
||||
import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
|
||||
import { AssistantSection } from "@/components/admin/assistant-section";
|
||||
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
|
||||
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
|
||||
@@ -104,6 +105,7 @@ export function AdminSectionRenderer({
|
||||
subtitle={t("admin.section.assistant.subtitle")}
|
||||
>
|
||||
<AssistantSection />
|
||||
<AiServerKeySection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "coach-feedback":
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* v1.16.6 — admin CRUD contract for the operator-wide AI key
|
||||
* (`/api/admin/ai-settings`). The endpoint predates its UI; the new
|
||||
* admin "AI server key" card now drives it, so the contract gets
|
||||
* pinned: admin-only access, encrypted storage, key removal, the
|
||||
* HTTPS + hostname allowlist on the base URL, and the masked
|
||||
* `keyPreview` in every response.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `enc(${value})`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/ai-settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: new Date(Date.now() + 60_000),
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/ai-settings", () => {
|
||||
it("propagates the admin gate (non-admin cannot read)", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
|
||||
await expect(GET()).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it("returns defaults + hasKey=false when nothing is configured", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({
|
||||
hasKey: false,
|
||||
keyPreview: null,
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns hasKey=true with a masked preview when a key is stored", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc(sk-test-12cd)",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.hasKey).toBe(true);
|
||||
expect(body.data.keyPreview).toBe("...12cd");
|
||||
expect(body.data.model).toBe("gpt-4o-mini");
|
||||
// The plaintext key never leaks into the envelope.
|
||||
expect(JSON.stringify(body)).not.toContain("sk-test-12cd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/ai-settings", () => {
|
||||
function upsertEcho() {
|
||||
vi.mocked(prisma.appSettings.upsert).mockImplementation(
|
||||
(async (args: { update: Record<string, unknown> }) => ({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
...args.update,
|
||||
})) as never,
|
||||
);
|
||||
}
|
||||
|
||||
it("propagates the admin gate (non-admin cannot write)", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
|
||||
await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it("encrypts and stores a new key, audit-logged", async () => {
|
||||
upsertEcho();
|
||||
const res = await PUT(jsonReq({ apiKey: "sk-new-key-89ab" }));
|
||||
const body = await res.json();
|
||||
|
||||
expect(encrypt).toHaveBeenCalledWith("sk-new-key-89ab");
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: { adminAiKeyEncrypted: "enc(sk-new-key-89ab)" },
|
||||
}),
|
||||
);
|
||||
expect(body.data.hasKey).toBe(true);
|
||||
expect(body.data.keyPreview).toBe("...89ab");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.ai-settings.update",
|
||||
expect.objectContaining({ userId: "admin-1" }),
|
||||
);
|
||||
// The audit detail records THAT the key changed, never the key.
|
||||
const details = vi.mocked(auditLog).mock.calls[0][1] as {
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
expect(JSON.stringify(details)).not.toContain("sk-new-key-89ab");
|
||||
});
|
||||
|
||||
it("clears the key when apiKey is empty", async () => {
|
||||
upsertEcho();
|
||||
const res = await PUT(jsonReq({ apiKey: "" }));
|
||||
const body = await res.json();
|
||||
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ update: { adminAiKeyEncrypted: null } }),
|
||||
);
|
||||
expect(body.data.hasKey).toBe(false);
|
||||
expect(body.data.keyPreview).toBeNull();
|
||||
});
|
||||
|
||||
it("updates model and baseUrl for an allowlisted https host", async () => {
|
||||
upsertEcho();
|
||||
const res = await PUT(
|
||||
jsonReq({ model: "gpt-4o-mini", baseUrl: "https://openrouter.ai/v1" }),
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.data.model).toBe("gpt-4o-mini");
|
||||
expect(body.data.baseUrl).toBe("https://openrouter.ai/v1");
|
||||
});
|
||||
|
||||
it("rejects a plain-http base URL", async () => {
|
||||
await expect(
|
||||
PUT(jsonReq({ baseUrl: "http://api.openai.com/v1" })),
|
||||
).rejects.toMatchObject({ statusCode: 422 });
|
||||
});
|
||||
|
||||
it("rejects a base URL host outside the allowlist", async () => {
|
||||
await expect(
|
||||
PUT(jsonReq({ baseUrl: "https://attacker.example/v1" })),
|
||||
).rejects.toMatchObject({ statusCode: 422 });
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty model", async () => {
|
||||
await expect(PUT(jsonReq({ model: " " }))).rejects.toMatchObject({
|
||||
statusCode: 422,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a body with no recognised fields", async () => {
|
||||
await expect(PUT(jsonReq({ unrelated: true }))).rejects.toMatchObject({
|
||||
statusCode: 422,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 429 when the admin surface is rate-limited", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -179,7 +179,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
const { conversationId, message, locale: bodyLocale, scope } = parsed.data;
|
||||
const {
|
||||
conversationId,
|
||||
message,
|
||||
locale: bodyLocale,
|
||||
scope,
|
||||
guidedQuestion,
|
||||
} = parsed.data;
|
||||
|
||||
// Per-user request-rate ceiling layered in front of the daily budget
|
||||
// gate. The budget catches the cost dimension; this catches the
|
||||
@@ -206,7 +212,14 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
|
||||
// ── Refusal short-circuit ────────────────────────────────────
|
||||
const refusal = detectRefusal({ message, locale });
|
||||
// v1.16.6 — the guided-flow question rides the prompt too, so it
|
||||
// runs through the same regex bank as the message. The questions
|
||||
// are server-derived in the honest case; this guards the dishonest
|
||||
// one (a crafted client using the field as an unchecked channel).
|
||||
const refusal = detectRefusal({
|
||||
message: guidedQuestion ? `${guidedQuestion}\n${message}` : message,
|
||||
locale,
|
||||
});
|
||||
if (refusal.refuse && refusal.message) {
|
||||
annotate({
|
||||
action: { name: "insights.coach.refused" },
|
||||
@@ -358,9 +371,20 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
const transcript = window
|
||||
.map((t) => `${t.role.toUpperCase()}: ${t.content}`)
|
||||
.join("\n\n");
|
||||
// v1.16.6 — guided clarifying-questions flow: the question the user
|
||||
// is answering exists only as a client-side bubble, so hand it to
|
||||
// the model as delimited context. The reaction should read as a
|
||||
// natural reply to the answer, not as a re-ask.
|
||||
const guidedBlock = guidedQuestion
|
||||
? `\nGUIDED QUESTION (user-provided context)
|
||||
The user's message answers this clarifying question from their self-context questionnaire:
|
||||
"""${guidedQuestion}"""
|
||||
React briefly and personally to the answer; do not repeat the question and do not ask it again.
|
||||
`
|
||||
: "";
|
||||
const userPrompt = `SNAPSHOT
|
||||
${snapshot.snapshotJson || "(no metric data in this user's log yet)"}
|
||||
|
||||
${guidedBlock}
|
||||
CONVERSATION
|
||||
${transcript}
|
||||
|
||||
|
||||
@@ -143,7 +143,9 @@ export const GET = apiHandler(
|
||||
// the LIVE schedule rows, so it must not reach past the newest
|
||||
// revision boundary into a previous era's cadence.
|
||||
const latestRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id },
|
||||
// Superseded rows are audit records — a correction may have
|
||||
// shortened the era, so the boundary reads only active rows.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
@@ -424,7 +426,9 @@ export const PUT = apiHandler(
|
||||
)
|
||||
) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id },
|
||||
// Chain from the ACTIVE boundary: a superseded row is an
|
||||
// audit record whose `validUntil` may sit past its correction.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
/**
|
||||
* v1.16.5 — DELETE /api/medications/[id]/schedule-revisions/[revisionId]
|
||||
* v1.16.6 — PATCH /api/medications/[id]/schedule-revisions/[revisionId]
|
||||
*
|
||||
* Removes a MANUAL schedule era (one the owner appended through the
|
||||
* sibling POST). Write-path archives (`source: "ARCHIVED"`) are
|
||||
* immutable history — the wholesale-replace path minted them from rows
|
||||
* that actually existed, so deleting one would falsify the ledger; the
|
||||
* route refuses with 409.
|
||||
* DELETE removes a MANUAL schedule era (one the owner appended through
|
||||
* the sibling POST, or a correction minted by PATCH). Write-path
|
||||
* archives (`source: "ARCHIVED"`) are immutable history — the
|
||||
* wholesale-replace path minted them from rows that actually existed,
|
||||
* so deleting one would falsify the ledger; the route refuses with 409.
|
||||
* Deleting a correction restores the archived original it superseded.
|
||||
*
|
||||
* PATCH corrects an era. A MANUAL era updates in place. An ARCHIVED era
|
||||
* stays untouched as the audit record: the correction is minted as a
|
||||
* new MANUAL revision and the original's `supersededByRevisionId`
|
||||
* points at it, so every era consumer reads the correction while the
|
||||
* recorded history remains inspectable. Validation mirrors the sibling
|
||||
* POST (bounds order, live-plan ceiling, no overlap with other active
|
||||
* eras); the check-then-write runs under a `FOR UPDATE` lock on the
|
||||
* medication row so concurrent era writes serialise.
|
||||
*
|
||||
* Auth: requireAuth() + medication ownership; a revision belonging to
|
||||
* another medication (or another user's medication) surfaces as 404,
|
||||
@@ -17,12 +28,246 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision";
|
||||
import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; revisionId: string }> };
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id, revisionId } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const med = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = scheduleRevisionUpdateSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const validFrom = new Date(parsed.data.validFrom);
|
||||
const validUntil = new Date(parsed.data.validUntil);
|
||||
|
||||
// Sanity floor — mirrors the sibling POST.
|
||||
if (validFrom.getUTCFullYear() < 1900) {
|
||||
return apiError("validFrom must be a date after 1900", 422);
|
||||
}
|
||||
|
||||
// Corrected snapshot, shaped exactly like the POST path: daily
|
||||
// recurrence at the (schema-deduped, sorted) times, window pulled
|
||||
// to their min/max.
|
||||
const times = parsed.data.timesOfDay;
|
||||
const entry = toRevisionPayloadEntry({
|
||||
timesOfDay: times,
|
||||
windowStart: times[0],
|
||||
windowEnd: times[times.length - 1],
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
label: null,
|
||||
dose: null,
|
||||
reminderGraceMinutes: null,
|
||||
});
|
||||
|
||||
// Validate-then-write under the per-medication row lock — the same
|
||||
// serialisation as the sibling POST, so a concurrent era write can
|
||||
// never slip an overlapping interval past the check.
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`
|
||||
SELECT id FROM medications
|
||||
WHERE id = ${id}
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
const target = await tx.medicationScheduleRevision.findUnique({
|
||||
where: { id: revisionId },
|
||||
select: {
|
||||
id: true,
|
||||
medicationId: true,
|
||||
source: true,
|
||||
supersededByRevisionId: true,
|
||||
validUntil: true,
|
||||
},
|
||||
});
|
||||
if (!target || target.medicationId !== id) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 404 as const,
|
||||
error: "Schedule revision not found",
|
||||
};
|
||||
}
|
||||
if (target.supersededByRevisionId !== null) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 409 as const,
|
||||
error: "This era has already been corrected",
|
||||
};
|
||||
}
|
||||
|
||||
const others = await tx.medicationScheduleRevision.findMany({
|
||||
where: {
|
||||
medicationId: id,
|
||||
supersededByRevisionId: null,
|
||||
id: { not: revisionId },
|
||||
},
|
||||
select: { validFrom: true, validUntil: true },
|
||||
});
|
||||
|
||||
// Live-plan ceiling, mirroring the POST rule: the live era began
|
||||
// at the newest active `validUntil` (never earlier than
|
||||
// `createdAt`). The edited era's own recorded end counts — an
|
||||
// ARCHIVED era adjacent to the live plan may keep its boundary,
|
||||
// but no correction may extend into tracked live history.
|
||||
const liveBoundary = [target.validUntil, ...others.map((r) => r.validUntil)].reduce(
|
||||
(latest, v) => (v > latest ? v : latest),
|
||||
med.createdAt,
|
||||
);
|
||||
if (validUntil.getTime() > liveBoundary.getTime()) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 422 as const,
|
||||
error: "A corrected era must end before the current plan begins",
|
||||
};
|
||||
}
|
||||
|
||||
// No overlap with any OTHER active interval `[validFrom,
|
||||
// validUntil)` — the corrected bounds may of course cover the
|
||||
// era's own previous interval.
|
||||
const overlaps = others.some(
|
||||
(r) =>
|
||||
validFrom.getTime() < r.validUntil.getTime() &&
|
||||
validUntil.getTime() > r.validFrom.getTime(),
|
||||
);
|
||||
if (overlaps) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 422 as const,
|
||||
error: "The era overlaps an existing schedule era",
|
||||
};
|
||||
}
|
||||
|
||||
const revisionSelect = {
|
||||
id: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
source: true,
|
||||
} as const;
|
||||
|
||||
if (target.source === "MANUAL") {
|
||||
const revision = await tx.medicationScheduleRevision.update({
|
||||
where: { id: revisionId },
|
||||
data: {
|
||||
validFrom,
|
||||
validUntil,
|
||||
payload: [entry] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: revisionSelect,
|
||||
});
|
||||
return { ok: true as const, revision, mode: "in_place" as const };
|
||||
}
|
||||
|
||||
// ARCHIVED — immutable. Mint the correction as a MANUAL row and
|
||||
// park the original behind it as the audit record.
|
||||
const revision = await tx.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom,
|
||||
validUntil,
|
||||
source: "MANUAL",
|
||||
payload: [entry] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: revisionSelect,
|
||||
});
|
||||
await tx.medicationScheduleRevision.update({
|
||||
where: { id: revisionId },
|
||||
data: { supersededByRevisionId: revision.id },
|
||||
});
|
||||
return { ok: true as const, revision, mode: "supersede" as const };
|
||||
});
|
||||
|
||||
if (!outcome.ok) {
|
||||
return apiError(outcome.error, outcome.status);
|
||||
}
|
||||
|
||||
await auditLog("medication.schedule_revision.updated", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
revisionId,
|
||||
mode: outcome.mode,
|
||||
...(outcome.mode === "supersede" && {
|
||||
correctionRevisionId: outcome.revision.id,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name:
|
||||
outcome.mode === "in_place"
|
||||
? "medication.schedule_revision.manual_updated"
|
||||
: "medication.schedule_revision.archived_corrected",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
revision_id: outcome.revision.id,
|
||||
...(outcome.mode === "supersede" && {
|
||||
superseded_revision_id: revisionId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// The corrected era re-segments history; refresh the pre-aggregated
|
||||
// compliance rollups asynchronously (best-effort).
|
||||
await enqueueUserMedicationComplianceBackfill(user.id);
|
||||
|
||||
return apiSuccess({
|
||||
id: outcome.revision.id,
|
||||
validFrom: outcome.revision.validFrom.toISOString(),
|
||||
validUntil: outcome.revision.validUntil.toISOString(),
|
||||
source: outcome.revision.source,
|
||||
entries: [
|
||||
{
|
||||
timesOfDay: times,
|
||||
label: null,
|
||||
dose: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -46,9 +291,18 @@ export const DELETE = apiHandler(
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.medicationScheduleRevision.delete({
|
||||
where: { id: revisionId },
|
||||
});
|
||||
// Deleting a correction restores the archived original it had
|
||||
// superseded — the audit record becomes the era again, atomically
|
||||
// with the delete.
|
||||
await prisma.$transaction([
|
||||
prisma.medicationScheduleRevision.delete({
|
||||
where: { id: revisionId },
|
||||
}),
|
||||
prisma.medicationScheduleRevision.updateMany({
|
||||
where: { medicationId: id, supersededByRevisionId: revisionId },
|
||||
data: { supersededByRevisionId: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
await auditLog("medication.schedule_revision.deleted", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* GET /api/medications/[id]/schedule-revisions
|
||||
* POST /api/medications/[id]/schedule-revisions
|
||||
* PATCH /api/medications/[id]/schedule-revisions/[revisionId]
|
||||
* DELETE /api/medications/[id]/schedule-revisions/[revisionId]
|
||||
*
|
||||
* The POST guards are the contract under test: a manual era must end
|
||||
@@ -10,12 +11,16 @@
|
||||
* archived era, and is always minted with `source: "MANUAL"`. The
|
||||
* DELETE refuses write-path archives (409) so the immutable history
|
||||
* the replace path mints can never be falsified through the API.
|
||||
* PATCH (v1.16.6) corrects an era: MANUAL updates in place, ARCHIVED
|
||||
* stays as the audit record behind a superseding MANUAL correction;
|
||||
* both write paths run inside the per-medication `FOR UPDATE`
|
||||
* transaction so concurrent era writes serialise.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
vi.mock("@/lib/db", () => {
|
||||
const prisma: Record<string, unknown> = {
|
||||
medication: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -23,10 +28,15 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
$queryRaw: vi.fn(),
|
||||
};
|
||||
prisma.$transaction = vi.fn();
|
||||
return { prisma };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -51,7 +61,7 @@ vi.mock("next/headers", () => ({
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { DELETE } from "../[revisionId]/route";
|
||||
import { DELETE, PATCH } from "../[revisionId]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
@@ -91,11 +101,30 @@ function deleteCtx(revisionId: string) {
|
||||
return { params: Promise.resolve({ id: "m1", revisionId }) };
|
||||
}
|
||||
|
||||
function patchReq(revisionId: string, body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/medications/m1/schedule-revisions/${revisionId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const MED_CREATED_AT = new Date("2026-01-10T08:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Interactive form runs the callback against the same mock client
|
||||
// (the routes only touch models the flat mock already carries); the
|
||||
// array form resolves the batched operations like Promise.all.
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (arg: unknown) =>
|
||||
typeof arg === "function"
|
||||
? (arg as (tx: typeof prisma) => unknown)(prisma)
|
||||
: Promise.all(arg as Array<Promise<unknown>>)) as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
// Ownership guard + GET both read `medication.findUnique`; the guard
|
||||
// selects `{ id, userId }`, the GET selects `{ createdAt }` — one
|
||||
// superset row satisfies both.
|
||||
@@ -216,21 +245,53 @@ describe("POST /api/medications/[id]/schedule-revisions", () => {
|
||||
});
|
||||
|
||||
it("422s when the era ends after the live plan began", async () => {
|
||||
// Realistic archive: the plan was replaced on 2026-03-01, after
|
||||
// tracking began (createdAt 2026-01-10) — the live plan has run
|
||||
// since 2026-03-01.
|
||||
vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
|
||||
{
|
||||
validFrom: MED_CREATED_AT,
|
||||
validUntil: new Date("2026-03-01T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
// Ends 2026-04-01 — after the live plan start — and does NOT
|
||||
// overlap the archived era; the boundary check must fire.
|
||||
const res = await POST(
|
||||
postReq({
|
||||
...VALID_BODY,
|
||||
validFrom: "2026-03-05T00:00:00.000Z",
|
||||
validUntil: "2026-04-01T00:00:00.000Z",
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const json = await res.json();
|
||||
expect(json.error).toMatch(/current plan/i);
|
||||
});
|
||||
|
||||
it("accepts a manual era filling the gap between an older manual era and createdAt", async () => {
|
||||
// Two-chunk pre-tracking history entered oldest-first: the second
|
||||
// chunk ends after the first chunk's validUntil but still at or
|
||||
// before createdAt. The boundary is max(createdAt, newest active
|
||||
// validUntil) — order of entry must not matter.
|
||||
vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
|
||||
{
|
||||
validFrom: new Date("2025-01-01T00:00:00Z"),
|
||||
validUntil: new Date("2025-04-01T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
// Ends 2025-06-01 — after the live plan start (2025-04-01) — and
|
||||
// does NOT overlap the archived era; the boundary check must fire.
|
||||
vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({
|
||||
id: "rev-new",
|
||||
validFrom: new Date("2025-04-01T00:00:00.000Z"),
|
||||
validUntil: new Date("2025-06-01T00:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
payload: [],
|
||||
} as never);
|
||||
const res = await POST(
|
||||
postReq({ ...VALID_BODY, validFrom: "2025-04-01T00:00:00.000Z" }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const json = await res.json();
|
||||
expect(json.error).toMatch(/current plan/i);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("422s when no revision exists and the era reaches past createdAt", async () => {
|
||||
@@ -292,6 +353,34 @@ describe("POST /api/medications/[id]/schedule-revisions", () => {
|
||||
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
|
||||
// Check-then-insert ran inside the transaction with the
|
||||
// per-medication `FOR UPDATE` lock taken first.
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collapses duplicate timesOfDay before snapshotting", async () => {
|
||||
vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({
|
||||
id: "rev-new",
|
||||
validFrom: new Date(VALID_BODY.validFrom),
|
||||
validUntil: new Date(VALID_BODY.validUntil),
|
||||
source: "MANUAL",
|
||||
payload: [],
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({ ...VALID_BODY, timesOfDay: ["19:00", "07:00", "19:00"] }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const createArgs = vi.mocked(prisma.medicationScheduleRevision.create)
|
||||
.mock.calls[0][0] as unknown as {
|
||||
data: { payload: Array<{ timesOfDay: string[] }> };
|
||||
};
|
||||
// The Zod transform dedupes + sorts — a double-tapped chip must
|
||||
// never mint a double-counted slot.
|
||||
expect(createArgs.data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,7 +411,7 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
|
||||
expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes a MANUAL era and enqueues the rollup backfill", async () => {
|
||||
it("deletes a MANUAL era, restores a superseded original, and enqueues the rollup backfill", async () => {
|
||||
vi.mocked(
|
||||
prisma.medicationScheduleRevision.findUnique,
|
||||
).mockResolvedValue({
|
||||
@@ -333,6 +422,9 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
|
||||
vi.mocked(prisma.medicationScheduleRevision.delete).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
vi.mocked(
|
||||
prisma.medicationScheduleRevision.updateMany,
|
||||
).mockResolvedValue({ count: 0 } as never);
|
||||
const res = await DELETE(deleteReq("rev-m"), deleteCtx("rev-m"));
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
@@ -340,6 +432,164 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
|
||||
expect(prisma.medicationScheduleRevision.delete).toHaveBeenCalledWith({
|
||||
where: { id: "rev-m" },
|
||||
});
|
||||
// If the deleted row was a correction, the archived original it
|
||||
// superseded becomes the era again.
|
||||
expect(prisma.medicationScheduleRevision.updateMany).toHaveBeenCalledWith({
|
||||
where: { medicationId: "m1", supersededByRevisionId: "rev-m" },
|
||||
data: { supersededByRevisionId: null },
|
||||
});
|
||||
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
|
||||
const PATCH_BODY = {
|
||||
validFrom: "2025-03-12T00:00:00.000Z",
|
||||
validUntil: "2025-06-01T00:00:00.000Z",
|
||||
timesOfDay: ["08:00", "20:00", "08:00"],
|
||||
};
|
||||
|
||||
function mockTarget(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(
|
||||
prisma.medicationScheduleRevision.findUnique,
|
||||
).mockResolvedValue({
|
||||
id: "rev-t",
|
||||
medicationId: "m1",
|
||||
source: "MANUAL",
|
||||
supersededByRevisionId: null,
|
||||
validUntil: new Date("2025-06-01T00:00:00Z"),
|
||||
...overrides,
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("404s when the revision belongs to another medication", async () => {
|
||||
mockTarget({ medicationId: "other-med" });
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", PATCH_BODY),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("409s when the era has already been corrected", async () => {
|
||||
mockTarget({ source: "ARCHIVED", supersededByRevisionId: "rev-fix" });
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", PATCH_BODY),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s when the corrected era overlaps another active era", async () => {
|
||||
mockTarget();
|
||||
vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
|
||||
{
|
||||
validFrom: new Date("2025-06-01T00:00:00Z"),
|
||||
validUntil: new Date("2025-08-01T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", { ...PATCH_BODY, validUntil: "2025-07-01T00:00:00.000Z" }),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const json = await res.json();
|
||||
expect(json.error).toMatch(/overlap/i);
|
||||
});
|
||||
|
||||
it("422s when the correction extends past the live boundary", async () => {
|
||||
// ARCHIVED era ending 2025-06-01, no other revisions; the live
|
||||
// ceiling is max(own recorded end, createdAt 2026-01-10) — a
|
||||
// correction reaching past it would swallow tracked live history.
|
||||
mockTarget({ source: "ARCHIVED" });
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", {
|
||||
...PATCH_BODY,
|
||||
validUntil: "2026-02-01T00:00:00.000Z",
|
||||
}),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const json = await res.json();
|
||||
expect(json.error).toMatch(/current plan/i);
|
||||
});
|
||||
|
||||
it("updates a MANUAL era in place with the deduped snapshot", async () => {
|
||||
mockTarget();
|
||||
vi.mocked(prisma.medicationScheduleRevision.update).mockResolvedValue({
|
||||
id: "rev-t",
|
||||
validFrom: new Date(PATCH_BODY.validFrom),
|
||||
validUntil: new Date(PATCH_BODY.validUntil),
|
||||
source: "MANUAL",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", PATCH_BODY),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.id).toBe("rev-t");
|
||||
expect(json.data.entries[0].timesOfDay).toEqual(["08:00", "20:00"]);
|
||||
|
||||
const updateArgs = vi.mocked(prisma.medicationScheduleRevision.update)
|
||||
.mock.calls[0][0] as unknown as {
|
||||
where: { id: string };
|
||||
data: { payload: Array<{ timesOfDay: string[]; rrule: string }> };
|
||||
};
|
||||
expect(updateArgs.where.id).toBe("rev-t");
|
||||
expect(updateArgs.data.payload[0].timesOfDay).toEqual(["08:00", "20:00"]);
|
||||
expect(updateArgs.data.payload[0].rrule).toBe("FREQ=DAILY");
|
||||
// In-place — no superseding row is minted.
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
// Check-then-write ran inside the locked transaction.
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("corrects an ARCHIVED era via a superseding MANUAL revision", async () => {
|
||||
mockTarget({ source: "ARCHIVED" });
|
||||
vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({
|
||||
id: "rev-fix",
|
||||
validFrom: new Date(PATCH_BODY.validFrom),
|
||||
validUntil: new Date(PATCH_BODY.validUntil),
|
||||
source: "MANUAL",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationScheduleRevision.update).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
patchReq("rev-t", PATCH_BODY),
|
||||
deleteCtx("rev-t"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
// The response carries the correction, which now takes the
|
||||
// original's place in every era read.
|
||||
expect(json.data.id).toBe("rev-fix");
|
||||
expect(json.data.source).toBe("MANUAL");
|
||||
|
||||
const createArgs = vi.mocked(prisma.medicationScheduleRevision.create)
|
||||
.mock.calls[0][0] as unknown as {
|
||||
data: { source: string; payload: Array<{ timesOfDay: string[] }> };
|
||||
};
|
||||
expect(createArgs.data.source).toBe("MANUAL");
|
||||
expect(createArgs.data.payload[0].timesOfDay).toEqual(["08:00", "20:00"]);
|
||||
// The ARCHIVED original is parked behind the correction, not
|
||||
// touched in its recorded content.
|
||||
expect(prisma.medicationScheduleRevision.update).toHaveBeenCalledWith({
|
||||
where: { id: "rev-t" },
|
||||
data: { supersededByRevisionId: "rev-fix" },
|
||||
});
|
||||
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
|
||||
@@ -86,7 +86,9 @@ export const GET = apiHandler(
|
||||
}
|
||||
|
||||
const revisions = await prisma.medicationScheduleRevision.findMany({
|
||||
where: { medicationId: id },
|
||||
// Superseded rows are audit records behind a correction — the
|
||||
// timeline shows the corrected state, like every era consumer.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validFrom: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -164,46 +166,10 @@ export const POST = apiHandler(
|
||||
return apiError("validFrom must be a date after 1900", 422);
|
||||
}
|
||||
|
||||
const existing = await prisma.medicationScheduleRevision.findMany({
|
||||
where: { medicationId: id },
|
||||
select: { validFrom: true, validUntil: true },
|
||||
});
|
||||
|
||||
// "Before the current plan": the live era began at the newest
|
||||
// archived `validUntil`; with no archive the live plan has covered
|
||||
// everything since the medication was created (the era splitter
|
||||
// reads `[newest validUntil, ∞)` — or the whole range when no
|
||||
// revision exists), so a manual era must end at or before
|
||||
// `createdAt`. Allowing it to reach "now" would let the manual
|
||||
// snapshot swallow tracked live history and re-score compliance
|
||||
// against the wrong plan.
|
||||
const liveStart = existing.reduce(
|
||||
(latest, r) => (r.validUntil > latest ? r.validUntil : latest),
|
||||
new Date(0),
|
||||
);
|
||||
const liveBoundary = existing.length > 0 ? liveStart : med.createdAt;
|
||||
if (validUntil.getTime() > liveBoundary.getTime()) {
|
||||
return apiError(
|
||||
"A manual era must end before the current plan begins",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// No overlap with any archived interval `[validFrom, validUntil)`.
|
||||
const overlaps = existing.some(
|
||||
(r) =>
|
||||
validFrom.getTime() < r.validUntil.getTime() &&
|
||||
validUntil.getTime() > r.validFrom.getTime(),
|
||||
);
|
||||
if (overlaps) {
|
||||
return apiError("The era overlaps an existing schedule era", 422);
|
||||
}
|
||||
|
||||
// Snapshot entry shaped exactly like the write-path archive: daily
|
||||
// recurrence at the given times, window pulled to their min/max.
|
||||
const times = [...parsed.data.timesOfDay].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
// The schema already deduped + sorted the times.
|
||||
const times = parsed.data.timesOfDay;
|
||||
const entry = toRevisionPayloadEntry({
|
||||
timesOfDay: times,
|
||||
windowStart: times[0],
|
||||
@@ -220,23 +186,77 @@ export const POST = apiHandler(
|
||||
reminderGraceMinutes: null,
|
||||
});
|
||||
|
||||
const revision = await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom,
|
||||
validUntil,
|
||||
source: "MANUAL",
|
||||
payload: [entry] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
source: true,
|
||||
payload: true,
|
||||
},
|
||||
// Validate-then-write under a per-medication row lock: two
|
||||
// concurrent era writes used to both pass the overlap check against
|
||||
// the same snapshot and both insert. `FOR UPDATE` on the medication
|
||||
// row serialises every era write for this medication; the reads run
|
||||
// through the same `tx` so they see the locked state.
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`
|
||||
SELECT id FROM medications
|
||||
WHERE id = ${id}
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
const existing = await tx.medicationScheduleRevision.findMany({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
select: { validFrom: true, validUntil: true },
|
||||
});
|
||||
|
||||
// "Before the current plan": the live era began at the newest
|
||||
// active `validUntil`, and never earlier than `createdAt` — the
|
||||
// era splitter reads `[newest validUntil, ∞)`, or the whole range
|
||||
// when no revision exists. Allowing a manual era to reach past
|
||||
// that instant would let the manual snapshot swallow tracked live
|
||||
// history and re-score compliance against the wrong plan.
|
||||
const liveBoundary = existing.reduce(
|
||||
(latest, r) => (r.validUntil > latest ? r.validUntil : latest),
|
||||
med.createdAt,
|
||||
);
|
||||
if (validUntil.getTime() > liveBoundary.getTime()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "A manual era must end before the current plan begins",
|
||||
} as const;
|
||||
}
|
||||
|
||||
// No overlap with any active interval `[validFrom, validUntil)`.
|
||||
const overlaps = existing.some(
|
||||
(r) =>
|
||||
validFrom.getTime() < r.validUntil.getTime() &&
|
||||
validUntil.getTime() > r.validFrom.getTime(),
|
||||
);
|
||||
if (overlaps) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "The era overlaps an existing schedule era",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const revision = await tx.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom,
|
||||
validUntil,
|
||||
source: "MANUAL",
|
||||
payload: [entry] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
source: true,
|
||||
payload: true,
|
||||
},
|
||||
});
|
||||
return { ok: true, revision } as const;
|
||||
});
|
||||
|
||||
if (!outcome.ok) {
|
||||
return apiError(outcome.error, 422);
|
||||
}
|
||||
const revision = outcome.revision;
|
||||
|
||||
await auditLog("medication.schedule_revision.created", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -99,7 +99,9 @@ async function buildMedicationsList(
|
||||
// past this boundary into a previous era's cadence.
|
||||
prisma.medicationScheduleRevision.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: { medication: { userId } },
|
||||
// Superseded rows are audit records — a correction may have
|
||||
// shortened the era, so the boundary reads only active rows.
|
||||
where: { medication: { userId }, supersededByRevisionId: null },
|
||||
_max: { validUntil: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -14,6 +14,8 @@ import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -58,6 +60,18 @@ export default function LoginPage() {
|
||||
return "/";
|
||||
}
|
||||
|
||||
// v1.16.6 first-load waterfall fix — the session cookie exists the
|
||||
// moment the login POST resolves, so the dashboard snapshot request
|
||||
// can ride in parallel with the route transition + page-chunk
|
||||
// download instead of queueing behind the dashboard mount.
|
||||
function navigateAfterLogin() {
|
||||
const target = getRedirectTarget();
|
||||
if (target === "/" && isDashboardSnapshotEnabled()) {
|
||||
prefetchDashboardSnapshot(queryClient);
|
||||
}
|
||||
router.push(target);
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
@@ -77,7 +91,7 @@ export default function LoginPage() {
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
router.push(getRedirectTarget());
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
// Route rejections carry the envelope error verbatim; everything
|
||||
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
|
||||
@@ -101,7 +115,7 @@ export default function LoginPage() {
|
||||
await apiPost("/api/auth/login", { email, password });
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
router.push(getRedirectTarget());
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
orderedVisibleSectionIds,
|
||||
type InsightsSectionId,
|
||||
} from "@/lib/insights-layout";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -221,6 +222,7 @@ interface ComprehensiveData {
|
||||
|
||||
export default function InsightsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.4.33 IW9 — scroll-to-top on route mount centralised in the
|
||||
@@ -340,8 +342,12 @@ export default function InsightsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Gated on `mounted`: the auth query can resolve before this
|
||||
// boundary hydrates, and a greeting that personalises during the
|
||||
// hydration render disagrees with the name-less SSR text — React
|
||||
// #418. See `useMounted`.
|
||||
const heroGreetingName =
|
||||
user?.username?.trim() && user.username.trim().length > 0
|
||||
mounted && user?.username?.trim() && user.username.trim().length > 0
|
||||
? user.username.split(/\s+/)[0]
|
||||
: null;
|
||||
const briefingPayload = advisor.payload?.dailyBriefing ?? null;
|
||||
|
||||
+6
-2
@@ -591,7 +591,11 @@ export default function DashboardPage() {
|
||||
// changes when the user's timezone changes; recomputing it every
|
||||
// render churned the parts-array and walked the format ICU twice per
|
||||
// render across a 1 400-line component body.
|
||||
const userTimezone = user?.timezone;
|
||||
// Gated on `mounted`: `user` comes from the auth query, which can
|
||||
// resolve before this boundary hydrates — the greeting text must
|
||||
// match the name-less SSR output during hydration (React #418) and
|
||||
// may only personalise from the first client re-render.
|
||||
const userTimezone = mounted ? user?.timezone : undefined;
|
||||
const hour = useMemo(
|
||||
() => (userTimezone ? getHourForTimeZone(userTimezone) : null),
|
||||
[userTimezone],
|
||||
@@ -605,7 +609,7 @@ export default function DashboardPage() {
|
||||
? t("dashboard.greeting.day")
|
||||
: t("dashboard.greeting.evening");
|
||||
const welcomeText =
|
||||
user?.username && user.username.trim().length > 0
|
||||
mounted && user?.username && user.username.trim().length > 0
|
||||
? t("dashboard.welcomeBackWithName", {
|
||||
greeting: timeGreeting,
|
||||
name: user.username,
|
||||
|
||||
@@ -24,10 +24,12 @@ vi.mock("next/navigation", () => ({
|
||||
// The shell gates its own frame on the confirmed ADMIN role — a
|
||||
// non-admin (or a still-loading auth state) must not see the section
|
||||
// nav while AuthShell's redirect effect is catching up.
|
||||
const mockUserRef = { value: { role: "ADMIN", username: "op" } as {
|
||||
role: string;
|
||||
username: string;
|
||||
} | null };
|
||||
const mockUserRef = {
|
||||
value: { role: "ADMIN", username: "op" } as {
|
||||
role: string;
|
||||
username: string;
|
||||
} | null,
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: mockUserRef.value }),
|
||||
}));
|
||||
|
||||
@@ -374,9 +374,7 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
|
||||
});
|
||||
|
||||
it("folds Telefónica + O2 variants down to 'O2'", () => {
|
||||
expect(
|
||||
carrierShortLabel("Telefónica Germany GmbH & Co. OHG"),
|
||||
).toBe("O2");
|
||||
expect(carrierShortLabel("Telefónica Germany GmbH & Co. OHG")).toBe("O2");
|
||||
expect(carrierShortLabel("Telefonica Germany Online Services")).toBe("O2");
|
||||
expect(carrierShortLabel("O2 Deutschland")).toBe("O2");
|
||||
});
|
||||
@@ -384,6 +382,8 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
|
||||
it("returns the raw organisation string for unknown carriers", () => {
|
||||
expect(carrierShortLabel("Google LLC")).toBe("Google LLC");
|
||||
expect(carrierShortLabel("Amazon.com, Inc.")).toBe("Amazon.com, Inc.");
|
||||
expect(carrierShortLabel("Hetzner Online GmbH")).toBe("Hetzner Online GmbH");
|
||||
expect(carrierShortLabel("Hetzner Online GmbH")).toBe(
|
||||
"Hetzner Online GmbH",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -120,9 +120,12 @@ describe("UserManagementSection — mobile responsive", () => {
|
||||
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/);
|
||||
});
|
||||
|
||||
it("stacks header rows on mobile so filter pills don't collide with title", () => {
|
||||
it("wraps the filter pills below the title on narrow viewports", () => {
|
||||
const html = render();
|
||||
// Header carries `flex-col` at base + `sm:flex-row` at small+.
|
||||
expect(html).toMatch(/class="[^"]*\bflex-col\b[^"]*sm:flex-row/);
|
||||
// The shared SettingsCardHeader title row wraps, so the status slot
|
||||
// (filter pills) reflows under the title instead of colliding with it.
|
||||
expect(html).toMatch(
|
||||
/class="[^"]*\bflex-wrap\b[^"]*items-start justify-between/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
|
||||
@@ -160,6 +161,18 @@ export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [
|
||||
"ARCHIVED",
|
||||
];
|
||||
|
||||
/** Shared green "configured" pill — the GlitchTip, Umami, bug-report,
|
||||
* and Web-Push VAPID cards all surface the same state; one component
|
||||
* keeps the tint and copy in lockstep. */
|
||||
export function ConfiguredBadge() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
|
||||
@@ -261,9 +261,9 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
// v1.4.25 W8 — chip strip is the primary mobile-admin
|
||||
// navigation surface. Pad to WCAG 2.5.5 44 px so the
|
||||
// chips can be tapped without zoom on a Pixel-5.
|
||||
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
// navigation surface. Pad to WCAG 2.5.5 44 px so the
|
||||
// chips can be tapped without zoom on a Pixel-5.
|
||||
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -66,7 +67,7 @@ export function AiQualitySection() {
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
|
||||
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-4 sm:p-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.aiQuality.loading")}
|
||||
@@ -90,14 +91,12 @@ export function AiQualitySection() {
|
||||
|
||||
if (!summary || summary.buckets.length === 0) {
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.aiQuality.title")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-sm">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.aiQuality.title")}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-3 pl-7 text-sm">
|
||||
{t("admin.aiQuality.empty")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -105,23 +104,21 @@ export function AiQualitySection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.aiQuality.title")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.aiQuality.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.aiQuality.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.aiQuality.title")}
|
||||
status={
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.aiQuality.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.aiQuality.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-slot="ai-quality-table">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { PasswordInput } from "./_shared";
|
||||
|
||||
/**
|
||||
* v1.16.6 — operator panel for the server-wide AI key.
|
||||
*
|
||||
* The backing endpoint (`/api/admin/ai-settings`, admin-cookie-only) has
|
||||
* existed since the `admin-openai` chain fallback shipped, but it never had
|
||||
* an operator UI — configuring the shared key took a manual API call. This
|
||||
* card is its home, beside the assistant feature flags.
|
||||
*
|
||||
* Semantics worth keeping visible to the operator:
|
||||
* - A user's own key (BYOK / Codex OAuth / local) always wins; the server
|
||||
* key sits at the END of the provider chain as the shared fallback.
|
||||
* - Egress over the server key is consent-gated per user
|
||||
* (`ConsentRequiredError` in `src/lib/ai/consent-guard.ts`) — setting a
|
||||
* key here never silently forwards anyone's health data.
|
||||
* - The base URL is locked server-side to HTTPS + a hostname allowlist
|
||||
* (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it).
|
||||
*/
|
||||
|
||||
interface AiServerKeyResponse {
|
||||
hasKey: boolean;
|
||||
keyPreview: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export function AiServerKeySection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: queryKeys.adminAiServerKey(),
|
||||
queryFn: () => apiGet<AiServerKeyResponse>("/api/admin/ai-settings"),
|
||||
});
|
||||
|
||||
const [keyDraft, setKeyDraft] = useState("");
|
||||
const [modelDraft, setModelDraft] = useState<string | null>(null);
|
||||
const [baseUrlDraft, setBaseUrlDraft] = useState<string | null>(null);
|
||||
|
||||
const modelValue = modelDraft ?? settings?.model ?? "";
|
||||
const baseUrlValue = baseUrlDraft ?? settings?.baseUrl ?? "";
|
||||
const configured = settings?.hasKey ?? false;
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) =>
|
||||
apiPut<AiServerKeyResponse>("/api/admin/ai-settings", payload),
|
||||
onSuccess: () => {
|
||||
setKeyDraft("");
|
||||
setModelDraft(null);
|
||||
setBaseUrlDraft(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminAiServerKey(),
|
||||
});
|
||||
toast.success(t("admin.aiServerKey.saved"));
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const payload: Record<string, unknown> = {
|
||||
model: modelValue,
|
||||
baseUrl: baseUrlValue,
|
||||
};
|
||||
if (keyDraft.trim().length > 0) {
|
||||
payload.apiKey = keyDraft.trim();
|
||||
}
|
||||
save.mutate(payload);
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
save.mutate({ apiKey: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="admin-ai-server-key"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.aiServerKey.title")}
|
||||
description={
|
||||
<>
|
||||
<p>{t("admin.aiServerKey.description")}</p>
|
||||
<p>{t("admin.aiServerKey.byokHint")}</p>
|
||||
<p>{t("admin.aiServerKey.consentHint")}</p>
|
||||
</>
|
||||
}
|
||||
status={
|
||||
<Badge variant={configured ? "default" : "outline"}>
|
||||
{configured
|
||||
? t("admin.aiServerKey.statusConfigured")
|
||||
: t("admin.aiServerKey.statusNotConfigured")}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-input">
|
||||
{t("admin.aiServerKey.keyLabel")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="admin-ai-server-key-input"
|
||||
value={keyDraft}
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
placeholder={
|
||||
configured && settings?.keyPreview
|
||||
? t("admin.aiServerKey.keyPlaceholderSet", {
|
||||
preview: settings.keyPreview,
|
||||
})
|
||||
: t("admin.aiServerKey.keyPlaceholder")
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-model">
|
||||
{t("admin.aiServerKey.modelLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-ai-server-key-model"
|
||||
value={modelValue}
|
||||
onChange={(e) => setModelDraft(e.target.value)}
|
||||
placeholder="gpt-4o"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-base-url">
|
||||
{t("admin.aiServerKey.baseUrlLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-ai-server-key-base-url"
|
||||
value={baseUrlValue}
|
||||
onChange={(e) => setBaseUrlDraft(e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={save.isPending}
|
||||
data-slot="admin-ai-server-key-save"
|
||||
>
|
||||
{save.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.aiServerKey.save")}
|
||||
</Button>
|
||||
{configured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={save.isPending}
|
||||
data-slot="admin-ai-server-key-remove"
|
||||
>
|
||||
{t("admin.aiServerKey.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type ApiTokenInfo } from "./_shared";
|
||||
@@ -157,10 +158,11 @@ export function ApiTokenOverviewSection() {
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border overflow-hidden rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.apiTokens")}</div>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Key}
|
||||
title={t("admin.apiTokens")}
|
||||
description={t("admin.apiTokensDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { WideEvent, LogLevel } from "@/lib/logging/types";
|
||||
@@ -104,7 +105,9 @@ export function AppLogPreviewSection() {
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range),
|
||||
queryFn: async () => {
|
||||
return apiGet<AppLogsResponse>(`/api/admin/app-logs?${params.toString()}`);
|
||||
return apiGet<AppLogsResponse>(
|
||||
`/api/admin/app-logs?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
@@ -112,31 +115,25 @@ export function AppLogPreviewSection() {
|
||||
const events = data?.events ?? [];
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="text-muted-foreground mt-0.5 h-5 w-5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.section.app-logs.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.section.app-logs.processNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label={t("admin.section.app-logs.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
|
||||
/>
|
||||
</Button>
|
||||
</header>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={FileText}
|
||||
title={t("admin.section.app-logs.title")}
|
||||
description={t("admin.section.app-logs.processNote")}
|
||||
status={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label={t("admin.section.app-logs.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SettingsToggle } from "./_shared";
|
||||
@@ -49,7 +50,9 @@ function useAssistantFlags() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
queryFn: async () => {
|
||||
return apiGet<AssistantFlagsResponse>("/api/admin/settings/assistant-flags");
|
||||
return apiGet<AssistantFlagsResponse>(
|
||||
"/api/admin/settings/assistant-flags",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -61,13 +64,13 @@ function useUpdateAssistantFlags() {
|
||||
mutationFn: async (
|
||||
patch: Partial<AssistantFlagsResponse["raw"]>,
|
||||
): Promise<AssistantFlagsResponse> => {
|
||||
return apiPut<AssistantFlagsResponse>("/api/admin/settings/assistant-flags", patch);
|
||||
return apiPut<AssistantFlagsResponse>(
|
||||
"/api/admin/settings/assistant-flags",
|
||||
patch,
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
client.setQueryData(
|
||||
queryKeys.adminAssistantFlags(),
|
||||
data,
|
||||
);
|
||||
client.setQueryData(queryKeys.adminAssistantFlags(), data);
|
||||
// Bust the runtime `/api/feature-flags` cache so the operator
|
||||
// sees the toggled surface react within the same session.
|
||||
client.invalidateQueries({ queryKey: queryKeys.featureFlags() });
|
||||
@@ -96,73 +99,72 @@ export function AssistantSection() {
|
||||
const disabledSubs = !masterOn || mutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.assistant.title")}</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground pl-7 text-sm">
|
||||
{t("admin.assistant.description")}
|
||||
</p>
|
||||
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.master.title")}
|
||||
description={t("admin.assistant.master.description")}
|
||||
checked={raw?.assistantEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantEnabled: checked })
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.assistant.title")}
|
||||
description={t("admin.assistant.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.master.title")}
|
||||
description={t("admin.assistant.master.description")}
|
||||
checked={raw?.assistantEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantEnabled: checked })
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
|
||||
<div className="border-border space-y-4 border-t pt-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.coach.title")}
|
||||
description={t("admin.assistant.coach.description")}
|
||||
checked={raw?.assistantCoachEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCoachEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.briefing.title")}
|
||||
description={t("admin.assistant.briefing.description")}
|
||||
checked={raw?.assistantBriefingEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantBriefingEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.insightStatus.title")}
|
||||
description={t("admin.assistant.insightStatus.description")}
|
||||
checked={raw?.assistantInsightStatusEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantInsightStatusEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.correlations.title")}
|
||||
description={t("admin.assistant.correlations.description")}
|
||||
checked={raw?.assistantCorrelationsEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCorrelationsEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.healthScoreExplainer.title")}
|
||||
description={t("admin.assistant.healthScoreExplainer.description")}
|
||||
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
assistantHealthScoreExplainerEnabled: checked,
|
||||
})
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<div className="border-border space-y-4 border-t pt-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.coach.title")}
|
||||
description={t("admin.assistant.coach.description")}
|
||||
checked={raw?.assistantCoachEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCoachEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.briefing.title")}
|
||||
description={t("admin.assistant.briefing.description")}
|
||||
checked={raw?.assistantBriefingEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantBriefingEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.insightStatus.title")}
|
||||
description={t("admin.assistant.insightStatus.description")}
|
||||
checked={raw?.assistantInsightStatusEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantInsightStatusEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.correlations.title")}
|
||||
description={t("admin.assistant.correlations.description")}
|
||||
checked={raw?.assistantCorrelationsEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCorrelationsEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.healthScoreExplainer.title")}
|
||||
description={t("admin.assistant.healthScoreExplainer.description")}
|
||||
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
assistantHealthScoreExplainerEnabled: checked,
|
||||
})
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
@@ -331,34 +332,34 @@ export function BackupsSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.section.backups.title")}
|
||||
</div>
|
||||
{data && (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Database}
|
||||
title={t("admin.section.backups.title")}
|
||||
titleAccessory={
|
||||
data ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{rows.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
>
|
||||
{runBackup.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.runNow")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
) : null
|
||||
}
|
||||
status={
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
>
|
||||
{runBackup.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.runNow")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.section.backups.description")}{" "}
|
||||
{/* External docs link — Phase E will publish the matching page on
|
||||
the docs site. `noopener noreferrer` because this leaves the
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bug, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import {
|
||||
ConfiguredBadge,
|
||||
PasswordInput,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function BugReportSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -45,27 +50,15 @@ export function BugReportSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bug className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.bugReportGithub")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.bugReportGithubDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Bug}
|
||||
title={t("admin.bugReportGithub")}
|
||||
description={t("admin.bugReportGithubDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="bg-muted/40 mt-4 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<div className="bg-muted/40 mt-4 ml-7 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.bugReportEnabledLabel")}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -69,25 +70,23 @@ export function CoachFeedbackSection() {
|
||||
// /admin/coach-feedback. Mirrors the canonical structure used by
|
||||
// <SystemStatusSection> (header outside the fetch-state branch).
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.coachFeedback.title")}
|
||||
</div>
|
||||
</div>
|
||||
{hasData && summary && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.coachFeedback.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.coachFeedback.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.coachFeedback.title")}
|
||||
status={
|
||||
hasData && summary ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.coachFeedback.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.coachFeedback.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -59,7 +59,7 @@ export function DangerZoneSection() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-6">
|
||||
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-4 sm:p-6">
|
||||
{/* v1.4.19 A8 / F-08: page header `admin.section.danger-zone.*`
|
||||
already names the section, so the card-level title was a
|
||||
duplicate of the page title. The destructive icon stays so
|
||||
|
||||
@@ -54,7 +54,9 @@ export function FeedbackInboxSection() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.adminFeedback(activeStatus),
|
||||
queryFn: async () => {
|
||||
return apiGet<FeedbackListResponse>(`/api/admin/feedback?status=${activeStatus}&limit=100`);
|
||||
return apiGet<FeedbackListResponse>(
|
||||
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,7 +67,7 @@ export function FeedbackInboxSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* v1.4.19 A8 / F-08 + F-20: this is the only card on
|
||||
`/admin/feedback`, so the page header already provides the
|
||||
title + description — the card-level repetition was just
|
||||
@@ -287,7 +289,9 @@ function FeedbackDetailDialog({
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<{ issueUrl: string }>(`/api/admin/feedback/${item.id}/github`);
|
||||
return apiPost<{ issueUrl: string }>(
|
||||
`/api/admin/feedback/${item.id}/github`,
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setIssueUrl(data.issueUrl);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
@@ -25,12 +26,13 @@ export function GeneralSettingsSection() {
|
||||
const currentDefaultTz = settings?.defaultUserTimezone ?? "";
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.appSettings")}</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Settings}
|
||||
title={t("admin.appSettings")}
|
||||
description={t("admin.appSettingsDescription")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.registrationEnabled")}
|
||||
description={t("admin.registrationEnabledDescription")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SettingsToggle,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
ConfiguredBadge,
|
||||
} from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -68,25 +69,15 @@ export function GlitchtipSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.glitchtipTitle")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.glitchtipDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.glitchtipTitle")}
|
||||
description={t("admin.glitchtipDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.glitchtipEnabled")}
|
||||
icon={AlertTriangle}
|
||||
|
||||
@@ -314,12 +314,15 @@ export function InviteTokensSection() {
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ticket className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<Ticket
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 id="admin-invites-title" className="text-lg font-semibold">
|
||||
{t("admin.invites.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-sm">
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.invites.description")}
|
||||
</p>
|
||||
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 pl-7 text-xs">
|
||||
@@ -559,79 +562,79 @@ export function InviteTokensSection() {
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">
|
||||
{t("admin.invites.ttlLegend")}
|
||||
</legend>
|
||||
<div
|
||||
className="mt-2 grid grid-cols-3 gap-2"
|
||||
role="radiogroup"
|
||||
>
|
||||
{TTL_CHOICES.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={ttlDays === days}
|
||||
onClick={() => setTtlDays(days)}
|
||||
data-testid={`admin-invites-ttl-${days}`}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
ttlDays === days
|
||||
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{t("admin.invites.createDays", { days })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-invites-max-uses">
|
||||
{t("admin.invites.maxUsesLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-invites-max-uses"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_USES_CAP}
|
||||
step={1}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Echo the submit-time clamp (1–50, integer) into
|
||||
// the field so the admin sees what will be minted.
|
||||
setMaxUses(String(clampMaxUses(maxUses)));
|
||||
}}
|
||||
className="w-24 tabular-nums"
|
||||
data-testid="admin-invites-max-uses"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.maxUsesHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
aria-busy={create.isPending || undefined}
|
||||
data-testid="admin-invites-submit-create"
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
<div className="space-y-5">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">
|
||||
{t("admin.invites.ttlLegend")}
|
||||
</legend>
|
||||
<div
|
||||
className="mt-2 grid grid-cols-3 gap-2"
|
||||
role="radiogroup"
|
||||
>
|
||||
{TTL_CHOICES.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={ttlDays === days}
|
||||
onClick={() => setTtlDays(days)}
|
||||
data-testid={`admin-invites-ttl-${days}`}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
ttlDays === days
|
||||
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{t("admin.invites.createDays", { days })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-invites-max-uses">
|
||||
{t("admin.invites.maxUsesLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-invites-max-uses"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_USES_CAP}
|
||||
step={1}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Echo the submit-time clamp (1–50, integer) into
|
||||
// the field so the admin sees what will be minted.
|
||||
setMaxUses(String(clampMaxUses(maxUses)));
|
||||
}}
|
||||
className="w-24 tabular-nums"
|
||||
data-testid="admin-invites-max-uses"
|
||||
/>
|
||||
) : (
|
||||
<Ticket className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("admin.invites.createConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.maxUsesHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
aria-busy={create.isPending || undefined}
|
||||
data-testid="admin-invites-submit-create"
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Ticket className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("admin.invites.createConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toCSV } from "@/lib/export";
|
||||
@@ -131,7 +132,9 @@ export function LoginOverviewSection() {
|
||||
if (filter === "failed") {
|
||||
params.set("action", "auth.login.failed");
|
||||
}
|
||||
return apiGet<AuditLogResponse>(`/api/admin/audit-log?${params.toString()}`);
|
||||
return apiGet<AuditLogResponse>(
|
||||
`/api/admin/audit-log?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -193,11 +196,12 @@ export function LoginOverviewSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.loginOverview")}</div>
|
||||
</div>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={ScrollText}
|
||||
title={t("admin.loginOverview")}
|
||||
description={t("admin.loginOverviewDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Quick-filter pills. Failed-only is the most common
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminAuditEntry, useAuthActionLabels } from "./_shared";
|
||||
@@ -38,7 +39,9 @@ export function RecentAuditPreview() {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.adminAuditOverview(),
|
||||
queryFn: async () => {
|
||||
return apiGet<AuditLogResponse>(`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`);
|
||||
return apiGet<AuditLogResponse>(
|
||||
`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`,
|
||||
);
|
||||
},
|
||||
// Avoid hitting the DB on every page focus; the overview is the
|
||||
// type of page admins keep open and re-render frequently.
|
||||
@@ -52,26 +55,22 @@ export function RecentAuditPreview() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-audit-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-audit-heading"
|
||||
className="text-lg font-semibold"
|
||||
<SettingsCardHeader
|
||||
icon={ScrollText}
|
||||
titleId="admin-overview-audit-heading"
|
||||
title={t("admin.overview.auditTitle")}
|
||||
status={
|
||||
<Link
|
||||
href="/admin/login-overview"
|
||||
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
|
||||
>
|
||||
{t("admin.overview.auditTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/login-overview"
|
||||
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
|
||||
>
|
||||
{t("admin.overview.auditViewAll")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
{t("admin.overview.auditViewAll")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
@@ -91,21 +92,17 @@ export function RemindersSection() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* v1.4.22 D / F-58 — icon swap so the section header matches
|
||||
the admin-shell nav tile (Bell). Clock now lives on the
|
||||
timing chips inline instead of competing as the page icon. */}
|
||||
<Bell className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.medicationReminders")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.medicationRemindersDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* v1.4.22 D / F-58 — icon swap so the section header matches
|
||||
the admin-shell nav tile (Bell). Clock now lives on the
|
||||
timing chips inline instead of competing as the page icon. */}
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("admin.medicationReminders")}
|
||||
description={t("admin.medicationRemindersDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-late" className="text-xs">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
@@ -10,15 +11,13 @@ export function ServicesSection() {
|
||||
const updateSettings = useUpdateSettings();
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.servicesGlobal")}</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.servicesGlobalDescription")}
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Globe}
|
||||
title={t("admin.servicesGlobal")}
|
||||
description={t("admin.servicesGlobalDescription")}
|
||||
/>
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<SettingsToggle
|
||||
label="Telegram"
|
||||
description={t("admin.telegramGlobal")}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
|
||||
@@ -51,13 +52,14 @@ export function SystemStatusSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<HostMetricsChart />
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.systemStatus")}</div>
|
||||
</div>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Server}
|
||||
title={t("admin.systemStatus")}
|
||||
description={t("admin.systemStatusDescription")}
|
||||
/>
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatusItem
|
||||
icon={Database}
|
||||
label={t("admin.database")}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
|
||||
@@ -34,20 +35,16 @@ export function SystemStatusSummary() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-snapshot-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-snapshot-heading"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("admin.overview.snapshotTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Server}
|
||||
titleId="admin-overview-snapshot-heading"
|
||||
title={t("admin.overview.snapshotTitle")}
|
||||
/>
|
||||
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.snapshotVersion")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SettingsToggle,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
ConfiguredBadge,
|
||||
} from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -71,25 +72,15 @@ export function UmamiSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.umamiTitle")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.umamiDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Activity}
|
||||
title={t("admin.umamiTitle")}
|
||||
description={t("admin.umamiDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.umamiEnabled")}
|
||||
icon={Activity}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
@@ -220,18 +221,12 @@ export function UserManagementSection() {
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* Header: stacks vertically on mobile so the filter pills get
|
||||
their own row instead of overlapping the title. The audit
|
||||
flagged this as CRITICAL #2 — at 393px the filter row was
|
||||
colliding with the count Badge and the user-count digit was
|
||||
floating into the title's gap. */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.userManagement")}
|
||||
</div>
|
||||
{filteredUsers && (
|
||||
<SettingsCardHeader
|
||||
icon={Users}
|
||||
title={t("admin.userManagement")}
|
||||
description={t("admin.userManagementDescription")}
|
||||
titleAccessory={
|
||||
filteredUsers ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredUsers.length}
|
||||
{filter !== "all" &&
|
||||
@@ -242,24 +237,25 @@ export function UserManagementSection() {
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Filters mirror the Settings-style horizontal pills */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["all", "admin", "user"] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={filter === value ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={() => setFilter(value)}
|
||||
aria-pressed={filter === value}
|
||||
>
|
||||
{t(`admin.section.users.filter.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
status={
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["all", "admin", "user"] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={filter === value ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={() => setFilter(value)}
|
||||
aria-pressed={filter === value}
|
||||
>
|
||||
{t(`admin.section.users.filter.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{filteredUsers ? (
|
||||
filteredUsers.length === 0 ? (
|
||||
|
||||
@@ -16,9 +16,16 @@
|
||||
* (`/admin`), which gates on `requireAdmin()`.
|
||||
*/
|
||||
|
||||
import { ArrowUpCircle, CheckCircle2, Clock, GitCommit, Tag } from "lucide-react";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
GitCommit,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -77,42 +84,39 @@ export function VersionTileSection() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-version-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-version-heading"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("admin.overview.versionTileTitle")}
|
||||
</h2>
|
||||
<SettingsCardHeader
|
||||
icon={Tag}
|
||||
titleId="admin-overview-version-heading"
|
||||
title={t("admin.overview.versionTileTitle")}
|
||||
status={
|
||||
newer ? (
|
||||
<a
|
||||
href={
|
||||
(update.status === "newer_available" && update.html_url) ||
|
||||
"https://github.com/MBombeck/HealthLog/releases/latest"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpdateAvailable", {
|
||||
tag:
|
||||
update.status === "newer_available" ? update.latest_tag : "",
|
||||
})}
|
||||
</a>
|
||||
) : upToDate ? (
|
||||
<span className="text-dracula-green inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpToDate")}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{newer ? (
|
||||
<a
|
||||
href={
|
||||
(update.status === "newer_available" && update.html_url) ||
|
||||
"https://github.com/MBombeck/HealthLog/releases/latest"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 ml-auto inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpdateAvailable", {
|
||||
tag:
|
||||
update.status === "newer_available" ? update.latest_tag : "",
|
||||
})}
|
||||
</a>
|
||||
) : upToDate ? (
|
||||
<span className="text-dracula-green ml-auto inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpToDate")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.versionTileVersion")}
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { BellRing, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import {
|
||||
ConfiguredBadge,
|
||||
PasswordInput,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function WebPushVapidSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -48,27 +53,15 @@ export function WebPushVapidSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.webPushVapidTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.webPushVapidDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={BellRing}
|
||||
title={t("admin.webPushVapidTitle")}
|
||||
description={t("admin.webPushVapidDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-public-key" className="text-xs">
|
||||
{t("admin.webPushVapidPublicKey")}
|
||||
|
||||
@@ -203,6 +203,58 @@ describe("guidedReducer — adoption settling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("guidedReducer — reaction-then-adopt sequencing (v1.16.6)", () => {
|
||||
// With a provider, the turn streams a Coach reaction and the flow
|
||||
// waits in `answered` until the adopt offer settles; the settle of
|
||||
// the CURRENT outcome is what advances the sequence.
|
||||
it("ADOPTION_SETTLED for the current outcome advances to the next question", () => {
|
||||
const state = run([
|
||||
{ type: "START", questions: QUESTIONS },
|
||||
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
|
||||
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
|
||||
]);
|
||||
if (state.phase !== "asking") throw new Error("unreachable");
|
||||
expect(state.index).toBe(1);
|
||||
expect(state.outcomes[0].adoption).toBe("adopted");
|
||||
});
|
||||
|
||||
it("ADOPTION_SETTLED on the last answer lands on the summary", () => {
|
||||
const state = run([
|
||||
{ type: "START", questions: ["only one"] },
|
||||
{ type: "ANSWER_SUBMITTED", answer: "An answer." },
|
||||
{ type: "ADOPTION_SETTLED", index: 0, adoption: "declined" },
|
||||
]);
|
||||
expect(state.phase).toBe("summary");
|
||||
if (state.phase !== "summary") throw new Error("unreachable");
|
||||
expect(state.outcomes[0].adoption).toBe("declined");
|
||||
});
|
||||
|
||||
it("a late settle for an older outcome does not advance", () => {
|
||||
// Silent (provider-less) flow already advanced via TURN_COMPLETE;
|
||||
// question 2 is answered and streaming when outcome 0 settles.
|
||||
const state = run([
|
||||
{ type: "START", questions: QUESTIONS },
|
||||
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
|
||||
{ type: "TURN_COMPLETE" },
|
||||
{ type: "ANSWER_SUBMITTED", answer: "Sleep." },
|
||||
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
|
||||
]);
|
||||
if (state.phase !== "answered") throw new Error("unreachable");
|
||||
expect(state.index).toBe(1);
|
||||
expect(state.outcomes[0].adoption).toBe("adopted");
|
||||
});
|
||||
|
||||
it("the silent flow still advances on TURN_COMPLETE alone", () => {
|
||||
const state = run([
|
||||
{ type: "START", questions: QUESTIONS },
|
||||
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
|
||||
{ type: "TURN_COMPLETE" },
|
||||
]);
|
||||
if (state.phase !== "asking") throw new Error("unreachable");
|
||||
expect(state.index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveThreadItems", () => {
|
||||
it("idle and done contribute nothing", () => {
|
||||
expect(deriveThreadItems(GUIDED_IDLE)).toEqual([]);
|
||||
|
||||
@@ -204,9 +204,12 @@ export function CoachConversation({
|
||||
dismissQuestions.mutate(guidedQuestion);
|
||||
}
|
||||
setInputValue("");
|
||||
await send.send({
|
||||
// v1.16.6 — hand the question to the turn so the Coach reaction is
|
||||
// contextual (the question bubble itself is never persisted).
|
||||
const resolvedId = await send.send({
|
||||
conversationId: currentConversationId ?? undefined,
|
||||
message: trimmed,
|
||||
guidedQuestion: guidedQuestion ?? undefined,
|
||||
});
|
||||
if (guidedQuestion !== null && guidedIndex !== null) {
|
||||
setPendingAdopt({
|
||||
@@ -214,9 +217,15 @@ export function CoachConversation({
|
||||
answer: trimmed,
|
||||
guidedIndex,
|
||||
});
|
||||
// The assistant turn is done — advance to the next question (or
|
||||
// the closing summary).
|
||||
dispatchGuided({ type: "TURN_COMPLETE" });
|
||||
// v1.16.6 — sequence per answer: answer → Coach reaction
|
||||
// (streamed above) → adopt offer → next question. A resolved id
|
||||
// means the reaction landed; the machine then advances when the
|
||||
// adopt offer settles (ADOPTION_SETTLED). A null id is the
|
||||
// provider-less / errored turn — no reaction is coming, so keep
|
||||
// the original silent flow and advance immediately.
|
||||
if (resolvedId === null) {
|
||||
dispatchGuided({ type: "TURN_COMPLETE" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,16 @@
|
||||
*
|
||||
* idle ──START──▶ asking ──ANSWER_SUBMITTED──▶ answered
|
||||
* │ │ ▲ │
|
||||
* LATER SKIP└──────TURN_COMPLETE────────┘
|
||||
* │ │ (next question, or…)
|
||||
* ▼ ├──EXIT/SKIP-on-last──▶ summary (answers exist)
|
||||
* done ◀────────────┴──────────────────────▶ done (none answered)
|
||||
* LATER SKIP└── TURN_COMPLETE ──────────┤
|
||||
* │ │ (provider-less: advance │
|
||||
* │ │ right after the send) │
|
||||
* │ │ ▲ │
|
||||
* │ └── ADOPTION_SETTLED ─────────┘
|
||||
* │ │ (current outcome: Coach reaction has
|
||||
* │ │ streamed, the adopt offer settled —
|
||||
* ▼ │ NOW the next question may appear)
|
||||
* done ◀────────────┴──EXIT/SKIP-on-last──▶ summary (answers exist)
|
||||
* ──▶ done (none answered)
|
||||
*
|
||||
* Server contract stays additive-free: answering dismisses that one
|
||||
* question (existing DELETE), "skip" / "later" leave questions pending
|
||||
@@ -140,11 +146,7 @@ export function guidedReducer(
|
||||
|
||||
case "TURN_COMPLETE": {
|
||||
if (state.phase !== "answered") return state;
|
||||
const next = state.index + 1;
|
||||
if (next < state.questions.length) {
|
||||
return { ...state, phase: "asking", index: next };
|
||||
}
|
||||
return finish(state);
|
||||
return advance(state);
|
||||
}
|
||||
|
||||
case "SKIP": {
|
||||
@@ -174,11 +176,38 @@ export function guidedReducer(
|
||||
? { ...o, adoption: event.adoption }
|
||||
: o,
|
||||
);
|
||||
// v1.16.6 — when a provider reacted to the answer the sequence
|
||||
// waits in `answered` until the adopt offer settles, so the
|
||||
// transcript reads answer → Coach reaction → adopt outcome →
|
||||
// next question. Settling the CURRENT outcome is that signal;
|
||||
// a late settle for an older outcome (provider-less flow, where
|
||||
// TURN_COMPLETE already advanced) only updates the tally.
|
||||
if (state.phase === "answered" && event.index === state.index) {
|
||||
return advance({ ...state, outcomes });
|
||||
}
|
||||
return { ...state, outcomes };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Move past the current question: next one, or close the sequence. */
|
||||
function advance(state: {
|
||||
questions: string[];
|
||||
index: number;
|
||||
outcomes: GuidedOutcome[];
|
||||
}): GuidedState {
|
||||
const next = state.index + 1;
|
||||
if (next < state.questions.length) {
|
||||
return {
|
||||
phase: "asking",
|
||||
questions: state.questions,
|
||||
index: next,
|
||||
outcomes: state.outcomes,
|
||||
};
|
||||
}
|
||||
return finish(state);
|
||||
}
|
||||
|
||||
/** Count of answers actually written into the self-context. */
|
||||
export function countAdopted(outcomes: GuidedOutcome[]): number {
|
||||
return outcomes.filter((o) => o.adoption === "adopted").length;
|
||||
|
||||
@@ -236,6 +236,12 @@ export interface SendCoachMessageParams {
|
||||
* back to all-source last30days, matching the legacy behaviour.
|
||||
*/
|
||||
scope?: CoachScope;
|
||||
/**
|
||||
* v1.16.6 — guided clarifying-questions flow: the pending question
|
||||
* this message answers. Server-side prompt context only; never
|
||||
* persisted with the message.
|
||||
*/
|
||||
guidedQuestion?: string;
|
||||
}
|
||||
|
||||
export interface UseSendCoachMessageOptions {
|
||||
@@ -286,7 +292,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
async (params: SendCoachMessageParams): Promise<void> => {
|
||||
async (
|
||||
params: SendCoachMessageParams,
|
||||
): Promise<string | null> => {
|
||||
// Cancel any prior in-flight request.
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
@@ -322,7 +330,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
messageId: null,
|
||||
errorCode: "coach.network",
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// apiFetchRaw: the chat POST streams SSE — the envelope helpers
|
||||
@@ -341,11 +349,12 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
prefill: params.prefill,
|
||||
locale: params.locale,
|
||||
scope: params.scope,
|
||||
guidedQuestion: params.guidedQuestion,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
if ((err as Error).name === "AbortError") return null;
|
||||
setStreaming({
|
||||
content: "",
|
||||
metricSource: null,
|
||||
@@ -353,7 +362,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
messageId: null,
|
||||
errorCode: "coach.network",
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
@@ -364,7 +373,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
messageId: null,
|
||||
errorCode: `coach.http.${response.status}`,
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Even on `!response.ok`, the route may have emitted a structured
|
||||
@@ -405,7 +414,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
// the bubble left the error standing next to an empty thread.
|
||||
// The content-equality dedupe in <MessageThread> still
|
||||
// suppresses the optimistic copy if a persisted twin lands.
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
@@ -495,6 +504,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
if (resolvedConversationId || !lastError) {
|
||||
setOptimisticUser(null);
|
||||
}
|
||||
return resolvedConversationId;
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
@@ -470,3 +470,74 @@ describe("medication card body — shared shell + dose-state presentation", () =
|
||||
expect(html).toContain("min-h-[2.75rem] space-y-3.5 text-sm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compliance bars — fixed geometry (v1.16.6)", () => {
|
||||
/**
|
||||
* The two percentage rows must keep an IDENTICAL, fixed geometry no
|
||||
* matter the rates, labels, or any status colouring around them: each
|
||||
* label line is pinned to one text line (`h-5` + `truncate`) and each
|
||||
* bar to `h-2`. A regression here lets a wrapping label or a
|
||||
* status-dependent caption make the two bars sit at unequal heights
|
||||
* with stray space below (the v1.16.4 card report).
|
||||
*/
|
||||
function geometrySignature(html: string): {
|
||||
labelRows: number;
|
||||
bars: number;
|
||||
truncated: number;
|
||||
} {
|
||||
return {
|
||||
labelRows: (html.match(/flex h-5 items-center justify-between/g) ?? [])
|
||||
.length,
|
||||
bars: (html.match(/data-slot="progress"/g) ?? []).length,
|
||||
truncated: (html.match(/truncate/g) ?? []).length,
|
||||
};
|
||||
}
|
||||
|
||||
it("pins both rows to the same fixed-height markup regardless of rates", () => {
|
||||
const low = render(
|
||||
<MedicationComplianceBars rate7={0} rate30={13} streak={0} />,
|
||||
);
|
||||
const high = render(
|
||||
<MedicationComplianceBars rate7={100} rate30={100} streak={9} />,
|
||||
);
|
||||
const wide = render(
|
||||
<MedicationComplianceBars
|
||||
rate7={50}
|
||||
rate30={50}
|
||||
streak={0}
|
||||
shortDays={90}
|
||||
longDays={365}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const html of [low, high, wide]) {
|
||||
const sig = geometrySignature(html);
|
||||
expect(sig.labelRows).toBe(2);
|
||||
expect(sig.bars).toBe(2);
|
||||
expect(sig.truncated).toBe(2);
|
||||
// Both bars carry the same fixed track height.
|
||||
expect(html.match(/data-slot="progress"[^>]*h-2/g) ?? []).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("rate / streak changes alter text only, never the geometry classes", () => {
|
||||
const a = render(
|
||||
<MedicationComplianceBars rate7={0} rate30={0} streak={0} />,
|
||||
);
|
||||
const b = render(
|
||||
<MedicationComplianceBars rate7={100} rate30={42} streak={0} />,
|
||||
);
|
||||
// Strip text nodes; the remaining tag/class skeleton must be identical
|
||||
// between a 0% and a 100% card (transform style varies with the value
|
||||
// by design, so it is normalised too).
|
||||
const skeleton = (html: string) =>
|
||||
html
|
||||
.replace(/>[^<]+</g, "><")
|
||||
.replace(/transform:[^;"]+/g, "transform:X")
|
||||
.replace(/aria-valuenow="\d+"/g, 'aria-valuenow="N"')
|
||||
.replace(/data-state="[^"]*"/g, 'data-state="S"')
|
||||
.replace(/data-value="[^"]*"/g, 'data-value="V"')
|
||||
.replace(/data-max="[^"]*"/g, 'data-max="M"');
|
||||
expect(skeleton(a)).toBe(skeleton(b));
|
||||
});
|
||||
});
|
||||
@@ -61,27 +61,35 @@ export function MedicationComplianceBars({
|
||||
const shortPct = fmt.number(Math.round(rate7));
|
||||
const longPct = fmt.number(Math.round(rate30));
|
||||
|
||||
// v1.16.6 — both rows carry an identical, FIXED geometry: the label line
|
||||
// is pinned to one text line (`h-5` + `truncate`) and the bar to `h-2`,
|
||||
// so neither a long locale label wrapping, nor the `?` trigger, nor any
|
||||
// status-coloured caption can make the two rows differ in height. Status
|
||||
// is colour / text only — never geometry; the two bars on a card (and
|
||||
// across a grid row) always align.
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
{shortLabel}
|
||||
<div className="flex h-5 items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex min-w-0 items-center gap-1">
|
||||
<span className="truncate">{shortLabel}</span>
|
||||
{/* One `?` for the whole block — both rows measure the same
|
||||
thing over different windows, so a second trigger on the
|
||||
long row would only add noise. */}
|
||||
<ComplianceInfoTip />
|
||||
</span>
|
||||
<span className="font-medium">{shortPct}%</span>
|
||||
<span className="shrink-0 font-medium">{shortPct}%</span>
|
||||
</div>
|
||||
{/* aria-label so the bar has an accessible name. */}
|
||||
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{longLabel}</span>
|
||||
<span className="font-medium">{longPct}%</span>
|
||||
<div className="flex h-5 items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground min-w-0 truncate">
|
||||
{longLabel}
|
||||
</span>
|
||||
<span className="shrink-0 font-medium">{longPct}%</span>
|
||||
</div>
|
||||
<Progress value={rate30} className="h-2" aria-label={longLabel} />
|
||||
</div>
|
||||
@@ -121,14 +129,14 @@ export function MedicationComplianceSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2.5" aria-hidden>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex h-5 items-center justify-between">
|
||||
<span className="bg-muted h-4 w-20 rounded" />
|
||||
<span className="bg-muted h-4 w-9 rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-2 rounded" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex h-5 items-center justify-between">
|
||||
<span className="bg-muted h-4 w-20 rounded" />
|
||||
<span className="bg-muted h-4 w-9 rounded" />
|
||||
</div>
|
||||
|
||||
@@ -318,6 +318,16 @@ export function Glp1MedicationCard({
|
||||
active: medication.active,
|
||||
lastTakenAt: medication.lastTakenAt,
|
||||
todayEventCount: medication.todayEventCount ?? 0,
|
||||
// v1.16.6 — gate the pill on the server display-due so a rolling
|
||||
// cadence whose next injection is days away can never paint an overdue
|
||||
// pill today. `undefined` (older payloads / fixtures) keeps legacy
|
||||
// behaviour; `next` is already the parsed-and-validated `nextDueAt`.
|
||||
nextDue:
|
||||
medication.nextDueAt === undefined
|
||||
? undefined
|
||||
: next
|
||||
? { at: next.date, overdue: medication.nextDueOverdue === true }
|
||||
: null,
|
||||
});
|
||||
|
||||
// v1.12.3 — slot instant of the injection this card is showing (the
|
||||
|
||||
@@ -251,6 +251,15 @@ export function MedicationCard({
|
||||
active: medication.active,
|
||||
lastTakenAt: medication.lastTakenAt,
|
||||
todayEventCount: medication.todayEventCount ?? 0,
|
||||
// v1.16.6 — gate the pill on the server display-due so a rolling
|
||||
// cadence whose next dose is tomorrow can never paint an overdue pill
|
||||
// today. `undefined` (older payloads / fixtures) keeps legacy behaviour.
|
||||
nextDue:
|
||||
medication.nextDueAt === undefined
|
||||
? undefined
|
||||
: nextAt !== undefined
|
||||
? { at: new Date(nextAt), overdue: medication.nextDueOverdue === true }
|
||||
: null,
|
||||
});
|
||||
|
||||
// v1.12.3 — the slot instant of the dose this card is currently showing
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* the chip + delete affordance, ARCHIVED eras get neither.
|
||||
* 4. No revisions: no toggle, the add-era CTA still renders.
|
||||
* 5. `eraTimes` dedupes/sorts across entries and drops non-arrays.
|
||||
* 6. v1.16.6: every era row (MANUAL and ARCHIVED) carries the edit
|
||||
* pencil; only MANUAL rows carry delete.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -130,9 +132,13 @@ describe("ScheduleHistoryTimeline", () => {
|
||||
expect(html).toContain("Manuell ergänzt");
|
||||
expect(html).toContain("Ära löschen");
|
||||
// Exactly ONE manual chip + ONE delete button — the archived era
|
||||
// renders read-only.
|
||||
// renders without delete.
|
||||
expect(html.match(/zeitplan-history-manual-chip/g)).toHaveLength(1);
|
||||
expect(html.match(/zeitplan-history-delete/g)).toHaveLength(1);
|
||||
// BOTH eras carry the edit pencil: a MANUAL era edits in place, an
|
||||
// ARCHIVED era is corrected via a superseding revision.
|
||||
expect(html.match(/zeitplan-history-edit/g)).toHaveLength(2);
|
||||
expect(html).toContain("Ära bearbeiten");
|
||||
expect(html).toContain("Frühere Ären ausblenden");
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
* - MANUAL eras carry a tinted chip and a delete affordance
|
||||
* (confirmed via AlertDialog); write-path archives are immutable
|
||||
* and render without one.
|
||||
* - v1.16.6: every era row carries an edit pencil. The dialog opens
|
||||
* prefilled like the add flow; a MANUAL era PATCHes in place, an
|
||||
* ARCHIVED era shows a quiet hint that the change corrects the
|
||||
* recorded history (the server mints a superseding MANUAL row and
|
||||
* keeps the original as the audit record).
|
||||
*
|
||||
* The "Bis" date is the day the NEXT plan took over (exclusive bound) —
|
||||
* the dialog copy says so, and adjacency with the live plan is the
|
||||
@@ -25,7 +30,14 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, History, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
History,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -50,7 +62,7 @@ import {
|
||||
import { DateInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -103,6 +115,9 @@ export function ScheduleHistoryTimeline({
|
||||
const queryClient = useQueryClient();
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<ScheduleRevisionRow | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ScheduleRevisionRow | null>(
|
||||
null,
|
||||
);
|
||||
@@ -206,22 +221,37 @@ export function ScheduleHistoryTimeline({
|
||||
{fmt.date(revision.validUntil)}
|
||||
</p>
|
||||
</div>
|
||||
{revision.source === "MANUAL" && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-dracula-red size-8 shrink-0"
|
||||
className="text-muted-foreground hover:text-foreground size-8"
|
||||
aria-label={t(
|
||||
"medications.detail.zeitplan.history.deleteAria",
|
||||
"medications.detail.zeitplan.history.editAria",
|
||||
)}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => setDeleteTarget(revision)}
|
||||
data-slot="zeitplan-history-delete"
|
||||
onClick={() => setEditTarget(revision)}
|
||||
data-slot="zeitplan-history-edit"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
<Pencil className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
{revision.source === "MANUAL" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-dracula-red size-8"
|
||||
aria-label={t(
|
||||
"medications.detail.zeitplan.history.deleteAria",
|
||||
)}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => setDeleteTarget(revision)}
|
||||
data-slot="zeitplan-history-delete"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRow>
|
||||
);
|
||||
@@ -266,11 +296,18 @@ export function ScheduleHistoryTimeline({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddEraDialog
|
||||
<EraDialog
|
||||
key={editTarget?.id ?? "add"}
|
||||
medicationId={medicationId}
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
onCreated={() => setExpanded(true)}
|
||||
target={editTarget}
|
||||
open={addOpen || editTarget !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
setAddOpen(false);
|
||||
setEditTarget(null);
|
||||
}
|
||||
}}
|
||||
onSaved={() => setExpanded(true)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
@@ -343,37 +380,58 @@ function TimelineRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** ISO instant → the local calendar date a `DateInput` understands. */
|
||||
function toLocalDateInput(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Frühere Ära ergänzen" — collects daily dose times + the Von/Bis date
|
||||
* pair and POSTs a MANUAL schedule revision. Client-side guards mirror
|
||||
* the server validation (times present, Von before Bis); the server
|
||||
* stays authoritative for overlap / before-current-plan and its message
|
||||
* surfaces through the error toast.
|
||||
* "Frühere Ära ergänzen" / "Ära bearbeiten" — collects daily dose times
|
||||
* + the Von/Bis date pair. Without a `target` it POSTs a MANUAL
|
||||
* revision; with one it PATCHes the era (a MANUAL era updates in place,
|
||||
* an ARCHIVED era gets a superseding correction — the dialog shows a
|
||||
* quiet hint that the recorded history is being corrected). The parent
|
||||
* remounts the dialog via `key` per target, so the prefill is a plain
|
||||
* lazy state init. Client-side guards mirror the server validation
|
||||
* (times present, Von before Bis); the server stays authoritative for
|
||||
* overlap / before-current-plan and its message surfaces through the
|
||||
* error toast.
|
||||
*/
|
||||
function AddEraDialog({
|
||||
function EraDialog({
|
||||
medicationId,
|
||||
target,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
onSaved,
|
||||
}: {
|
||||
medicationId: string;
|
||||
/** Era being edited; null = the add flow. */
|
||||
target: ScheduleRevisionRow | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [times, setTimes] = useState<string[]>([]);
|
||||
const [fromDate, setFromDate] = useState("");
|
||||
const [untilDate, setUntilDate] = useState("");
|
||||
const [times, setTimes] = useState<string[]>(() =>
|
||||
target ? eraTimes(target.entries) : [],
|
||||
);
|
||||
const [fromDate, setFromDate] = useState(() =>
|
||||
target ? toLocalDateInput(target.validFrom) : "",
|
||||
);
|
||||
const [untilDate, setUntilDate] = useState(() =>
|
||||
target ? toLocalDateInput(target.validUntil) : "",
|
||||
);
|
||||
const [validationKey, setValidationKey] = useState<
|
||||
"timesMissing" | "rangeMissing" | "rangeOrder" | null
|
||||
>(null);
|
||||
|
||||
function reset() {
|
||||
setTimes([]);
|
||||
setFromDate("");
|
||||
setUntilDate("");
|
||||
setTimes(target ? eraTimes(target.entries) : []);
|
||||
setFromDate(target ? toLocalDateInput(target.validFrom) : "");
|
||||
setUntilDate(target ? toLocalDateInput(target.validUntil) : "");
|
||||
setValidationKey(null);
|
||||
}
|
||||
|
||||
@@ -383,21 +441,34 @@ function AddEraDialog({
|
||||
validUntil: string;
|
||||
timesOfDay: string[];
|
||||
}) =>
|
||||
apiPost<ScheduleRevisionRow>(
|
||||
`/api/medications/${medicationId}/schedule-revisions`,
|
||||
input,
|
||||
),
|
||||
target
|
||||
? apiPatch<ScheduleRevisionRow>(
|
||||
`/api/medications/${medicationId}/schedule-revisions/${target.id}`,
|
||||
input,
|
||||
)
|
||||
: apiPost<ScheduleRevisionRow>(
|
||||
`/api/medications/${medicationId}/schedule-revisions`,
|
||||
input,
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success(t("medications.detail.zeitplan.history.createdToast"));
|
||||
toast.success(
|
||||
target
|
||||
? t("medications.detail.zeitplan.history.updatedToast")
|
||||
: t("medications.detail.zeitplan.history.createdToast"),
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.medicationDetail(medicationId),
|
||||
});
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
onCreated();
|
||||
onSaved();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("medications.detail.zeitplan.history.createError"));
|
||||
toast.error(
|
||||
target
|
||||
? t("medications.detail.zeitplan.history.updateError")
|
||||
: t("medications.detail.zeitplan.history.createError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -437,12 +508,24 @@ function AddEraDialog({
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.zeitplan.history.addEraTitle")}
|
||||
{target
|
||||
? t("medications.detail.zeitplan.history.editEraTitle")
|
||||
: t("medications.detail.zeitplan.history.addEraTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.zeitplan.history.addEraDescription")}
|
||||
{target
|
||||
? t("medications.detail.zeitplan.history.editEraDescription")
|
||||
: t("medications.detail.zeitplan.history.addEraDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{target?.source === "ARCHIVED" && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="zeitplan-history-archived-hint"
|
||||
>
|
||||
{t("medications.detail.zeitplan.history.archivedEditHint")}
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -14,6 +19,8 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AppSettingsProvider } from "@/components/app-settings-provider";
|
||||
import { VersionPoller } from "@/components/version-poller";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
|
||||
// ── Theme Context ────────────────────────────────────
|
||||
|
||||
@@ -109,6 +116,26 @@ function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dashboard snapshot preloader ─────────────────────
|
||||
//
|
||||
// v1.16.6 first-load waterfall fix: fire the snapshot fetch the moment
|
||||
// the router commits to "/" instead of waiting for the dashboard page
|
||||
// chunk to download + mount (~450 ms later on a 4G / 4x-CPU profile).
|
||||
// The proxy has already enforced auth + onboarding for "/" before any
|
||||
// client code runs here, so the prefetch cannot fire for an
|
||||
// unauthenticated visitor; a racing edge case just yields a swallowed
|
||||
// 401 prefetch and the mounted cell re-resolves normally.
|
||||
function DashboardSnapshotPreloader() {
|
||||
const queryClient = useQueryClient();
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
if (pathname === "/" && isDashboardSnapshotEnabled()) {
|
||||
prefetchDashboardSnapshot(queryClient);
|
||||
}
|
||||
}, [pathname, queryClient]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Root Providers ───────────────────────────────────
|
||||
|
||||
export function Providers({
|
||||
@@ -143,6 +170,7 @@ export function Providers({
|
||||
initialMessages={initialMessages}
|
||||
>
|
||||
<AppSettingsProvider>
|
||||
<DashboardSnapshotPreloader />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<VersionPoller />
|
||||
|
||||
@@ -121,7 +121,9 @@ describe("<UpdateBadge> — a11y contract", () => {
|
||||
ariaLabel="A newer version is available: v1.4.99"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
|
||||
expect(html).toContain(
|
||||
'aria-label="A newer version is available: v1.4.99"',
|
||||
);
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
expect(html).toContain("focus-visible:ring-ring");
|
||||
@@ -140,7 +142,9 @@ describe("<UpdateBadge> — a11y contract", () => {
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
|
||||
expect(html).toContain(
|
||||
'aria-label="A newer version is available: v1.4.99"',
|
||||
);
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
});
|
||||
|
||||
@@ -44,9 +44,9 @@ describe("resolveInitialTimezone — v1.4.37 silent auto-seed", () => {
|
||||
});
|
||||
|
||||
it("respects any non-Berlin stored value — explicit user choice wins over browser detection", () => {
|
||||
expect(
|
||||
resolveInitialTimezone("America/Los_Angeles", "Europe/Berlin"),
|
||||
).toBe("America/Los_Angeles");
|
||||
expect(resolveInitialTimezone("America/Los_Angeles", "Europe/Berlin")).toBe(
|
||||
"America/Los_Angeles",
|
||||
);
|
||||
});
|
||||
|
||||
it("respects a non-Berlin stored value even when the browser reports a different non-Berlin zone", () => {
|
||||
|
||||
@@ -30,9 +30,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
|
||||
ResearchModeAcknowledgmentDialog: () => (
|
||||
<div data-slot="mock-ack-dialog" />
|
||||
),
|
||||
ResearchModeAcknowledgmentDialog: () => <div data-slot="mock-ack-dialog" />,
|
||||
}));
|
||||
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
|
||||
@@ -137,9 +137,7 @@ describe("<AdvancedSection> → Research Mode card", () => {
|
||||
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
|
||||
expect(html).toContain("Disclaimer updated");
|
||||
expect(html).toContain(
|
||||
'data-slot="settings-research-mode-reprompt-cta"',
|
||||
);
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt-cta"');
|
||||
expect(html).toContain("Re-acknowledge disclaimer");
|
||||
// Status line reflects the stale state without claiming
|
||||
// "acknowledged on …" — that copy would mis-represent that the
|
||||
|
||||
@@ -290,17 +290,19 @@ describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
// The pin-only ids reuse `measurements.type*` labels that resolve to
|
||||
// distinctive English strings; none should appear as a toggle aria-label.
|
||||
const pinOnlyLabels: Record<(typeof IOS_PIN_ONLY_WIDGET_IDS)[number], string> =
|
||||
{
|
||||
cardioRecovery: "Cardio recovery",
|
||||
sixMinuteWalk: "Six-minute walk distance",
|
||||
stairAscentSpeed: "Stair ascent speed",
|
||||
stairDescentSpeed: "Stair descent speed",
|
||||
breathingDisturbances: "Breathing disturbances",
|
||||
wristTemperature: "Wrist temperature",
|
||||
falls: "Falls",
|
||||
walkingSteadiness: "Walking steadiness",
|
||||
};
|
||||
const pinOnlyLabels: Record<
|
||||
(typeof IOS_PIN_ONLY_WIDGET_IDS)[number],
|
||||
string
|
||||
> = {
|
||||
cardioRecovery: "Cardio recovery",
|
||||
sixMinuteWalk: "Six-minute walk distance",
|
||||
stairAscentSpeed: "Stair ascent speed",
|
||||
stairDescentSpeed: "Stair descent speed",
|
||||
breathingDisturbances: "Breathing disturbances",
|
||||
wristTemperature: "Wrist temperature",
|
||||
falls: "Falls",
|
||||
walkingSteadiness: "Walking steadiness",
|
||||
};
|
||||
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
|
||||
// The widget label drives the switch aria-label; if the row were
|
||||
// rendered the localised label would show up in the markup.
|
||||
|
||||
@@ -27,11 +27,7 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
ImportPanel,
|
||||
EXAMPLE_IMPORT,
|
||||
parseImportJson,
|
||||
} from "../import-panel";
|
||||
import { ImportPanel, EXAMPLE_IMPORT, parseImportJson } from "../import-panel";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -61,11 +61,12 @@ beforeEach(() => {
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -113,7 +113,9 @@ describe("Settings — TimeFormatSelect", () => {
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const html = render();
|
||||
expect(html).toMatch(/<option[^>]*selected[^>]*value="H24"|<option[^>]*value="H24"[^>]*selected/);
|
||||
expect(html).toMatch(
|
||||
/<option[^>]*selected[^>]*value="H24"|<option[^>]*value="H24"[^>]*selected/,
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to AUTO when the user has no stored preference", () => {
|
||||
@@ -122,14 +124,14 @@ describe("Settings — TimeFormatSelect", () => {
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const html = render();
|
||||
expect(html).toMatch(/<option[^>]*selected[^>]*value="AUTO"|<option[^>]*value="AUTO"[^>]*selected/);
|
||||
expect(html).toMatch(
|
||||
/<option[^>]*selected[^>]*value="AUTO"|<option[^>]*value="AUTO"[^>]*selected/,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables the select when unauthenticated", () => {
|
||||
const html = render(false);
|
||||
const select = html.match(
|
||||
/<select[^>]*settings-time-format-select[^>]*>/,
|
||||
);
|
||||
const select = html.match(/<select[^>]*settings-time-format-select[^>]*>/);
|
||||
expect(select).not.toBeNull();
|
||||
expect(select![0]).toContain("disabled");
|
||||
});
|
||||
|
||||
@@ -184,7 +184,7 @@ export function AboutMeSection({
|
||||
<section
|
||||
aria-labelledby="settings-ai-about-me-title"
|
||||
data-testid="settings-about-me-card"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-6"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -234,10 +234,7 @@ export function AboutSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-about-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-about-title" className="sr-only">
|
||||
{t("settings.sections.about.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -249,7 +246,7 @@ export function AboutSection() {
|
||||
stacked the license under the version inside its own boxed Badge,
|
||||
which read as a separate field even though the two values belong
|
||||
on the same row. */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader icon={Info} title="HealthLog" className="mb-4" />
|
||||
|
||||
{isLoading || !version ? (
|
||||
@@ -319,7 +316,7 @@ export function AboutSection() {
|
||||
About surface, so the buttons floated alone with no heading
|
||||
telling the user what the section was for. */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={BookOpen}
|
||||
title={t("settings.about.linksHeading")}
|
||||
@@ -368,7 +365,7 @@ export function AboutSection() {
|
||||
links. Mirrors the stack-on-mobile / right-align-on-desktop
|
||||
contract used by the Account section's "Restart onboarding
|
||||
tour" card so the two surfaces feel consistent. */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
|
||||
@@ -414,10 +414,7 @@ export function AccountSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-account-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-account-title" className="sr-only">
|
||||
{t("settings.sections.account.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -429,7 +426,7 @@ export function AccountSection() {
|
||||
{/* Profile photo card */}
|
||||
<AvatarSection />
|
||||
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={User}
|
||||
title={t("settings.profile")}
|
||||
@@ -648,7 +645,7 @@ export function AccountSection() {
|
||||
<CycleTrackingCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* Passkeys card */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Shield}
|
||||
title={t("settings.passkeys")}
|
||||
@@ -692,7 +689,7 @@ export function AccountSection() {
|
||||
(the German "Passwort ändern" already pushes it; the tour
|
||||
card's "Restart onboarding tour" actually broke through the
|
||||
right border by ~48 px). */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
@@ -722,7 +719,7 @@ export function AccountSection() {
|
||||
background reopens the spotlight tour immediately. v1.4.19
|
||||
A6 — same stack-on-mobile / right-align-on-desktop contract
|
||||
as the password card so both action surfaces look identical. */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
@@ -1190,7 +1187,7 @@ function AvatarSection() {
|
||||
const busy = upload.isPending || remove.isPending;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={ImageUp}
|
||||
title={t("settings.avatar.title")}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -56,10 +57,7 @@ export function AdvancedSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-advanced-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-advanced-title" className="sr-only">
|
||||
{t("settings.sections.advanced.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -151,18 +149,14 @@ function ResearchModeCard() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-slot="settings-research-mode-card"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpenCheck className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.researchMode.sectionTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("settings.researchMode.subtitle")}
|
||||
</p>
|
||||
<SettingsCardHeader
|
||||
icon={BookOpenCheck}
|
||||
title={t("settings.researchMode.sectionTitle")}
|
||||
description={t("settings.researchMode.subtitle")}
|
||||
/>
|
||||
|
||||
{showRePrompt && (
|
||||
<div
|
||||
@@ -270,7 +264,7 @@ function DataResetCard() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-slot="settings-data-reset-card"
|
||||
>
|
||||
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
|
||||
@@ -418,7 +412,7 @@ function AccountDeleteCard() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-slot="settings-account-delete-card"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
|
||||
@@ -16,23 +16,31 @@ import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
|
||||
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
|
||||
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function AiSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// v1.16.6 — the auth query can resolve before this boundary
|
||||
// hydrates; every `disabled={!isAuthenticated}` binding in the child
|
||||
// cards would then disagree with the SSR HTML (React #418). Gate the
|
||||
// prop on `useMounted()` so the hydration render matches the
|
||||
// server-rendered "not yet authenticated" state and the cards enable
|
||||
// on the first client re-render.
|
||||
const mounted = useMounted();
|
||||
const authed = mounted && isAuthenticated;
|
||||
// v1.11.2 — the Coach-memory panel rides the same gate as the rest of
|
||||
// the Coach surface: when the user has hidden the Coach there is no
|
||||
// assistant to remember anything, so the memory controls hide too.
|
||||
const coachEnabled = !user?.disableCoach;
|
||||
// (`mounted`-gated like `authed`: SSR renders the panels, so the
|
||||
// hydration render must too, even when the user disabled the Coach.)
|
||||
const coachEnabled = !mounted || !user?.disableCoach;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-ai-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-ai-title" className="sr-only">
|
||||
{t("settings.sections.ai.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -40,27 +48,27 @@ export function AiSection() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AiInsightsCard isAuthenticated={isAuthenticated} />
|
||||
<AiInsightsCard isAuthenticated={authed} />
|
||||
|
||||
{/* v1.4.47 W3 — per-user Coach opt-out. Lives below the provider
|
||||
card so users who want the surface gone never have to scroll
|
||||
through provider configuration before finding the toggle. */}
|
||||
<DisableCoachCard isAuthenticated={isAuthenticated} />
|
||||
<DisableCoachCard isAuthenticated={authed} />
|
||||
|
||||
{/* v1.16.1 — Coach preferences (tone, verbosity, data scope)
|
||||
moved here from the in-chat sheet; the chat header gear
|
||||
deep-links to this section. Gated on the Coach being
|
||||
enabled. */}
|
||||
{coachEnabled && <CoachPrefsSection isAuthenticated={isAuthenticated} />}
|
||||
{coachEnabled && <CoachPrefsSection isAuthenticated={authed} />}
|
||||
|
||||
{/* v1.11.2 — "What the Coach remembers": durable-fact review +
|
||||
forget controls. Gated on the Coach being enabled. */}
|
||||
{coachEnabled && <CoachMemorySection isAuthenticated={isAuthenticated} />}
|
||||
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
|
||||
|
||||
{/* v1.15.20 — user-authored "about me" context for the Coach +
|
||||
daily briefing. Not gated on the Coach toggle: the briefing
|
||||
reads it too. */}
|
||||
<AboutMeSection isAuthenticated={isAuthenticated} />
|
||||
<AboutMeSection isAuthenticated={authed} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export function AiInsightsCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
{/* The card used to render an icon-only header row (Sparkles +
|
||||
status badges, no title), which left the tile unanchored next
|
||||
to its titled siblings. It now follows the shared
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -74,7 +75,12 @@ export function DisableCoachCard({
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const checked = optimistic ?? user?.disableCoach ?? false;
|
||||
// `mounted`-gated: `user` comes from the auth query, which can
|
||||
// resolve before this boundary hydrates — the Switch state must
|
||||
// match the unchecked SSR HTML during hydration (React #418) and
|
||||
// pick up the wire value on the first client re-render.
|
||||
const mounted = useMounted();
|
||||
const checked = mounted ? (optimistic ?? user?.disableCoach ?? false) : false;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: boolean) => {
|
||||
@@ -122,7 +128,7 @@ export function DisableCoachCard({
|
||||
<section
|
||||
aria-labelledby="settings-ai-disable-coach-title"
|
||||
data-testid="settings-disable-coach-card"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={MessageCircleOff}
|
||||
|
||||
@@ -42,10 +42,7 @@ export function ApiSection() {
|
||||
return (
|
||||
<section aria-labelledby="settings-section-api-title" className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-api-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-api-title" className="sr-only">
|
||||
{t("settings.sections.api.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -78,7 +75,7 @@ function ApiEndpointsCard() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Key}
|
||||
title={t("settings.apiEndpointsTitle")}
|
||||
@@ -241,7 +238,7 @@ function ApiTokensCard() {
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Key}
|
||||
title={t("settings.apiTokens")}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { Brain, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateOrRelative } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "sonner";
|
||||
@@ -136,70 +137,65 @@ export function CoachMemorySection({
|
||||
<section
|
||||
aria-labelledby="settings-ai-coach-memory-title"
|
||||
data-testid="settings-coach-memory-card"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-6"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="pr-2">
|
||||
<h2
|
||||
id="settings-ai-coach-memory-title"
|
||||
className="text-base font-semibold"
|
||||
>
|
||||
{t("settings.ai.coachMemory.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ai.coachMemory.description")}
|
||||
</p>
|
||||
</div>
|
||||
{facts.length > 0 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="settings-coach-memory-forget-all"
|
||||
disabled={!isAuthenticated || forgetAll.isPending}
|
||||
>
|
||||
{forgetAll.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAll")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("settings.ai.coachMemory.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="settings-coach-memory-forget-all-confirm"
|
||||
disabled={forgetAll.isPending}
|
||||
aria-busy={forgetAll.isPending || undefined}
|
||||
onClick={() => forgetAll.mutate()}
|
||||
<SettingsCardHeader
|
||||
icon={Brain}
|
||||
titleId="settings-ai-coach-memory-title"
|
||||
title={t("settings.ai.coachMemory.title")}
|
||||
description={t("settings.ai.coachMemory.description")}
|
||||
status={
|
||||
facts.length > 0 ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="settings-coach-memory-forget-all"
|
||||
disabled={!isAuthenticated || forgetAll.isPending}
|
||||
>
|
||||
{forgetAll.isPending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{forgetAll.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
{t("settings.ai.coachMemory.forgetAll")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("settings.ai.coachMemory.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="settings-coach-memory-forget-all-confirm"
|
||||
disabled={forgetAll.isPending}
|
||||
aria-busy={forgetAll.isPending || undefined}
|
||||
onClick={() => forgetAll.mutate()}
|
||||
>
|
||||
{forgetAll.isPending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{query.isError && (
|
||||
<p
|
||||
|
||||
@@ -172,7 +172,7 @@ export function CoachNudgeCard({
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-coach-nudge-title"
|
||||
className="bg-card rounded-lg border p-4 sm:p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-testid="settings-coach-nudge-card"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -19,17 +19,10 @@
|
||||
* contradict each other.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Loader2, SlidersHorizontal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -41,14 +34,11 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
|
||||
import {
|
||||
DEFAULT_COACH_CLUSTERS,
|
||||
DEFAULT_COACH_PREFS,
|
||||
coachDataClusterEnum,
|
||||
type CoachDataCluster,
|
||||
type CoachDefaultWindow,
|
||||
type CoachExcludeMetric,
|
||||
type CoachPrefs,
|
||||
type CoachTone,
|
||||
@@ -76,15 +66,6 @@ const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
|
||||
"anthropometrics",
|
||||
];
|
||||
|
||||
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"allTime",
|
||||
];
|
||||
|
||||
const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options;
|
||||
|
||||
export interface CoachPrefsSectionProps {
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
@@ -122,36 +103,22 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
|
||||
setDraft((prev) => {
|
||||
const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
|
||||
const updated = next
|
||||
? Array.from(new Set([...current, cluster]))
|
||||
: current.filter((c) => c !== cluster);
|
||||
return { ...prev, dataClusters: updated };
|
||||
});
|
||||
}
|
||||
|
||||
const enabledClusters = new Set<CoachDataCluster>(
|
||||
draft.dataClusters ?? DEFAULT_COACH_CLUSTERS,
|
||||
);
|
||||
|
||||
const isLoading = isAuthenticated && !persisted;
|
||||
|
||||
return (
|
||||
<Card data-slot="coach-prefs-section">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
{t("insights.coach.settingsTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("insights.coach.settingsDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-slot="coach-prefs-section"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={SlidersHorizontal}
|
||||
title={t("insights.coach.settingsTitle")}
|
||||
description={t("insights.coach.settingsDescription")}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<CardContent
|
||||
<div
|
||||
data-slot="coach-prefs-skeleton"
|
||||
className="flex flex-col gap-5"
|
||||
className="mt-4 flex flex-col gap-5 pl-7"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
@@ -166,9 +133,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
) : (
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
<div className="mt-4 flex flex-col gap-5 pl-7">
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{/* Tone */}
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -181,7 +148,10 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="coach-prefs-tone" data-slot="coach-prefs-tone">
|
||||
<SelectTrigger
|
||||
id="coach-prefs-tone"
|
||||
data-slot="coach-prefs-tone"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -236,84 +206,24 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default analysis window */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-default-window"
|
||||
className="text-xs font-medium"
|
||||
{/* Data clusters + analysis window live on ONE owner: the
|
||||
sources rail inside the chat ("What I draw on"). This card
|
||||
used to render its own copies of both controls; two writable
|
||||
surfaces for the same persisted fields is exactly the
|
||||
redundancy the rail was built to end. A pointer replaces
|
||||
them. */}
|
||||
<p
|
||||
data-slot="coach-prefs-sources-pointer"
|
||||
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-[11px] leading-relaxed"
|
||||
>
|
||||
{t("insights.coach.settingsSourcesPointer")}{" "}
|
||||
<Link
|
||||
href="/insights/coach"
|
||||
className="text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("insights.coach.settingsDefaultWindowLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsDefaultWindowHint")}
|
||||
</p>
|
||||
<Select
|
||||
value={draft.defaultWindow}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
defaultWindow: value as CoachDefaultWindow,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-default-window"
|
||||
data-slot="coach-prefs-default-window"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_WINDOW_OPTIONS.map((w) => (
|
||||
<SelectItem key={w} value={w}>
|
||||
{t(`insights.coach.window.${w}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Clustered, opt-in data sources */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("insights.coach.settingsClustersLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsClustersHint")}
|
||||
</p>
|
||||
<ul
|
||||
data-slot="coach-prefs-cluster-list"
|
||||
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
|
||||
>
|
||||
{CLUSTER_OPTIONS.map((cluster) => {
|
||||
const id = `coach-prefs-cluster-${cluster}`;
|
||||
const checked = enabledClusters.has(cluster);
|
||||
return (
|
||||
<li
|
||||
key={cluster}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="cursor-pointer text-xs font-medium"
|
||||
>
|
||||
{t(`insights.coach.cluster.${cluster}.label`)}
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-[11px] leading-snug">
|
||||
{t(`insights.coach.cluster.${cluster}.hint`)}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
data-slot={`coach-prefs-cluster-${cluster}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => toggleCluster(cluster, next)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{t("insights.coach.settingsSourcesPointerLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Exclude metrics — checked = excluded. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -386,9 +296,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
)}
|
||||
<CardFooter className="justify-end">
|
||||
<div className="mt-4 flex justify-end pl-7">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -404,7 +314,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
) : null}
|
||||
{t("insights.coach.settingsSave")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export function CycleTrackingCard({
|
||||
const enabled = data?.cycleTrackingEnabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg border p-4 sm:p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={CalendarHeart}
|
||||
title={t("settings.cycleTracking.title")}
|
||||
|
||||
@@ -294,7 +294,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-6"
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile,
|
||||
right-align-on-desktop contract as Account → Password +
|
||||
|
||||
@@ -12,10 +12,7 @@ export function DashboardSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-dashboard-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-dashboard-title" className="sr-only">
|
||||
{t("settings.sections.dashboard.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -79,10 +79,7 @@ export function ExportSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-export-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-export-title" className="sr-only">
|
||||
{t("settings.sections.export.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -158,7 +155,7 @@ function ExportCardShell({
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className={`bg-card border-border flex h-full flex-col rounded-xl border p-6${outerClassName ? ` ${outerClassName}` : ""}`}
|
||||
className={`bg-card border-border flex h-full flex-col rounded-xl border p-4 sm:p-6${outerClassName ? ` ${outerClassName}` : ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -505,7 +502,10 @@ function CycleExportCard() {
|
||||
const prefsQuery = useQuery({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => {
|
||||
return apiGet<{ cycleTrackingEnabled: boolean }>("/api/auth/me/cycle-prefs", { credentials: "include" });
|
||||
return apiGet<{ cycleTrackingEnabled: boolean }>(
|
||||
"/api/auth/me/cycle-prefs",
|
||||
{ credentials: "include" },
|
||||
);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
@@ -93,10 +93,13 @@ function ImportCardShell({
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="bg-card border-border flex h-full flex-col rounded-xl border p-6"
|
||||
className="bg-card border-border flex h-full flex-col rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-muted-foreground h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<Icon
|
||||
className="text-muted-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
@@ -144,7 +147,10 @@ function AppleHealthImportCard() {
|
||||
return 2000;
|
||||
},
|
||||
queryFn: async (): Promise<JobStatus> => {
|
||||
return apiGet<JobStatus>(`/api/import/apple-health-export/${jobId}/status`, { credentials: "include" });
|
||||
return apiGet<JobStatus>(
|
||||
`/api/import/apple-health-export/${jobId}/status`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,9 @@ export function InjectionSitesCard({
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: InjectionSiteKey[]) => {
|
||||
await apiPatch("/api/auth/me/injection-site-prefs", { globalExcludedInjectionSites: next });
|
||||
await apiPatch("/api/auth/me/injection-site-prefs", {
|
||||
globalExcludedInjectionSites: next,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
|
||||
@@ -125,12 +125,12 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (tiles: InsightsTileConfig[]) => {
|
||||
return apiPut<InsightsLayout>("/api/insights/layout", {
|
||||
version: 2,
|
||||
// Preserve the overview section order/visibility verbatim — this
|
||||
// surface owns the PILL order only.
|
||||
sections: layout.sections,
|
||||
tiles,
|
||||
});
|
||||
version: 2,
|
||||
// Preserve the overview section order/visibility verbatim — this
|
||||
// surface owns the PILL order only.
|
||||
sections: layout.sections,
|
||||
tiles,
|
||||
});
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
|
||||
|
||||
@@ -24,10 +24,7 @@ export function InsightsSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-insights-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-insights-title" className="sr-only">
|
||||
{t("settings.sections.insights.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -106,12 +106,9 @@ export function IntegrationStatusPill({
|
||||
// a reauth won't fix" vs "tried to keep going for 24h but the mismatch
|
||||
// persists, manual intervention required" — distinct from the red
|
||||
// reconnect pill (`error`).
|
||||
const connectedChipClass =
|
||||
"border-success/30 bg-success/15 text-success";
|
||||
const warningChipClass =
|
||||
"border-warning/30 bg-warning/15 text-warning";
|
||||
const parkedChipClass =
|
||||
"border-warning/30 bg-warning/15 text-warning";
|
||||
const connectedChipClass = "border-success/30 bg-success/15 text-success";
|
||||
const warningChipClass = "border-warning/30 bg-warning/15 text-warning";
|
||||
const parkedChipClass = "border-warning/30 bg-warning/15 text-warning";
|
||||
|
||||
let label: string;
|
||||
let icon: React.ReactNode;
|
||||
|
||||
@@ -99,10 +99,7 @@ export function IntegrationsSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-integrations-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-integrations-title" className="sr-only">
|
||||
{t("settings.sections.integrations.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -9,7 +9,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { HeartPulse, Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
|
||||
import {
|
||||
HeartPulse,
|
||||
Link2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -182,7 +189,7 @@ export function FitbitCard({
|
||||
return (
|
||||
<div
|
||||
data-testid="fitbit-card"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={HeartPulse}
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Activity, Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Link2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -174,7 +181,7 @@ export function WhoopCard({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Activity}
|
||||
title={t("settings.whoop")}
|
||||
|
||||
@@ -189,7 +189,7 @@ export function WithingsCard({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Link2}
|
||||
title={t("settings.withings")}
|
||||
|
||||
@@ -107,7 +107,9 @@ export function MoodReminderCard({
|
||||
: t("notifications.moodReminder.disabledToast"),
|
||||
);
|
||||
setMsgType("success");
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.userProfile() });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userProfile(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
} else {
|
||||
@@ -155,7 +157,7 @@ export function MoodReminderCard({
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-mood-reminder-title"
|
||||
className="bg-card rounded-lg border p-4 sm:p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={SmilePlus}
|
||||
@@ -180,10 +182,7 @@ export function MoodReminderCard({
|
||||
/>
|
||||
{enabled && (
|
||||
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
|
||||
<label
|
||||
htmlFor="mood-reminder-hour"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
<label htmlFor="mood-reminder-hour" className="text-sm font-medium">
|
||||
{t("notifications.moodReminder.hourLabel")}
|
||||
</label>
|
||||
<NativeSelect
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
@@ -113,7 +114,7 @@ export function NotificationStatusCard() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
@@ -128,37 +129,30 @@ export function NotificationStatusCard() {
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
data-testid="notification-status-empty"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.notificationStatus.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-sm">
|
||||
{t("settings.notificationStatus.emptyDescription")}
|
||||
</p>
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("settings.notificationStatus.title")}
|
||||
description={t("settings.notificationStatus.emptyDescription")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<header className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.notificationStatus.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground pl-7 text-xs">
|
||||
{t("settings.notificationStatus.description")}
|
||||
</p>
|
||||
</header>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("settings.notificationStatus.title")}
|
||||
description={t("settings.notificationStatus.description")}
|
||||
/>
|
||||
|
||||
<ul className="space-y-3" data-testid="notification-status-list">
|
||||
<ul
|
||||
className="mt-4 space-y-3 pl-7"
|
||||
data-testid="notification-status-list"
|
||||
>
|
||||
{channels.map((ch) => (
|
||||
<ChannelRow
|
||||
key={ch.id}
|
||||
|
||||
@@ -74,10 +74,7 @@ export function NotificationsSection() {
|
||||
</ol>
|
||||
</nav>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-notifications-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-notifications-title" className="sr-only">
|
||||
{t("settings.sections.notifications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -82,7 +82,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("settings.ntfy")}
|
||||
|
||||
@@ -70,10 +70,7 @@ export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id={`settings-section-${slug}-title`}
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id={`settings-section-${slug}-title`} className="sr-only">
|
||||
{sectionTitle}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{sectionDescription}</p>
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -285,10 +286,7 @@ export function SourcesSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-sources-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-sources-title" className="sr-only">
|
||||
{t("settings.sections.sources.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -308,29 +306,23 @@ export function SourcesSection() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.sections.sources.cardTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending || saveMutation.isPending}
|
||||
className="self-end sm:self-auto"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.sources.resetDefaults")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground pl-7 text-xs">
|
||||
{t("settings.sections.sources.help")}
|
||||
</p>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Layers}
|
||||
title={t("settings.sections.sources.cardTitle")}
|
||||
description={t("settings.sections.sources.help")}
|
||||
status={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending || saveMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.sources.resetDefaults")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading || !priority ? (
|
||||
<SourcesSkeletonList />
|
||||
|
||||
@@ -87,7 +87,7 @@ export function TelegramCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={MessageCircle}
|
||||
title={t("settings.telegram")}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-6"
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
{/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*`
|
||||
already provides the title + description for this route, so the
|
||||
|
||||
@@ -39,10 +39,7 @@ export function ThresholdsSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-thresholds-title"
|
||||
className="sr-only"
|
||||
>
|
||||
<h1 id="settings-section-thresholds-title" className="sr-only">
|
||||
{t("settings.sections.thresholds.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -63,8 +63,7 @@ export function TimeFormatSelect({
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const value: TimeFormatPreference =
|
||||
optimistic ?? user?.timeFormat ?? "AUTO";
|
||||
const value: TimeFormatPreference = optimistic ?? user?.timeFormat ?? "AUTO";
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: TimeFormatPreference) => {
|
||||
@@ -94,9 +93,7 @@ export function TimeFormatSelect({
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("settings.timeFormat.saveError"),
|
||||
err instanceof Error ? err.message : t("settings.timeFormat.saveError"),
|
||||
);
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
|
||||
@@ -200,7 +200,7 @@ export function WebPushCard() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={BellRing}
|
||||
title={t("settings.webPush")}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "../about-me";
|
||||
import {
|
||||
buildFallbackQuestions,
|
||||
buildPrompts,
|
||||
parseQuestionsReply,
|
||||
} from "../self-context-questions";
|
||||
|
||||
@@ -176,3 +177,25 @@ describe("buildFallbackQuestions", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPrompts", () => {
|
||||
// v1.16.6 — the questions completion rides the Coach snapshot so the
|
||||
// model can ask about what the user actually tracks.
|
||||
it("appends the health-data snapshot block when one is available", () => {
|
||||
const { systemPrompt, userPrompt } = buildPrompts(
|
||||
{ ...emptyCtx, coachFocus: "sleep" },
|
||||
"en",
|
||||
'{"weight":{"trend":"down"}}',
|
||||
);
|
||||
expect(userPrompt).toContain("HEALTH DATA SNAPSHOT");
|
||||
expect(userPrompt).toContain('{"weight":{"trend":"down"}}');
|
||||
expect(userPrompt).toContain("coach focus: sleep");
|
||||
expect(systemPrompt).toContain("health-data snapshot");
|
||||
});
|
||||
|
||||
it("stays fields-only without a snapshot", () => {
|
||||
const { userPrompt } = buildPrompts(emptyCtx, "en", null);
|
||||
expect(userPrompt).not.toContain("HEALTH DATA SNAPSHOT");
|
||||
expect(userPrompt).toContain("conditions: (not answered)");
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
clampPendingQuestions,
|
||||
type SelfContext,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale, locales } from "@/lib/i18n/config";
|
||||
@@ -93,12 +94,17 @@ export function parseQuestionsReply(content: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompts(
|
||||
export function buildPrompts(
|
||||
ctx: SelfContext,
|
||||
locale: Locale,
|
||||
snapshotJson?: string | null,
|
||||
): { systemPrompt: string; userPrompt: string } {
|
||||
const language = LANGUAGE_NAMES[locale];
|
||||
const systemPrompt = `You help complete a health-app user's self-description for their AI health coach. Based on what the user has already shared, write AT MOST 3 short, specific follow-up questions that would best complete the picture (e.g. unstated conditions a stated medication implies, missing context for a stated goal). Never ask for anything already answered. Never ask for a diagnosis. Write the questions in ${language}, addressed informally to the user. Reply with ONLY a JSON array of strings — no prose, no code fences.`;
|
||||
// v1.16.6 — the prompt carries the same health-data snapshot the
|
||||
// Coach chat uses, so the questions can reference what the user
|
||||
// actually tracks (a logged medication, a weight trend, the stated
|
||||
// focus) instead of staying generic.
|
||||
const systemPrompt = `You help complete a health-app user's self-description for their AI health coach. Based on what the user has already shared — and on the health-data snapshot of what they track in the app — write AT MOST 3 short, specific follow-up questions that would best complete the picture (e.g. unstated conditions a stated medication implies, missing context for a stated goal, a tracked metric the self-description does not explain). Prefer questions grounded in the user's own data and stated focus over generic ones. Never ask for anything already answered. Never ask for a diagnosis. Write the questions in ${language}, addressed informally to the user. Reply with ONLY a JSON array of strings — no prose, no code fences.`;
|
||||
|
||||
const fieldLines = [
|
||||
`conditions: ${ctx.conditions ?? "(not answered)"}`,
|
||||
@@ -106,7 +112,10 @@ function buildPrompts(
|
||||
`coach focus: ${ctx.coachFocus ?? "(not answered)"}`,
|
||||
`free text: ${ctx.aboutMe ?? "(not answered)"}`,
|
||||
];
|
||||
return { systemPrompt, userPrompt: fieldLines.join("\n") };
|
||||
const userPrompt = snapshotJson
|
||||
? `${fieldLines.join("\n")}\n\nHEALTH DATA SNAPSHOT\n${snapshotJson}`
|
||||
: fieldLines.join("\n");
|
||||
return { systemPrompt, userPrompt };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,9 +142,17 @@ export async function deriveClarifyingQuestions(
|
||||
if (spent >= MAX_TOKENS_PER_USER_PER_DAY) return fallback();
|
||||
|
||||
const provider = await resolveProvider(userId);
|
||||
// v1.16.6 — same snapshot the Coach chat rides; lets the model
|
||||
// ask about what the user actually tracks. Best-effort: a snapshot
|
||||
// failure must not cost the AI path, the prompt just stays
|
||||
// fields-only.
|
||||
const snapshotJson = await buildCoachSnapshot(userId)
|
||||
.then((s) => s.snapshotJson || null)
|
||||
.catch(() => null);
|
||||
const { systemPrompt, userPrompt } = buildPrompts(
|
||||
ctx,
|
||||
resolveLocale(locale),
|
||||
snapshotJson,
|
||||
);
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt,
|
||||
|
||||
@@ -133,6 +133,15 @@ export const coachChatRequestSchema = z.object({
|
||||
prefill: z.string().max(2000).optional(),
|
||||
locale: z.enum(["en", "de"]).optional(),
|
||||
scope: coachScopeSchema.optional(),
|
||||
/**
|
||||
* v1.16.6 — guided clarifying-questions flow: the pending question
|
||||
* this message answers. The question bubble is client-side only
|
||||
* (never persisted), so without this context the model would see a
|
||||
* bare answer ("since 2019, with medication") and could not react
|
||||
* to it. Rides the prompt as delimited user-provided context; the
|
||||
* persisted user turn stays the answer alone.
|
||||
*/
|
||||
guidedQuestion: z.string().min(1).max(500).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -264,3 +264,104 @@ describe("reduceCurrentWindowStatus — stale window never beats timesOfDay (v1.
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => {
|
||||
// Mirrors the production shape that mis-pilled: a rolling 7-day
|
||||
// medication (daysOfWeek empty) with a single 08:00 dose and an explicit
|
||||
// 08:00–09:00 on-time window. Without the gate the band re-mints every
|
||||
// local day and escalates after 09:00 even when the next unresolved slot
|
||||
// is tomorrow.
|
||||
const rolling = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
doseWindows: [{ timeOfDay: "08:00", start: "08:00", end: "09:00" }],
|
||||
},
|
||||
];
|
||||
// June 11 2026, 07:00 Berlin (CEST) — "tomorrow" relative to the
|
||||
// localClock fixtures (June 10).
|
||||
const dueTomorrow = new Date("2026-06-11T05:00:00Z");
|
||||
// June 10 2026, 08:00 Berlin — "today's" dose anchor.
|
||||
const dueToday = new Date("2026-06-10T06:00:00Z");
|
||||
|
||||
it("rolling med due tomorrow never paints an overdue pill today", () => {
|
||||
// Contrast pin: WITHOUT the gate the legacy band model escalates to
|
||||
// very_late at 13:00 (240 min past the 09:00 band end) — the bug.
|
||||
const ungated = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(13, 0),
|
||||
});
|
||||
expect(ungated.status).toBe("very_late");
|
||||
|
||||
// WITH the server display-due (next slot tomorrow, not overdue) the
|
||||
// pill stays calm — the status can never read more overdue than the
|
||||
// next-due line.
|
||||
const gated = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(13, 0),
|
||||
nextDue: { at: dueTomorrow, overdue: false },
|
||||
});
|
||||
expect(gated.status).toBeNull();
|
||||
|
||||
// Same at the late tier (10:00 — 60 min past the band end).
|
||||
const lateTier = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(10, 0),
|
||||
nextDue: { at: dueTomorrow, overdue: false },
|
||||
});
|
||||
expect(lateTier.status).toBeNull();
|
||||
});
|
||||
|
||||
it("a genuinely open overdue slot keeps the band escalation", () => {
|
||||
const late = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(10, 0),
|
||||
nextDue: { at: dueToday, overdue: true },
|
||||
});
|
||||
expect(late.status).toBe("late");
|
||||
|
||||
const veryLate = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(12, 30),
|
||||
nextDue: { at: dueToday, overdue: true },
|
||||
});
|
||||
expect(veryLate.status).toBe("very_late");
|
||||
});
|
||||
|
||||
it("take-now shows only on the due day itself", () => {
|
||||
// In the 08:00–09:00 band but the next dose is tomorrow → calm.
|
||||
const notYetDue = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(8, 30),
|
||||
nextDue: { at: dueTomorrow, overdue: false },
|
||||
});
|
||||
expect(notYetDue.status).toBeNull();
|
||||
|
||||
// Same wall clock, dose due today → the take-now pill renders.
|
||||
const dueNow = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(8, 30),
|
||||
nextDue: { at: dueToday, overdue: false },
|
||||
});
|
||||
expect(dueNow.status).toBe("in_window");
|
||||
});
|
||||
|
||||
it("a null gate (no upcoming slot) suppresses the pill entirely", () => {
|
||||
const res = reduceCurrentWindowStatus({
|
||||
...BASE,
|
||||
schedules: rolling,
|
||||
nowBerlin: localClock(8, 30),
|
||||
nextDue: null,
|
||||
});
|
||||
expect(res.status).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,56 @@ describe("segmentRangeIntoEras", () => {
|
||||
expect(eras).toHaveLength(1);
|
||||
expect(eras[0].live).toBe(true);
|
||||
});
|
||||
|
||||
it("skips a superseded revision — only its correction mints an era", () => {
|
||||
const range = {
|
||||
from: new Date("2026-05-10T00:00:00.000Z"),
|
||||
to: new Date("2026-06-10T00:00:00.000Z"),
|
||||
};
|
||||
// The recorded archive was corrected: the original points at the
|
||||
// MANUAL correction and must no longer mint.
|
||||
const superseded: ScheduleRevisionLike = {
|
||||
...oldEraRevision,
|
||||
supersededByRevisionId: "rev-fix",
|
||||
};
|
||||
const correction: ScheduleRevisionLike = {
|
||||
id: "rev-fix",
|
||||
validFrom: createdAt,
|
||||
validUntil: replaceAt,
|
||||
payload: [
|
||||
entry({
|
||||
timesOfDay: ["06:00"],
|
||||
windowStart: "06:00",
|
||||
windowEnd: "06:00",
|
||||
}),
|
||||
],
|
||||
supersededByRevisionId: null,
|
||||
};
|
||||
const eras = segmentRangeIntoEras(
|
||||
range,
|
||||
[superseded, correction],
|
||||
[liveSchedule],
|
||||
);
|
||||
expect(eras).toHaveLength(2);
|
||||
expect(eras[0].live).toBe(false);
|
||||
expect(eras[0].schedules[0].timesOfDay).toEqual(["06:00"]);
|
||||
expect(eras[1].live).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to a single live era when every revision is superseded", () => {
|
||||
const range = {
|
||||
from: new Date("2026-05-10T00:00:00.000Z"),
|
||||
to: new Date("2026-06-10T00:00:00.000Z"),
|
||||
};
|
||||
const superseded: ScheduleRevisionLike = {
|
||||
...oldEraRevision,
|
||||
supersededByRevisionId: "rev-gone",
|
||||
};
|
||||
const eras = segmentRangeIntoEras(range, [superseded], [liveSchedule]);
|
||||
expect(eras).toHaveLength(1);
|
||||
expect(eras[0].live).toBe(true);
|
||||
expect(eras[0].from).toEqual(range.from);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBandsForSchedulesWithEras", () => {
|
||||
|
||||
@@ -50,6 +50,14 @@ export interface ScheduleRevisionLike {
|
||||
validUntil: Date;
|
||||
/** JSON array of {@link ScheduleRevisionEntry} snapshots. */
|
||||
payload: unknown;
|
||||
/**
|
||||
* v1.16.6 — set when a correction replaced this era (the id of the
|
||||
* superseding MANUAL revision). A superseded row is an audit record,
|
||||
* not an era: the splitter skips it. Optional so pre-existing
|
||||
* callers/tests that thread plain `{id, validFrom, validUntil,
|
||||
* payload}` projections keep compiling — absent means active.
|
||||
*/
|
||||
supersededByRevisionId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,9 +163,12 @@ export function segmentRangeIntoEras(
|
||||
options?: { oneShot?: boolean },
|
||||
): ScheduleEra[] {
|
||||
const eras: ScheduleEra[] = [];
|
||||
const sorted = [...revisions].sort(
|
||||
(a, b) => a.validFrom.getTime() - b.validFrom.getTime(),
|
||||
);
|
||||
// Superseded rows are audit records, not eras — a correction took
|
||||
// their place. Skipping them here covers every era consumer (band
|
||||
// minting, occurrence counts) in one spot.
|
||||
const sorted = revisions
|
||||
.filter((r) => r.supersededByRevisionId == null)
|
||||
.sort((a, b) => a.validFrom.getTime() - b.validFrom.getTime());
|
||||
for (const revision of sorted) {
|
||||
const from = new Date(
|
||||
Math.max(revision.validFrom.getTime(), range.from.getTime()),
|
||||
|
||||
@@ -352,7 +352,13 @@ const MEDICATION_SELECT = {
|
||||
// attributes against the era that was live at that takenAt.
|
||||
scheduleRevisions: {
|
||||
orderBy: { validFrom: "asc" },
|
||||
select: { id: true, validFrom: true, validUntil: true, payload: true },
|
||||
select: {
|
||||
id: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
payload: true,
|
||||
supersededByRevisionId: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user