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:
Marc-André Bombeck committed 2026-06-11 13:57:15 +02:00
1 parent a4b3d2fcd0
commit 40f7196c20
115 files changed
+3014 -1084

No files matched your search

+2
View File
@@ -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,
});
});
});
+27 -3
View File
@@ -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}
+6 -2
View File
@@ -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),
+3 -1
View File
@@ -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 },
}),
]);
+16 -2
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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/,
);
});
});
+13
View File
@@ -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,
+3 -3
View File
@@ -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",
+23 -26
View File
@@ -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
+73 -71
View File
@@ -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>
);
+26 -25
View File
@@ -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
+15 -22
View File
@@ -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")}
+18 -19
View File
@@ -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">
+1 -1
View File
@@ -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")}
+10 -19
View File
@@ -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}
+77 -74
View File
@@ -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
+18 -19
View File
@@ -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 ? (
+11 -14
View File
@@ -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">
+8 -9
View File
@@ -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")}
+8 -11
View File
@@ -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")}
+10 -19
View File
@@ -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 ? (
+39 -35
View File
@@ -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")}
+15 -22
View File
@@ -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();
+29 -1
View File
@@ -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");
});
+1 -1
View File
@@ -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
+4 -7
View File
@@ -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" />
+6 -9
View File
@@ -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")}
+10 -16
View File
@@ -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">
+18 -10
View File
@@ -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}
+3 -6
View File
@@ -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
+1 -1
View File
@@ -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
+41 -131
View File
@@ -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">
+6 -6
View File
@@ -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,
});
+9 -3
View File
@@ -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);
+1 -4
View File
@@ -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">
+1 -1
View File
@@ -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>
+19 -27
View File
@@ -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 />
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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)");
});
});
+20 -3
View File
@@ -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,
+9
View File
@@ -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