From ef420f9e2a264b6db857579e86afbcacfb352137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:46:16 +0200 Subject: [PATCH 1/8] fix(insights): draw the blood-pressure zones from the record's resolved band The blood-pressure chart rebuilt its target zones in the browser from the signed-in account's date of birth. A band the person had set themselves never reached the chart, and inside a shared or managed record the zones followed the viewer's age instead of the record's. The targets payload already resolves for the record on screen. Its blood- pressure card now honours the user's own band too (labelled Custom, like the weight card), and the chart reads its zones from that card, so the chart and the reference panel under it name one band. --- src/app/insights/blood-pressure/page.tsx | 16 ++-- src/lib/analytics/effective-range.ts | 20 ++++ src/lib/queries/use-bp-target-band.ts | 61 ++++++++++++ .../__tests__/bp-target-override.test.ts | 96 +++++++++++++++++++ src/lib/targets/build-response.ts | 12 +++ src/lib/targets/vitals-builder.ts | 16 +++- 6 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 src/lib/queries/use-bp-target-band.ts create mode 100644 src/lib/targets/__tests__/bp-target-override.test.ts diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index d2ad8102e..98df95c45 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -18,7 +18,7 @@ import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { useBpTargetBand } from "@/lib/queries/use-bp-target-band"; /** * v1.4.25 W4 — `/insights/blood-pressure`. @@ -26,7 +26,8 @@ import { getBpTargets } from "@/lib/analytics/bp-targets"; * Routed Blood-Pressure sub-page. The mother page hosts the overview * (hero + briefing + advisor); this page concentrates on BP-only depth: * the BP chart with its chart-cog overlays, the per-section AI - * assessment, and the chart's target zones tied to the user's age. + * assessment, and the chart's target zones — the band the server resolved + * for the record on screen (the user's own target, else the age default). * * v1.4.27 F17 — when the user has zero BP observations, the page * short-circuits to an empty-state CTA pointing at @@ -57,6 +58,7 @@ export default function InsightsBlutdruckPage() { // series at once: the single chart reports per-type visible-range stats and // each strip column reads its own half. const { statsByType, onVisibleStats } = useChartDomainStats(); + const bpTargets = useBpTargetBand(); if (isEmpty) { return ( @@ -82,13 +84,11 @@ export default function InsightsBlutdruckPage() { ); } - const bpTargets = - user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null; const bpTargetZones = bpTargets ? [ { - min: bpTargets.sysLow, - max: bpTargets.sysHigh, + min: bpTargets.systolic.min, + max: bpTargets.systolic.max, color: "var(--chart-3)", opacity: 0.21, label: t("charts.systolic"), @@ -96,8 +96,8 @@ export default function InsightsBlutdruckPage() { lineOpacity: 0.24, }, { - min: bpTargets.diaLow, - max: bpTargets.diaHigh, + min: bpTargets.diastolic.min, + max: bpTargets.diastolic.max, color: "var(--info)", opacity: 0.21, label: t("charts.diastolic"), diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index 0c46b89fc..16fdd2587 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -371,6 +371,26 @@ export function resolveEffectiveBpTargets( }; } +/** + * The user's own blood-pressure band, or `null` when they have not set one. + * + * `resolveEffectiveBpTargets` answers "which band applies" and falls back to + * the age default; this answers "did the person pick it", which is what a + * surface needs to label the band as their own rather than as the guideline. + * A one-sided override (systolic only) still counts: the other half is the + * age default, exactly as `resolveEffectiveBpTargets` fills it. + */ +export function resolveBpTargetOverride( + profile: UserProfileForRange, + thresholdsJson: unknown, +): BpTargets | null { + const hasOverride = + resolveThresholdOverride(thresholdsJson, "BLOOD_PRESSURE_SYS") != null || + resolveThresholdOverride(thresholdsJson, "BLOOD_PRESSURE_DIA") != null; + if (!hasOverride) return null; + return resolveEffectiveBpTargets(profile, thresholdsJson); +} + /** * Convenience: resolve every supported metric at once. Useful for /targets * and the insight generator. diff --git a/src/lib/queries/use-bp-target-band.ts b/src/lib/queries/use-bp-target-band.ts new file mode 100644 index 000000000..3daa9d477 --- /dev/null +++ b/src/lib/queries/use-bp-target-band.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { useAuth } from "@/hooks/use-auth"; + +interface TargetRange { + min: number; + max: number; +} + +/** The slice of `GET /api/insights/targets` the blood-pressure band needs. */ +interface BpTargetsSlice { + targets: ReadonlyArray<{ type: string; range: TargetRange | null }>; + bpDiastolic: { range: TargetRange | null }; +} + +export interface BpTargetBand { + systolic: TargetRange; + diastolic: TargetRange; +} + +/** + * The blood-pressure target band for the record on screen, as the server + * resolved it. + * + * The chart used to rebuild the band in the browser from the signed-in + * account's date of birth. That lost a band the person had set themselves, + * and inside a shared or managed record it used the viewer's age rather than + * the record's. `/api/insights/targets` already resolves the band for the + * record being viewed, with the user's own target winning over the age + * default, and the reference panel under the chart reads the same payload — + * so the zones and the panel name one band. + * + * Shares the `insightsTargets()` cache entry with the panel; the payload + * shape is the route's, so both readers hold the same object. + */ +export function useBpTargetBand(): BpTargetBand | null { + const { isAuthenticated } = useAuth(); + const { data } = useQuery({ + queryKey: queryKeys.insightsTargets(), + queryFn: () => apiGet("/api/insights/targets"), + enabled: isAuthenticated, + }); + return selectBpTargetBand(data ?? null); +} + +/** Pure projection, exported for the unit test. */ +export function selectBpTargetBand( + data: BpTargetsSlice | null, +): BpTargetBand | null { + if (!data) return null; + const systolic = + data.targets.find((entry) => entry.type === "BLOOD_PRESSURE")?.range ?? + null; + const diastolic = data.bpDiastolic?.range ?? null; + if (!systolic || !diastolic) return null; + return { systolic, diastolic }; +} diff --git a/src/lib/targets/__tests__/bp-target-override.test.ts b/src/lib/targets/__tests__/bp-target-override.test.ts new file mode 100644 index 000000000..1da20a698 --- /dev/null +++ b/src/lib/targets/__tests__/bp-target-override.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { resolveBpTargetOverride } from "@/lib/analytics/effective-range"; +import { selectBpTargetBand } from "@/lib/queries/use-bp-target-band"; +import { buildVitalTargets } from "../vitals-builder"; + +const NOW = new Date("2026-07-21T12:00:00.000Z"); + +// `restingPulseProxy` is listed so the literal stays valid whichever shape of +// the input the builder carries; the assertion keeps the extra key legal. +const base = { + recentMeasurements: [], + restingPulseProxy: [], + latestByType: {}, + average30ByType: {}, + heightCm: 178, + age: 40, + gender: null, + timezone: "UTC", + now: NOW, + weightTargetOverride: null, +} as unknown as Parameters[0]; + +const PROFILE_40 = { + heightCm: 178, + dateOfBirth: new Date("1986-03-01T12:00:00.000Z"), + gender: null, +}; + +describe("blood-pressure target: the user's own band", () => { + it("is null when no blood-pressure threshold is stored", () => { + expect(resolveBpTargetOverride(PROFILE_40, null)).toBeNull(); + expect( + resolveBpTargetOverride(PROFILE_40, { WEIGHT: { min: 70, max: 75 } }), + ).toBeNull(); + }); + + it("carries the stored band, filling the other half from the age default", () => { + expect( + resolveBpTargetOverride(PROFILE_40, { + BLOOD_PRESSURE_SYS: { min: 110, max: 135 }, + }), + ).toEqual({ sysLow: 110, sysHigh: 135, diaLow: 70, diaHigh: 79 }); + }); + + it("replaces the age band on the card and labels it as the user's", () => { + const card = buildVitalTargets({ + ...base, + bpTargetOverride: { sysLow: 110, sysHigh: 135, diaLow: 65, diaHigh: 85 }, + }); + const bp = card.targets.find((t) => t.type === "BLOOD_PRESSURE"); + expect(bp?.range).toEqual({ min: 110, max: 135 }); + expect(bp?.source).toBe("Custom"); + expect(card.bpRange).toEqual({ + sysLow: 110, + sysHigh: 135, + diaLow: 65, + diaHigh: 85, + }); + }); + + it("keeps the ESH age band when none is set", () => { + const bp = buildVitalTargets(base).targets.find( + (t) => t.type === "BLOOD_PRESSURE", + ); + expect(bp?.range).toEqual({ min: 120, max: 129 }); + expect(bp?.source).toBe("ESH 2023"); + }); +}); + +describe("selectBpTargetBand — the chart reads the server's band", () => { + it("projects the systolic card range and the diastolic range", () => { + expect( + selectBpTargetBand({ + targets: [ + { type: "WEIGHT", range: { min: 70, max: 75 } }, + { type: "BLOOD_PRESSURE", range: { min: 110, max: 135 } }, + ], + bpDiastolic: { range: { min: 65, max: 85 } }, + }), + ).toEqual({ + systolic: { min: 110, max: 135 }, + diastolic: { min: 65, max: 85 }, + }); + }); + + it("draws no band when the server resolved none", () => { + expect(selectBpTargetBand(null)).toBeNull(); + expect( + selectBpTargetBand({ + targets: [{ type: "BLOOD_PRESSURE", range: null }], + bpDiastolic: { range: null }, + }), + ).toBeNull(); + }); +}); diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 6addbc725..7421b13b2 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -17,6 +17,7 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import pLimit from "p-limit"; import type { MeasurementType } from "@/generated/prisma/client"; import { + resolveBpTargetOverride, resolveWeightTargetOverride, type ThresholdOverridesJson, } from "@/lib/analytics/effective-range"; @@ -277,6 +278,17 @@ export async function buildTargetsResponse(user: AuthedUser) { // set one, so the reference panel and the weight chart above it name the // same band. weightTargetOverride: resolveWeightTargetOverride(dbUser?.thresholdsJson), + // The blood-pressure card likewise names the user's own band when they + // set one. The insights page draws its chart zones from this same range, + // so the chart and the card below it cannot disagree. + bpTargetOverride: resolveBpTargetOverride( + { + heightCm, + dateOfBirth: dbUser?.dateOfBirth ?? null, + gender: dbUser?.gender ?? null, + }, + dbUser?.thresholdsJson, + ), }); const sleepTarget = buildSleepTarget({ sleepStageRows, diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts index 05c6eccaf..58a4ebfd1 100644 --- a/src/lib/targets/vitals-builder.ts +++ b/src/lib/targets/vitals-builder.ts @@ -54,6 +54,15 @@ interface VitalTargetsInput { * always treated a user override. `null` keeps the height-derived band. */ weightTargetOverride: { min: number; max: number } | null; + /** + * The user's own blood-pressure band from `User.thresholdsJson` + * (`resolveBpTargetOverride`). When present it replaces the age-based ESH + * band on the card, its in-target share and its seven-day strip, and the + * source reads "Custom" — the same rule as the weight override above. The + * ESH classification of the latest reading stays guideline-derived: it + * names a category, not a goal. Absent or `null` keeps the age band. + */ + bpTargetOverride?: BloodPressureTargetRange | null; } export interface VitalTargetsResult { @@ -148,6 +157,7 @@ export function buildVitalTargets({ timezone, now, weightTargetOverride, + bpTargetOverride = null, }: VitalTargetsInput): VitalTargetsResult { const targets: TargetItem[] = []; const consistencyClock = { timezone, now }; @@ -191,7 +201,9 @@ export function buildVitalTargets({ }), }); - const bpRange = age != null ? getBpTargetsByAge(age, referenceSex) : null; + const bpRange = + bpTargetOverride ?? + (age != null ? getBpTargetsByAge(age, referenceSex) : null); const bpPairsByDay = buildBpPairsByDay(recentMeasurements, bpRange, timezone); let bpClassification: TargetItem["classification"] = null; if ( @@ -216,7 +228,7 @@ export function buildVitalTargets({ unit: "mmHg", range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null, classification: bpClassification, - source: "ESH 2023", + source: bpTargetOverride ? "Custom" : "ESH 2023", ...rollupFromDayMap({ dayBandByKey: bpPairsByDay, ...consistencyClock, From eae46ed535d44388ba6ac33abccf20559687493e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:48:48 +0200 Subject: [PATCH 2/8] fix(medications): preselect only a dose offered today, graded by the account's thresholds The intake pickers chose their default from every active medication, so a course that had ended but was not archived could be the preselected dose. The default now reads the server's intakeActionable answer; the medications page dialog still lists ended courses for back-filling a past dose, they are just never the default. Both pickers also graded "due" against the fixed 120 / 240-minute defaults while the cards beside them used the account's own late and missed thresholds. They read the same thresholds now. --- src/app/medications/page-client.tsx | 1 + .../medication-intake-quick-add.test.tsx | 4 +- .../dashboard/medication-intake-quick-add.tsx | 6 ++- .../__tests__/log-intake-dialog.test.tsx | 39 ++++++++++++++++++ .../medications/log-intake-dialog.tsx | 6 ++- .../__tests__/default-medication.test.ts | 21 ++++++++++ src/lib/medications/default-medication.ts | 16 ++++++-- src/lib/queries/use-reminder-thresholds.ts | 40 +++++++++++++++++++ 8 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 src/lib/queries/use-reminder-thresholds.ts diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 73bd791ae..23df5de21 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -665,6 +665,7 @@ export default function MedicationsPageClient() { name: m.name, dose: m.dose, active: m.active, + intakeActionable: m.intakeActionable === true, lastTakenAt: m.lastTakenAt, todayEventCount: m.todayEventCount ?? 0, nextDueAt: m.nextDueAt, diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 343bedae8..9b0ca7e92 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -98,7 +98,9 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => { it("returns null when no active medications exist", () => { expect(pickDefaultMedicationId([])).toBeNull(); expect( - pickDefaultMedicationId([makeMed("a", { active: false })]), + pickDefaultMedicationId([ + makeMed("a", { active: false, intakeActionable: false }), + ]), ).toBeNull(); }); diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 29ae37927..91676c106 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -32,6 +32,7 @@ import { runUndoIntake, } from "@/components/medications/use-medication-intake"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { useMedicationDueThresholds } from "@/lib/queries/use-reminder-thresholds"; /** * v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add. @@ -122,6 +123,9 @@ export function MedicationIntakeQuickAdd({ // logged-out mounts behave unchanged. const { user } = useAuth(); const userTz = user?.timezone || DEFAULT_TIMEZONE; + // The account's own late / missed thresholds decide which dose counts as + // due for the preselection, the same tiers the medication cards show. + const dueThresholds = useMedicationDueThresholds(); // The record this dose lands in, or null in the caller's own. const recordName = useActiveRecordName(); @@ -188,7 +192,7 @@ export function MedicationIntakeQuickAdd({ medications, medicationOverride, selectionNow, - undefined, + dueThresholds, userTz, ) ?? ""; const selectedMedication = medications.find((m) => m.id === medicationId); diff --git a/src/components/medications/__tests__/log-intake-dialog.test.tsx b/src/components/medications/__tests__/log-intake-dialog.test.tsx index 2388cc65d..538e47e25 100644 --- a/src/components/medications/__tests__/log-intake-dialog.test.tsx +++ b/src/components/medications/__tests__/log-intake-dialog.test.tsx @@ -3,8 +3,15 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; +// The reminder-thresholds read: `undefined` = not loaded (the 120 / 240 +// defaults apply), otherwise the account's own tiers. +let mockThresholds: + | { lateMinutes: number; missedMinutes: number; lowStockRunwayDays: null } + | undefined; + vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useQuery: () => ({ data: mockThresholds }), })); vi.mock("sonner", () => ({ @@ -49,6 +56,7 @@ function medication( name: `Medication ${id}`, dose: "5 mg", active: true, + intakeActionable: true, schedules: [], lastTakenAt: null, todayEventCount: 0, @@ -82,6 +90,37 @@ describe(" medication default", () => { afterEach(() => { vi.useRealTimers(); + mockThresholds = undefined; + }); + + it("grades the due dose by the account's own missed threshold", () => { + // 10:00 in Berlin, five hours after a 04:00–05:00 window closed. Under the + // 240-minute default the dose is past missed and the alphabetical + // fallback wins; an account that keeps a six-hour missed threshold still + // sees it as very late, so it is the default. + const meds = [ + medication("first", { name: "Alpha" }), + medication("late", { + name: "Zulu", + nextDueAt: undefined, + schedules: [ + { + windowStart: "04:00", + windowEnd: "05:00", + daysOfWeek: null, + label: null, + dose: null, + }, + ], + }), + ]; + expectSelected(renderDialog(meds), "first"); + mockThresholds = { + lateMinutes: 120, + missedMinutes: 360, + lowStockRunwayDays: null, + }; + expectSelected(renderDialog(meds), "late"); }); it("selects an overdue current-window medication ahead of array order", () => { const html = renderDialog([ diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index 411ef022f..82df0513e 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -45,6 +45,7 @@ import { runUndoIntake, } from "@/components/medications/use-medication-intake"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { useMedicationDueThresholds } from "@/lib/queries/use-reminder-thresholds"; interface LogIntakeSchedule extends ScheduleWindowInput { label: string | null; @@ -122,6 +123,9 @@ export function LogIntakeDialog({ const queryClient = useQueryClient(); const { user } = useAuth(); const userTz = user?.timezone || DEFAULT_TIMEZONE; + // The account's own late / missed thresholds decide which dose counts as + // due for the preselection, the same tiers the medication cards show. + const dueThresholds = useMedicationDueThresholds(); const formId = useId(); const [medicationOverride, setMedicationOverride] = useState( @@ -133,7 +137,7 @@ export function LogIntakeDialog({ medications, medicationOverride, selectionNow, - undefined, + dueThresholds, userTz, ) ?? ""; const [slot, setSlot] = useState(NO_SLOT); diff --git a/src/lib/medications/__tests__/default-medication.test.ts b/src/lib/medications/__tests__/default-medication.test.ts index 8390c4b69..adcd529e4 100644 --- a/src/lib/medications/__tests__/default-medication.test.ts +++ b/src/lib/medications/__tests__/default-medication.test.ts @@ -14,6 +14,7 @@ function medication( id, name: `Medication ${id}`, active: true, + intakeActionable: true, schedules: [], lastTakenAt: null, todayEventCount: 0, @@ -62,6 +63,26 @@ describe("pickDefaultMedicationId", () => { expect(pickDefaultMedicationId(options, now)).toBe("r"); expect(pickDefaultMedicationId([...options].reverse(), now)).toBe("r"); }); + + it("never preselects an ended course, even when it is the only active one", () => { + // Still active (not archived) but the course ended: the server answers + // `intakeActionable: false`, the list keeps it for back-filling, and the + // default lands on the medication that offers a dose today. + const options = [ + medication("ended", { + name: "Amoxicillin", + active: true, + intakeActionable: false, + }), + medication("current", { name: "Ramipril" }), + ]; + expect(pickDefaultMedicationId(options)).toBe("current"); + expect( + pickDefaultMedicationId([ + medication("ended", { active: true, intakeActionable: false }), + ]), + ).toBeNull(); + }); }); describe("resolveMedicationSelectionId", () => { diff --git a/src/lib/medications/default-medication.ts b/src/lib/medications/default-medication.ts index dee3be9b9..c91e526ef 100644 --- a/src/lib/medications/default-medication.ts +++ b/src/lib/medications/default-medication.ts @@ -8,6 +8,13 @@ export interface DefaultMedicationOption { id: string; name: string; active: boolean; + /** + * The server's answer to "does this medication offer a dose today" + * (`src/lib/medications/intake-actionable.ts`): active, intake tracked, + * and the course includes today. The preselection reads it and never + * re-derives it from `active`, so an ended course is never the default. + */ + intakeActionable: boolean; schedules: ScheduleWindowInput[]; lastTakenAt: string | null; todayEventCount?: number | null; @@ -27,8 +34,11 @@ const DEFAULT_THRESHOLDS: MedicationDueThresholds = { /** * Pick the medication that should be pre-selected when an intake form opens. - * Current, late, and overdue schedule windows win; otherwise the fallback is - * the single active medication or the first active name alphabetically. + * Only a medication that offers a dose today (`intakeActionable`) can be the + * default. Among those, current, late, and overdue schedule windows win; + * otherwise the fallback is the single candidate or the first name + * alphabetically. A list may still carry ended courses for back-filling a + * past dose; they stay pickable by hand but are never preselected. */ export function pickDefaultMedicationId( options: DefaultMedicationOption[], @@ -36,7 +46,7 @@ export function pickDefaultMedicationId( thresholds: MedicationDueThresholds = DEFAULT_THRESHOLDS, tz: string = DEFAULT_TIMEZONE, ): string | null { - const actives = options.filter((medication) => medication.active); + const actives = options.filter((medication) => medication.intakeActionable); if (actives.length === 0) return null; if (actives.length === 1) return actives[0].id; diff --git a/src/lib/queries/use-reminder-thresholds.ts b/src/lib/queries/use-reminder-thresholds.ts new file mode 100644 index 000000000..dab89e19b --- /dev/null +++ b/src/lib/queries/use-reminder-thresholds.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { useAuth } from "@/hooks/use-auth"; +import type { MedicationDueThresholds } from "@/lib/medications/default-medication"; + +/** + * The account's own late / missed thresholds for the intake pickers. + * + * The cards, the table and the medications page each read + * `/api/settings/reminder-thresholds` under this key with the same fetcher + * shape (failure resolves to `null`), so the pickers share their cache entry. + * `undefined` until it lands or when it failed — the default picker then + * falls back to the 120 / 240-minute defaults every reader shares. + */ +export function useMedicationDueThresholds(): + MedicationDueThresholds | undefined { + const { isAuthenticated } = useAuth(); + const { data } = useQuery({ + queryKey: queryKeys.settingsReminderThresholds(), + queryFn: async () => { + try { + return await apiGet<{ + lateMinutes: number; + missedMinutes: number; + lowStockRunwayDays: number | null; + }>("/api/settings/reminder-thresholds"); + } catch { + return null; + } + }, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + if (!data) return undefined; + return { lateMinutes: data.lateMinutes, missedMinutes: data.missedMinutes }; +} From fdbbd3a84adbbd37c29c120069f07fc39b613554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:50:18 +0200 Subject: [PATCH 3/8] fix(settings): gate the Coach tuning on the record's module state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Coach hid its preference and nudge cards on the raw disableCoach column, which /me publishes for the signed-in person. Inside a managed or shared record that answered for the wrong person: a guardian with the Coach on saw the tuning for a record that has it off, and the other way round. The cards now read the module map the server resolves for the record on screen, the same map the navigation and the settings entry already follow. --- .../__tests__/coach-memory-while-off.test.tsx | 2 + .../coach-reminders-read-only.test.tsx | 2 + .../settings/__tests__/coach-section.test.tsx | 40 ++++++++++++++++++- src/components/settings/coach-section.tsx | 21 ++++++---- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/components/settings/__tests__/coach-memory-while-off.test.tsx b/src/components/settings/__tests__/coach-memory-while-off.test.tsx index 96eecaca1..ffa3ee4b0 100644 --- a/src/components/settings/__tests__/coach-memory-while-off.test.tsx +++ b/src/components/settings/__tests__/coach-memory-while-off.test.tsx @@ -24,6 +24,8 @@ vi.mock("@/hooks/use-auth", () => ({ role: "USER", timezone: "Europe/Berlin", disableCoach: authState.disableCoach, + // The server's module map mirrors the record's opt-out. + modules: { coach: !authState.disableCoach }, }, isAuthenticated: true, isLoading: false, diff --git a/src/components/settings/__tests__/coach-reminders-read-only.test.tsx b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx index 6a2011886..21af3b076 100644 --- a/src/components/settings/__tests__/coach-reminders-read-only.test.tsx +++ b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx @@ -34,6 +34,8 @@ vi.mock("@/hooks/use-auth", () => ({ role: "USER", timezone: "Europe/Berlin", disableCoach: !coach.available, + // The record's module map, which the section's gate reads. + modules: { coach: coach.available }, }, isAuthenticated: true, isLoading: false, diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index f24d59fff..20c9689d2 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -14,6 +14,13 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/settings/coach", })); +// Past hydration only where a test says so: the gate reads the account once +// mounted, and the pre-hydration pass shows every card. +const mountState = { mounted: false }; +vi.mock("@/hooks/use-mounted", () => ({ + useMounted: () => mountState.mounted, +})); + vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), useInfiniteQuery: () => ({ data: undefined, isLoading: false }), @@ -31,10 +38,18 @@ vi.mock("@/hooks/use-ai-capability", () => ({ }), })); -const authState: { disableCoach: boolean } = { disableCoach: false }; +const authState: { + disableCoach: boolean; + modules: { coach?: boolean } | undefined; +} = { disableCoach: false, modules: undefined }; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ - user: { id: "u1", role: "USER", disableCoach: authState.disableCoach }, + user: { + id: "u1", + role: "USER", + disableCoach: authState.disableCoach, + modules: authState.modules, + }, isAuthenticated: true, isLoading: false, refetch: vi.fn(), @@ -83,3 +98,24 @@ describe(" — SSR smoke", () => { expect(html).not.toContain("settings.coach.activate."); }); }); + +describe(" — the tuning follows the record, not the account", () => { + it("hides the nudge when the record has the Coach off, whatever the viewer's own opt-out says", () => { + // A guardian with the Coach on, inside a managed record that has it off: + // `/me` publishes the viewer's own `disableCoach` and the RECORD's module + // map. The record's answer decides. + mountState.mounted = true; + authState.disableCoach = false; + authState.modules = { coach: false }; + try { + expect(render()).not.toContain('id="coach-nudge"'); + authState.disableCoach = true; + authState.modules = { coach: true }; + expect(render()).toContain('id="coach-nudge"'); + } finally { + mountState.mounted = false; + authState.disableCoach = false; + authState.modules = undefined; + } + }); +}); diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx index e4a0ccc83..631029590 100644 --- a/src/components/settings/coach-section.tsx +++ b/src/components/settings/coach-section.tsx @@ -17,8 +17,12 @@ * 4. Coach memory — durable-fact review + forget controls. * * The nav entry is module-gated on `coach`. The preference + nudge cards - * keep their own content gate on `!user.disableCoach`: hiding the Coach hides - * its tuning. The memory (facts, stored conversations and reminders) is never + * keep their own content gate on the same published module state + * (`user.modules.coach`, resolved by the server for the record on screen + * from that record's opt-out and the operator's switches): hiding the Coach + * hides its tuning. The raw `disableCoach` column is the signed-in person's + * own, so inside a managed or shared record it answered for the wrong + * person. The memory (facts, stored conversations and reminders) is never * gated: it is the person's own record, readable and deletable whatever the * Coach's state. The reminders card turns read-only while the Coach is * unavailable and, with the Coach hidden, shows only when rows exist. @@ -39,6 +43,7 @@ import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { isSurfaceVisible } from "@/lib/modules/surface"; export function CoachSection() { const { isAuthenticated, user } = useAuth(); @@ -47,11 +52,13 @@ export function CoachSection() { // `useMounted()` to keep the hydration render matching the SSR HTML. const mounted = useMounted(); const authed = mounted && isAuthenticated; - // The preference + memory cards ride the same content gate as the rest - // of the Coach surface: when the user has hidden the Coach there is no - // assistant to tune or remember anything. (`mounted`-gated so the SSR + - // hydration renders agree.) - const coachEnabled = !mounted || !user?.disableCoach; + // The preference + nudge cards ride the same content gate as the rest of + // the Coach surface: when the record has the Coach hidden there is no + // assistant to tune. Read from the server's module map for the record, + // never recomputed from the account's own columns. (`mounted`-gated so the + // SSR + hydration renders agree.) + const coachEnabled = + !mounted || isSurfaceVisible("settings:coach", user?.modules); return (
From a66976874e9b685dd52669495a50ad8bcb246c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:01:06 +0200 Subject: [PATCH 4/8] fix(mood): report the tag and factor keys a mood write did not store An unknown or archived tag key, or a factor the catalog no longer carries, was dropped on the way in while the entry still reported "inserted" (bulk) or 201 (create, edit). A client had no way to tell that part of what it sent never landed. Each write now names those keys: the bulk result per entry, the create and edit responses on the entry, as droppedTagKeys and droppedFactorKeys. Both are present only when non-empty, so a response where everything landed is unchanged. An archived tag an edit preserved is on the entry and is not listed. The web shows a short notice after saving when a picked tag was left off. --- docs/api/openapi.yaml | 26 +++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../mood-entries/[id]/__tests__/route.test.ts | 5 +- src/app/api/mood-entries/[id]/route.ts | 26 ++- .../api/mood-entries/__tests__/route.test.ts | 40 ++++- .../mood-entries/bulk/__tests__/route.test.ts | 48 +++++- src/app/api/mood-entries/bulk/route.ts | 35 +++- src/app/api/mood-entries/route.ts | 26 ++- src/components/mood/mood-form.tsx | 9 +- src/components/mood/mood-list.tsx | 11 +- .../mood/__tests__/dropped-link-keys.test.ts | 16 ++ src/lib/mood/__tests__/tag-links.test.ts | 66 ++++++++ src/lib/mood/dropped-link-keys.ts | 20 +++ src/lib/mood/tag-links.ts | 153 +++++++++++++++--- src/lib/openapi/routes/mood.ts | 23 +++ tests/integration/mood-entries-bulk.test.ts | 64 ++++++++ 22 files changed, 525 insertions(+), 50 deletions(-) create mode 100644 src/lib/mood/__tests__/dropped-link-keys.test.ts create mode 100644 src/lib/mood/dropped-link-keys.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20072acf6..e6e09540c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -60081,6 +60081,19 @@ components: externalId: description: Echoed back when the entry carried one, for client mapping. type: string + droppedTagKeys: + description: "`tagKeys` from this request that were not stored: unknown to the catalog, archived, another account's + custom tag, or a rated factor sent as a binary key. Present only when non-empty; absent means every + submitted key landed. The entry itself was written." + type: array + items: + type: string + droppedFactorKeys: + description: "`ratedFactors` keys from this request that were not stored: unknown, archived, or not a rated factor. + Present only when non-empty; absent means every submitted factor landed." + type: array + items: + type: string required: - index - status @@ -60722,6 +60735,19 @@ components: - $ref: "#/components/schemas/MoodContextOutput" - type: "null" description: The day's context, or null when the entry carries none. Null and an object of nulls are different answers. + droppedTagKeys: + description: "Create and edit responses only: `tagKeys` from the request that are not on the entry (unknown, archived, + another account's custom tag, or a rated factor sent as a binary key). Present only when non-empty. An + archived tag the edit preserved is on the entry and is not listed." + type: array + items: + type: string + droppedFactorKeys: + description: "Create and edit responses only: `ratedFactors` keys from the request that are not on the entry. Present + only when non-empty." + type: array + items: + type: string required: - id - userId diff --git a/messages/de.json b/messages/de.json index cecd50085..553d63b6c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "Damit werden die eingegebene Stimmung, Tags und Notiz gelöscht. Das lässt sich nicht rückgängig machen.", "formResetConfirmAction": "Zurücksetzen", "saveError": "Fehler beim Speichern", + "tagsNotSaved": "Gespeichert. Tags, die es nicht mehr gibt, wurden weggelassen.", "deleteError": "Fehler beim Löschen", "deletedToast": "Eintrag gelöscht", "restoredToast": "Eintrag wiederhergestellt", diff --git a/messages/en.json b/messages/en.json index 322af6ad7..55d0718d6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "This clears the mood, tags and note you have entered. This cannot be undone.", "formResetConfirmAction": "Reset", "saveError": "Error saving", + "tagsNotSaved": "Saved. Tags that are no longer available were left off.", "deleteError": "Error deleting", "deletedToast": "Entry deleted", "restoredToast": "Entry restored", diff --git a/messages/es.json b/messages/es.json index 06010c86e..1718c1acc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "Esto borra el estado de ánimo, las etiquetas y la nota que has introducido. No se puede deshacer.", "formResetConfirmAction": "Restablecer", "saveError": "Error al guardar", + "tagsNotSaved": "Guardado. Se omitieron las etiquetas que ya no están disponibles.", "deleteError": "Error al eliminar", "deletedToast": "Entrada eliminada", "restoredToast": "Entrada restaurada", diff --git a/messages/fr.json b/messages/fr.json index 5135074b2..0afad1da7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "Cela efface l’humeur, les tags et la note que vous avez saisis. Cette action est irréversible.", "formResetConfirmAction": "Réinitialiser", "saveError": "Erreur lors de l’enregistrement", + "tagsNotSaved": "Enregistré. Les étiquettes qui ne sont plus disponibles ont été omises.", "deleteError": "Erreur lors de la suppression", "deletedToast": "Entrée supprimée", "restoredToast": "Entrée restaurée", diff --git a/messages/it.json b/messages/it.json index 7ffad325e..883d3c4a1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "Questo cancella umore, tag e nota inseriti. L’operazione non può essere annullata.", "formResetConfirmAction": "Reimposta", "saveError": "Errore durante il salvataggio", + "tagsNotSaved": "Salvato. I tag non più disponibili sono stati omessi.", "deleteError": "Errore durante l’eliminazione", "deletedToast": "Voce eliminata", "restoredToast": "Voce ripristinata", diff --git a/messages/ko.json b/messages/ko.json index 28038e56f..f814ab916 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "입력한 기분, 태그, 메모가 모두 지워져요. 되돌릴 수 없어요.", "formResetConfirmAction": "초기화", "saveError": "저장하지 못했어요", + "tagsNotSaved": "저장했습니다. 더 이상 사용할 수 없는 태그는 제외했습니다.", "deleteError": "삭제하지 못했어요", "deletedToast": "기록을 삭제했어요", "restoredToast": "기록을 되살렸어요", diff --git a/messages/pl.json b/messages/pl.json index 295afc34f..7e45f4587 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "Spowoduje to usunięcie wprowadzonego nastroju, tagów i notatki. Tej operacji nie można cofnąć.", "formResetConfirmAction": "Resetuj", "saveError": "Błąd zapisu", + "tagsNotSaved": "Zapisano. Pominięto tagi, które nie są już dostępne.", "deleteError": "Błąd usuwania", "deletedToast": "Wpis usunięty", "restoredToast": "Wpis przywrócony", diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index 3570b2b13..fc01e1444 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -126,8 +126,8 @@ beforeEach(() => { txClient.moodEntryTagLink.findMany.mockResolvedValue([]); txClient.moodContext.deleteMany.mockResolvedValue({ count: 0 }); txClient.moodContext.findUnique.mockResolvedValue(null); - vi.mocked(replaceTagLinks).mockResolvedValue(undefined); - vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined); + vi.mocked(replaceTagLinks).mockResolvedValue([]); + vi.mocked(replaceRatedFactorLinks).mockResolvedValue([]); // v1.7.0 sync — PUT now looks the row up via `findFirst` with a // `deletedAt: null` guard (refuses to resurrect-edit a tombstone). vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue( @@ -316,6 +316,7 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => { }; vi.mocked(replaceTagLinks).mockImplementationOnce(async () => { draft.binary = ["calm"]; + return []; }); vi.mocked(replaceRatedFactorLinks).mockImplementationOnce(async () => { draft.rated = [{ key: "factor_work", rating: 5 }]; diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 94cb5f59e..816293996 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -25,6 +25,7 @@ import { RatedFactorOutOfRangeError, replaceRatedFactorLinks, replaceTagLinks, + droppedLinkKeysForWire, } from "@/lib/mood/tag-links"; type RouteParams = { params: Promise<{ id: string }> }; @@ -197,11 +198,15 @@ export const PUT = apiHandler( // Omission preserves the corresponding link set. Explicit null/empty // clears it, matching the validated update contract. - if (data.tagKeys !== undefined) { - await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx); - } + // Each replacement returns the submitted keys it could not store, + // reported on the response rather than dropped in silence. + const droppedTagKeys = + data.tagKeys !== undefined + ? await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx) + : []; + let droppedFactorKeys: string[] = []; if (data.ratedFactors !== undefined) { - await replaceRatedFactorLinks( + droppedFactorKeys = await replaceRatedFactorLinks( id, user.id, data.ratedFactors ?? [], @@ -236,6 +241,7 @@ export const PUT = apiHandler( return { entry: updated, contextOutcome, + droppedKeys: { droppedTagKeys, droppedFactorKeys }, persistedContext: storedContext, persistedTagKeys: links .filter((link) => link.moodTag.kind !== "RATED") @@ -282,6 +288,7 @@ export const PUT = apiHandler( persistedRatedFactors, persistedContext, contextOutcome, + droppedKeys, } = transactionOutcome.result; await auditLog("moodEntry.update", { @@ -315,7 +322,12 @@ export const PUT = apiHandler( annotate({ action: { name: "mood-entries.update" }, - meta: { moodEntryId: id, mood_context: contextOutcome }, + meta: { + moodEntryId: id, + mood_context: contextOutcome, + dropped_tag_keys: droppedKeys.droppedTagKeys.length, + dropped_factor_keys: droppedKeys.droppedFactorKeys.length, + }, }); // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. @@ -360,6 +372,10 @@ export const PUT = apiHandler( ratedFactors: persistedRatedFactors, // v1.38 — the stored context after the edit, or null when there is none. context: persistedContext ? contextForWire(persistedContext) : null, + // Submitted keys that are not on the entry after the edit, each list + // present only when non-empty (an archived link the edit preserved is + // on the entry and is not listed). + ...droppedLinkKeysForWire(droppedKeys), }); }, ); diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 3af7aca11..08f0b7de7 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -66,8 +66,10 @@ vi.mock("@/lib/mood/tag-links", async () => { "@/lib/mood/tag-links", ); return { - createTagLinks: vi.fn().mockResolvedValue(undefined), + createTagLinks: vi.fn().mockResolvedValue(actual.NO_DROPPED_LINK_KEYS), RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError, + droppedLinkKeysForWire: actual.droppedLinkKeysForWire, + NO_DROPPED_LINK_KEYS: actual.NO_DROPPED_LINK_KEYS, }; }); @@ -137,7 +139,10 @@ beforeEach(() => { (fn as (tx: typeof txClient) => unknown)(txClient), ); txClient.moodEntryTagLink.findMany.mockResolvedValue([]); - vi.mocked(createTagLinks).mockResolvedValue(undefined); + vi.mocked(createTagLinks).mockResolvedValue({ + droppedTagKeys: [], + droppedFactorKeys: [], + }); // `reset` blanks the post-commit best-effort mock; restore the promise // return so `recompute(...)` awaits. vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined); @@ -349,6 +354,37 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", () expect(body.data.tagKeys).toEqual(["happy"]); }); + it("names the keys it did not store on the 201, and omits the fields when all landed", async () => { + txClient.moodEntry.create.mockResolvedValue({ + id: "mood-1", + tags: null, + moodLoggedAt: new Date(VALID_BODY.moodLoggedAt), + mood: "GUT", + note: null, + source: "MANUAL", + date: "2026-06-01", + }); + txClient.moodEntryTagLink.findMany.mockResolvedValue([ + { moodTag: { key: "happy" } }, + ]); + vi.mocked(createTagLinks).mockResolvedValueOnce({ + droppedTagKeys: ["custom:archived"], + droppedFactorKeys: [], + }); + + const res = await POST( + postReq({ ...VALID_BODY, tagKeys: ["happy", "custom:archived"] }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: Record }; + expect(body.data.droppedTagKeys).toEqual(["custom:archived"]); + expect(body.data).not.toHaveProperty("droppedFactorKeys"); + + const clean = await POST(postReq({ ...VALID_BODY, tagKeys: ["happy"] })); + const cleanBody = (await clean.json()) as { data: Record }; + expect(cleanBody.data).not.toHaveProperty("droppedTagKeys"); + }); + it("rolls the entry back when the tag-link write fails (no commit)", async () => { txClient.moodEntry.create.mockResolvedValue({ id: "mood-2", diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index e897edbdc..35907e376 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -55,8 +55,10 @@ vi.mock("@/lib/mood/tag-links", async () => { "@/lib/mood/tag-links", ); return { - createTagLinks: vi.fn().mockResolvedValue(undefined), + createTagLinks: vi.fn().mockResolvedValue(actual.NO_DROPPED_LINK_KEYS), RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError, + droppedLinkKeysForWire: actual.droppedLinkKeysForWire, + NO_DROPPED_LINK_KEYS: actual.NO_DROPPED_LINK_KEYS, }; }); @@ -140,7 +142,10 @@ beforeEach(() => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); - vi.mocked(createTagLinks).mockResolvedValue(undefined); + vi.mocked(createTagLinks).mockResolvedValue({ + droppedTagKeys: [], + droppedFactorKeys: [], + }); }); describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => { @@ -317,6 +322,45 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { ); }); + it("lists the keys that did not land on that entry's result, and only there", async () => { + vi.mocked(createTagLinks) + .mockResolvedValueOnce({ + droppedTagKeys: ["custom:archived"], + droppedFactorKeys: ["factor_retired"], + }) + .mockResolvedValueOnce({ droppedTagKeys: [], droppedFactorKeys: [] }); + const res = await POST( + postReq({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + tagKeys: ["movies", "custom:archived"], + ratedFactors: [{ key: "factor_retired", rating: 2 }], + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + tagKeys: ["movies"], + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { entries: Array> }; + }; + expect(json.data.entries[0]).toMatchObject({ + status: "inserted", + droppedTagKeys: ["custom:archived"], + droppedFactorKeys: ["factor_retired"], + }); + // Nothing dropped: the fields are absent, so the result reads exactly + // as it did before they existed. + expect(json.data.entries[1]).not.toHaveProperty("droppedTagKeys"); + expect(json.data.entries[1]).not.toHaveProperty("droppedFactorKeys"); + }); + it("reads existence once via a batched findMany, keyed on (source, externalId) when the entry carries one", async () => { const res = await POST( postReq({ diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 3a504fa71..d22415e71 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -58,7 +58,11 @@ import { persistMoodContext } from "@/lib/mood/context"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; -import { createTagLinks } from "@/lib/mood/tag-links"; +import { + createTagLinks, + droppedLinkKeysForWire, + NO_DROPPED_LINK_KEYS, +} from "@/lib/mood/tag-links"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -73,8 +77,9 @@ const bulkEntrySchema = z.object({ * Without this the bulk path Zod-stripped the field, so iOS-sent * taxonomy links were silently dropped on the adopt-on-pair backfill. * The server resolves each key to a `MoodTag` row and writes the - * `MoodEntryTagLink` join; unknown keys are dropped silently (the - * catalog is the source of truth). Bounds match the single-entry + * `MoodEntryTagLink` join; an unknown or archived key is not stored (the + * catalog is the source of truth) and is listed on that entry's result + * as `droppedTagKeys`. Bounds match the single-entry * `structuredTagKeys` schema so one entry can't fan out an unbounded * link set. */ @@ -178,6 +183,11 @@ interface EntryResult { // result so iOS can map a server row id onto its local SwiftData row // without re-deriving it. Omitted when the entry sent no externalId. externalId?: string; + // Submitted keys the catalog did not resolve (unknown, archived, another + // account's custom tag, or the wrong kind). Present only when non-empty, + // on an "inserted" or "duplicate" result: the entry landed, these did not. + droppedTagKeys?: string[]; + droppedFactorKeys?: string[]; } export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); @@ -251,6 +261,8 @@ async function postBulk(request: NextRequest): Promise { const results: EntryResult[] = []; let inserted = 0; let duplicates = 0; + let droppedTagKeyCount = 0; + let droppedFactorKeyCount = 0; const skipped: Array<{ index: number; reason: string }> = []; const unstableShapes: UnstableExternalIdShape[] = []; @@ -437,7 +449,7 @@ async function postBulk(request: NextRequest): Promise { // the row committed) left a persisted mood entry with no links while the // per-entry catch reported the entry as "skipped" — the client believes // nothing saved and a half-written row is on disk. - const result = await prisma.$transaction(async (tx) => { + const written = await prisma.$transaction(async (tx) => { const upserted = await tx.moodEntry.upsert({ where: probeWhere, create: { @@ -489,11 +501,14 @@ async function postBulk(request: NextRequest): Promise { // an entry reported as skipped really did write nothing. await persistMoodContext(tx, upserted.id, user.id, parsedContext.data); + // The keys the catalog did not resolve are reported on this entry's + // result: "inserted" alone told a client its tags had landed. + let droppedKeys = NO_DROPPED_LINK_KEYS; if ( (entry.tagKeys && entry.tagKeys.length > 0) || (entry.ratedFactors && entry.ratedFactors.length > 0) ) { - await createTagLinks( + droppedKeys = await createTagLinks( upserted.id, user.id, entry.tagKeys ?? [], @@ -502,8 +517,12 @@ async function postBulk(request: NextRequest): Promise { ); } - return upserted; + return { upserted, droppedKeys }; }); + const result = written.upserted; + const droppedKeys = written.droppedKeys; + droppedTagKeyCount += droppedKeys.droppedTagKeys.length; + droppedFactorKeyCount += droppedKeys.droppedFactorKeys.length; if (existing) { duplicates += 1; @@ -512,6 +531,7 @@ async function postBulk(request: NextRequest): Promise { status: "duplicate", id: result.id, ...(entry.externalId ? { externalId: entry.externalId } : {}), + ...droppedLinkKeysForWire(droppedKeys), }); } else { inserted += 1; @@ -520,6 +540,7 @@ async function postBulk(request: NextRequest): Promise { status: "inserted", id: result.id, ...(entry.externalId ? { externalId: entry.externalId } : {}), + ...droppedLinkKeysForWire(droppedKeys), }); } } catch (err: unknown) { @@ -586,6 +607,8 @@ async function postBulk(request: NextRequest): Promise { inserted, duplicates, skipped: skipped.length, + dropped_tag_keys: droppedTagKeyCount, + dropped_factor_keys: droppedFactorKeyCount, ...(unstableShapes.length > 0 ? unstableExternalIdMeta("mood.bulk", unstableShapes) : {}), diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 86b14901a..9f23a965f 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -33,6 +33,8 @@ import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { createTagLinks, + droppedLinkKeysForWire, + NO_DROPPED_LINK_KEYS, RatedFactorOutOfRangeError, } from "@/lib/mood/tag-links"; @@ -219,6 +221,7 @@ async function postMoodEntry(request: NextRequest) { persistedRatedFactors, persistedContext, contextOutcome, + droppedKeys, } = await prisma.$transaction(async (tx) => { // v1.12.1 — when the client supplies a source-stable `externalId`, // upsert on the NULL-distinct `(userId, source, externalId)` key so @@ -287,14 +290,16 @@ async function postMoodEntry(request: NextRequest) { }, }); + let droppedKeys = NO_DROPPED_LINK_KEYS; if ( (tagKeys && tagKeys.length > 0) || (ratedFactors && ratedFactors.length > 0) ) { - // Unknown / non-RATED keys are dropped inside the helper (the - // catalog is the source of truth). An out-of-scale rating - // throws `RatedFactorOutOfRangeError`, rolling the tx back. - await createTagLinks( + // Unknown, archived or non-RATED keys are not stored (the catalog + // is the source of truth); the helper returns them so the response + // can say so. An out-of-scale rating throws + // `RatedFactorOutOfRangeError`, rolling the tx back. + droppedKeys = await createTagLinks( created.id, user.id, tagKeys ?? [], @@ -333,6 +338,7 @@ async function postMoodEntry(request: NextRequest) { return { entry: created, contextOutcome, + droppedKeys, persistedContext: storedContext, persistedTagKeys: links .filter((link) => link.moodTag.kind !== "RATED") @@ -360,7 +366,13 @@ async function postMoodEntry(request: NextRequest) { // boolean, because "the request said nothing" and "the request cleared // it" are different acts and a dashboard that folded them together // could not tell an abandoned section from a deliberate one. - meta: { moodEntryId: entry.id, mood, mood_context: contextOutcome }, + meta: { + moodEntryId: entry.id, + mood, + mood_context: contextOutcome, + dropped_tag_keys: droppedKeys.droppedTagKeys.length, + dropped_factor_keys: droppedKeys.droppedFactorKeys.length, + }, }); // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. @@ -398,6 +410,10 @@ async function postMoodEntry(request: NextRequest) { // when the entry carries none, which is a different answer from an // object full of nulls and reads as one. context: persistedContext ? contextForWire(persistedContext) : null, + // The submitted keys that were not stored (unknown, archived, or the + // wrong kind), each list present only when non-empty. Without them + // a 201 read as "everything landed" when part of it had not. + ...droppedLinkKeysForWire(droppedKeys), }, 201, ); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 23b18f965..9b14f4697 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -63,6 +63,10 @@ import { import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context"; import { MoodQuickTags } from "./mood-quick-tags"; import { useRecentTags } from "./recent-tags"; +import { + droppedAnyLinkKeys, + type DroppedLinkKeysWire, +} from "@/lib/mood/dropped-link-keys"; import { moodFaceIcon } from "./mood-tag-icons"; // One catalogue for the chip strip and for every server-side reader of what it // writes. Split apart, the write side gained a locale and the read side did @@ -219,7 +223,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { const trimmedNote = note.trim(); - await apiPost("/api/mood-entries", { + const saved = await apiPost("/api/mood-entries", { mood, tags: tags.length > 0 ? tags : undefined, tagKeys: tagKeys.length > 0 ? tagKeys : undefined, @@ -243,6 +247,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { await invalidateKeys(queryClient, moodDependentKeys); await refetchInactiveDailyReads(queryClient); toast.success(t("common.saved")); + // The entry landed, but a tag picked here may have been archived or + // deleted elsewhere since the picker loaded; the server says so. + if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved")); onSuccess?.(); } catch (err) { setError(localizedApiError(err, t, "mood.saveError")); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 3f2144d57..6d68cd3a2 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -112,6 +112,10 @@ import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { MOOD_LIST_PAGE_SIZE as PAGE_SIZE } from "@/lib/mood/list-page-size"; import { localizedApiError } from "@/lib/api/localized-error"; +import { + droppedAnyLinkKeys, + type DroppedLinkKeysWire, +} from "@/lib/mood/dropped-link-keys"; // Re-export the score map under the legacy local name to keep the // rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single @@ -471,7 +475,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { note: string | null; moodLoggedAt: string; }) => { - await apiPut(`/api/mood-entries/${id}`, { + return apiPut(`/api/mood-entries/${id}`, { mood, tags, tagKeys, @@ -488,7 +492,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { moodLoggedAt, }); }, - onSuccess: async () => { + onSuccess: async (saved) => { + // A tag picked in the dialog may have been archived or deleted + // elsewhere since it opened; the server names what it did not store. + if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved")); await invalidateKeys(queryClient, moodDependentKeys); await refetchInactiveDailyReads(queryClient); setEditSeed({ diff --git a/src/lib/mood/__tests__/dropped-link-keys.test.ts b/src/lib/mood/__tests__/dropped-link-keys.test.ts new file mode 100644 index 000000000..862bf5867 --- /dev/null +++ b/src/lib/mood/__tests__/dropped-link-keys.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { droppedAnyLinkKeys } from "@/lib/mood/dropped-link-keys"; + +describe("droppedAnyLinkKeys — the web's read of a mood write response", () => { + it("is false for a response that lists nothing (the fields are absent)", () => { + expect(droppedAnyLinkKeys({})).toBe(false); + expect(droppedAnyLinkKeys(null)).toBe(false); + expect(droppedAnyLinkKeys({ droppedTagKeys: [] })).toBe(false); + }); + + it("is true when either list names a key", () => { + expect(droppedAnyLinkKeys({ droppedTagKeys: ["custom:gone"] })).toBe(true); + expect(droppedAnyLinkKeys({ droppedFactorKeys: ["factor_x"] })).toBe(true); + }); +}); diff --git a/src/lib/mood/__tests__/tag-links.test.ts b/src/lib/mood/__tests__/tag-links.test.ts index c14e7c7e6..0f2863893 100644 --- a/src/lib/mood/__tests__/tag-links.test.ts +++ b/src/lib/mood/__tests__/tag-links.test.ts @@ -310,3 +310,69 @@ describe("entry-ownership guard", () => { }); }); }); + +describe("the write helpers report the keys they did not store", () => { + it("createTagLinks returns the unresolved binary and factor keys", async () => { + // First lookup: rated factors (only `factor_work` is a live factor). + // Second lookup: binary keys (`happy` live; the other two are unknown or + // archived, which the `isActive` filter makes look the same). + moodTagFindMany + .mockResolvedValueOnce([ + { id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 }, + ]) + .mockResolvedValueOnce([{ id: "mt_happy", key: "happy" }]); + + const dropped = await createTagLinks( + "entry-1", + "user-1", + ["happy", "custom:gone", "no_such_tag", "happy"], + undefined, + [ + { key: "factor_work", rating: 3 }, + { key: "factor_retired", rating: 2 }, + ], + ); + + expect(dropped).toEqual({ + droppedTagKeys: ["custom:gone", "no_such_tag"], + droppedFactorKeys: ["factor_retired"], + }); + }); + + it("createTagLinks reports nothing when every key landed", async () => { + moodTagFindMany.mockResolvedValue([{ id: "mt_happy", key: "happy" }]); + expect(await createTagLinks("entry-1", "user-1", ["happy"])).toEqual({ + droppedTagKeys: [], + droppedFactorKeys: [], + }); + }); + + it("replaceTagLinks does not report a key whose archived link it preserved", async () => { + // `custom:old` is archived but already on the entry: the edit keeps it, + // so it is not dropped. `custom:never` resolves to nothing anywhere. + moodTagFindMany.mockResolvedValue([]); + linkFindMany.mockResolvedValue([ + { + moodTagId: "mt_old", + moodTag: { isActive: false, kind: "BINARY", key: "custom:old" }, + }, + ]); + expect( + await replaceTagLinks("entry-1", "user-1", [ + "custom:old", + "custom:never", + ]), + ).toEqual(["custom:never"]); + }); + + it("replaceRatedFactorLinks does not report an archived factor that stays on the entry", async () => { + moodTagFindMany.mockResolvedValue([]); + linkFindMany.mockResolvedValue([{ moodTag: { key: "factor_archived" } }]); + expect( + await replaceRatedFactorLinks("entry-1", "user-1", [ + { key: "factor_archived", rating: 2 }, + { key: "factor_unknown", rating: 2 }, + ]), + ).toEqual(["factor_unknown"]); + }); +}); diff --git a/src/lib/mood/dropped-link-keys.ts b/src/lib/mood/dropped-link-keys.ts new file mode 100644 index 000000000..18e7612c7 --- /dev/null +++ b/src/lib/mood/dropped-link-keys.ts @@ -0,0 +1,20 @@ +/** + * Client-side reader for the `droppedTagKeys` / `droppedFactorKeys` fields a + * mood write answers with (see `src/lib/mood/tag-links.ts`). Each list is + * present only when the server did not store some of the submitted keys — + * a tag archived in another tab, a custom tag deleted on another device — + * so its presence alone is the signal; the client re-derives nothing. + */ +export interface DroppedLinkKeysWire { + droppedTagKeys?: string[]; + droppedFactorKeys?: string[]; +} + +export function droppedAnyLinkKeys( + response: DroppedLinkKeysWire | null | undefined, +): boolean { + return ( + (response?.droppedTagKeys?.length ?? 0) > 0 || + (response?.droppedFactorKeys?.length ?? 0) > 0 + ); +} diff --git a/src/lib/mood/tag-links.ts b/src/lib/mood/tag-links.ts index 5ad4c07ed..685029f7b 100644 --- a/src/lib/mood/tag-links.ts +++ b/src/lib/mood/tag-links.ts @@ -6,8 +6,11 @@ import { prisma } from "@/lib/db"; * * Resolve a list of catalog tag keys (`mood_tags.key`) to their ids and * write the `mood_entry_tag_links` join for a mood entry. The catalog is - * the source of truth: unknown keys are dropped silently so a stale - * client can never mint a link to a tag the deployment doesn't carry. + * the source of truth: an unknown or archived key never mints a link, so a + * stale client cannot link a tag the deployment doesn't carry. The write + * helpers return the keys they did not store, and the routes report them + * per entry (`droppedTagKeys` / `droppedFactorKeys`), so a client is never + * told an entry landed whole when part of it did not. * * Every helper accepts an optional `db` client so the caller can thread * the same `$transaction` client that wrote the entry — the entry row and @@ -76,7 +79,8 @@ interface ResolvedRatedFactor { * resolved `MoodTag`'s own `scaleMin..scaleMax`. The route maps this to a * 422 (the per-tag scale is the real gate; the Zod schema only enforces * the outer 1..5 envelope). Unknown / non-RATED keys are NOT an error — - * they are dropped silently, matching the binary `tagKeys` posture. + * the entry still lands without them, matching the binary `tagKeys` + * posture, and the write helpers report them back as dropped. */ export class RatedFactorOutOfRangeError extends Error { constructor( @@ -92,10 +96,20 @@ export class RatedFactorOutOfRangeError extends Error { } } +/** + * The submitted keys a write did not store: unknown to the catalog, archived, + * another account's custom tag, or the wrong kind for the field they came in. + * Empty arrays when everything landed. + */ +export interface DroppedLinkKeys { + droppedTagKeys: string[]; + droppedFactorKeys: string[]; +} + /** * Resolve rated-factor inputs against the catalog. Unknown keys, inactive - * tags, and `kind = 'BINARY'` keys are dropped silently (the catalog is - * the source of truth, same posture as `resolveTagKeysToIds`). A rating + * tags, and `kind = 'BINARY'` keys are not resolved (the catalog is the + * source of truth, same posture as `resolveTagKeysToIds`). A rating * outside the resolved tag's `scaleMin..scaleMax` throws * `RatedFactorOutOfRangeError` so the route returns 422. The last value * wins on a duplicate key in the same payload. @@ -104,7 +118,15 @@ export async function resolveRatedFactors( factors: RatedFactorInput[], db: TagLinkDb = prisma, ): Promise { - if (factors.length === 0) return []; + return (await resolveRatedFactorsReporting(factors, db)).resolved; +} + +/** `resolveRatedFactors`, plus the submitted keys that did not resolve. */ +async function resolveRatedFactorsReporting( + factors: RatedFactorInput[], + db: TagLinkDb, +): Promise<{ resolved: ResolvedRatedFactor[]; dropped: string[] }> { + if (factors.length === 0) return { resolved: [], dropped: [] }; // Last-writer-wins on a duplicate key in the same submission. const byKey = new Map(); for (const f of factors) byKey.set(f.key, f.rating); @@ -135,7 +157,11 @@ export async function resolveRatedFactors( } resolved.push({ moodTagId: row.id, rating }); } - return resolved; + const found = new Set(rows.map((row) => row.key)); + return { + resolved, + dropped: [...byKey.keys()].filter((key) => !found.has(key)), + }; } /** @@ -161,8 +187,18 @@ export async function resolveTagKeysToIds( ownerUserId?: string, kind?: "BINARY" | "RATED", ): Promise { + return (await resolveTagKeysReporting(keys, db, ownerUserId, kind)).ids; +} + +/** `resolveTagKeysToIds`, plus the submitted keys that did not resolve. */ +async function resolveTagKeysReporting( + keys: string[], + db: TagLinkDb, + ownerUserId?: string, + kind?: "BINARY" | "RATED", +): Promise<{ ids: string[]; dropped: string[] }> { const unique = Array.from(new Set(keys)); - if (unique.length === 0) return []; + if (unique.length === 0) return { ids: [], dropped: [] }; const ownerClause = ownerUserId ? { OR: [{ userId: null }, { userId: ownerUserId }] } : { userId: null }; @@ -173,9 +209,13 @@ export async function resolveTagKeysToIds( ...(kind ? { kind } : {}), ...ownerClause, }, - select: { id: true }, + select: { id: true, key: true }, }); - return rows.map((row) => row.id); + const found = new Set(rows.map((row) => row.key)); + return { + ids: rows.map((row) => row.id), + dropped: unique.filter((key) => !found.has(key)), + }; } /** @@ -191,6 +231,8 @@ export async function resolveTagKeysToIds( * route 422) when a rating is out of the factor's scale. Asserts the * entry belongs to `userId` before any write — a defensive guard against * linking into an entry the acting session does not own. + * + * Returns the submitted keys that were not stored, for the route to report. */ export async function createTagLinks( moodEntryId: string, @@ -198,12 +240,13 @@ export async function createTagLinks( keys: string[], db: TagLinkDb = prisma, ratedFactors: RatedFactorInput[] = [], -): Promise { +): Promise { await assertEntryOwnership(moodEntryId, userId, db); // Resolve rated factors FIRST so an out-of-range rating aborts before // any write, and so the rated rows (which carry a value) take priority // over a bare binary row for the same tag id under `skipDuplicates`. - const resolvedFactors = await resolveRatedFactors(ratedFactors, db); + const { resolved: resolvedFactors, dropped: droppedFactorKeys } = + await resolveRatedFactorsReporting(ratedFactors, db); const ratedTagIds = new Set(resolvedFactors.map((f) => f.moodTagId)); if (resolvedFactors.length > 0) { @@ -218,16 +261,19 @@ export async function createTagLinks( }); } - const tagIds = (await resolveTagKeysToIds(keys, db, userId)).filter( + const binary = await resolveTagKeysReporting(keys, db, userId); + const tagIds = binary.ids.filter( // A key already written as a rated link is not re-inserted as a // bare binary row (that would lose the rating to `skipDuplicates`). (id) => !ratedTagIds.has(id), ); - if (tagIds.length === 0) return; - await db.moodEntryTagLink.createMany({ - data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })), - skipDuplicates: true, - }); + if (tagIds.length > 0) { + await db.moodEntryTagLink.createMany({ + data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })), + skipDuplicates: true, + }); + } + return { droppedTagKeys: binary.dropped, droppedFactorKeys }; } /** @@ -248,24 +294,31 @@ export async function createTagLinks( * (`isActive: false`) are therefore preserved untouched no matter what * the body carries; a purge (`DELETE /api/mood/tags/custom/[key]?purge=true`) * remains the only path that removes them. + * + * Returns the submitted keys that are not on the entry afterwards. A key + * whose archived link was preserved is on the entry, so it is not reported. */ export async function replaceTagLinks( moodEntryId: string, userId: string, keys: string[], db: TagLinkDb = prisma, -): Promise { +): Promise { await assertEntryOwnership(moodEntryId, userId, db); - const desiredIds = new Set( - await resolveTagKeysToIds(keys, db, userId, "BINARY"), - ); + const desired = await resolveTagKeysReporting(keys, db, userId, "BINARY"); + const desiredIds = new Set(desired.ids); const existing = await db.moodEntryTagLink.findMany({ where: { moodEntryId }, select: { moodTagId: true, - moodTag: { select: { isActive: true, kind: true } }, + moodTag: { select: { isActive: true, kind: true, key: true } }, }, }); + const preservedKeys = new Set( + existing + .filter((row) => !row.moodTag.isActive && row.moodTag.kind === "BINARY") + .map((row) => row.moodTag.key), + ); const existingIds = new Set(existing.map((row) => row.moodTagId)); const toDelete = existing @@ -289,6 +342,7 @@ export async function replaceTagLinks( skipDuplicates: true, }); } + return desired.dropped.filter((key) => !preservedKeys.has(key)); } /** @@ -300,17 +354,40 @@ export async function replaceTagLinks( * Throws `RatedFactorOutOfRangeError` (→ route 422) before any write when a * rating is out of scale. Asserts the entry belongs to `userId` before any * write. + * + * Returns the submitted factor keys that are not on the entry afterwards; an + * archived factor whose link survives is on the entry and is not reported. */ export async function replaceRatedFactorLinks( moodEntryId: string, userId: string, factors: RatedFactorInput[], db: TagLinkDb = prisma, -): Promise { +): Promise { await assertEntryOwnership(moodEntryId, userId, db); // Resolve (and range-check) before mutating so an out-of-range rating // aborts the replace cleanly. - const resolved = await resolveRatedFactors(factors, db); + const { resolved, dropped } = await resolveRatedFactorsReporting(factors, db); + // Looked up only when something did not resolve: which of those keys still + // sit on the entry as an archived factor the replacement leaves alone. + const preservedKeys = + dropped.length === 0 + ? new Set() + : new Set( + ( + await db.moodEntryTagLink.findMany({ + where: { + moodEntryId, + moodTag: { + isActive: false, + kind: "RATED", + key: { in: dropped }, + }, + }, + select: { moodTag: { select: { key: true } } }, + }) + ).map((row) => row.moodTag.key), + ); // Drop every active RATED link, including a legacy row whose rating is // null. Same archive contract as `replaceTagLinks`: a link whose tag has @@ -333,4 +410,30 @@ export async function replaceRatedFactorLinks( skipDuplicates: true, }); } + return dropped.filter((key) => !preservedKeys.has(key)); } + +/** + * The wire form of `DroppedLinkKeys`: each list only when it is non-empty. + * Absent means every submitted key of that kind landed, which keeps the + * common response byte-identical to what clients read before the fields + * existed. + */ +export function droppedLinkKeysForWire( + dropped: DroppedLinkKeys, +): Partial { + return { + ...(dropped.droppedTagKeys.length > 0 + ? { droppedTagKeys: dropped.droppedTagKeys } + : {}), + ...(dropped.droppedFactorKeys.length > 0 + ? { droppedFactorKeys: dropped.droppedFactorKeys } + : {}), + }; +} + +/** No key dropped: the value a write that carried no keys reports. */ +export const NO_DROPPED_LINK_KEYS: DroppedLinkKeys = Object.freeze({ + droppedTagKeys: [], + droppedFactorKeys: [], +}) as DroppedLinkKeys; diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 5e80702f8..64f88bbde 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -401,6 +401,21 @@ const bulkMoodPayload = z "iOS SyncMode bulk mood backfill. 1–500 entries per call; per-entry UPSERT keyed by `externalId` so re-runs are idempotent.", }); +// The submitted keys a write did not store. Shared by the bulk result and the +// single-entry write responses so the two surfaces name the same thing. +const droppedTagKeysField = z + .array(z.string()) + .optional() + .describe( + "`tagKeys` from this request that were not stored: unknown to the catalog, archived, another account's custom tag, or a rated factor sent as a binary key. Present only when non-empty; absent means every submitted key landed. The entry itself was written.", + ); +const droppedFactorKeysField = z + .array(z.string()) + .optional() + .describe( + "`ratedFactors` keys from this request that were not stored: unknown, archived, or not a rated factor. Present only when non-empty; absent means every submitted factor landed.", + ); + const bulkMoodEntryResult = z .object({ index: z.number().int().nonnegative(), @@ -418,6 +433,8 @@ const bulkMoodEntryResult = z .string() .optional() .describe("Echoed back when the entry carried one, for client mapping."), + droppedTagKeys: droppedTagKeysField, + droppedFactorKeys: droppedFactorKeysField, }) .meta({ id: "BulkMoodEntryResult" }); @@ -526,6 +543,12 @@ const moodEntryDto = z .describe( "The day's context, or null when the entry carries none. Null and an object of nulls are different answers.", ), + droppedTagKeys: droppedTagKeysField.describe( + "Create and edit responses only: `tagKeys` from the request that are not on the entry (unknown, archived, another account's custom tag, or a rated factor sent as a binary key). Present only when non-empty. An archived tag the edit preserved is on the entry and is not listed.", + ), + droppedFactorKeys: droppedFactorKeysField.describe( + "Create and edit responses only: `ratedFactors` keys from the request that are not on the entry. Present only when non-empty.", + ), }) .meta({ id: "MoodEntryDTO", diff --git a/tests/integration/mood-entries-bulk.test.ts b/tests/integration/mood-entries-bulk.test.ts index 388e1531f..50818240e 100644 --- a/tests/integration/mood-entries-bulk.test.ts +++ b/tests/integration/mood-entries-bulk.test.ts @@ -277,6 +277,70 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => { expect(linkCount).toBe(0); }); + it("names the unknown and archived keys on the entry's result instead of reporting it whole", async () => { + const db = getPrismaClient(); + const category = await db.moodTagCategory.findFirstOrThrow({ + select: { id: true }, + }); + const archived = await db.moodTag.create({ + data: { + categoryId: category.id, + key: "test_archived_tag", + labelKey: "mood.tags.test_archived_tag", + isActive: false, + }, + }); + try { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + tagKeys: ["movies", "test_archived_tag", "not_a_real_tag"], + ratedFactors: [ + { key: "factor_work", rating: 4 }, + { key: "factor_not_real", rating: 2 }, + ], + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + tagKeys: ["movies"], + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { inserted: number; entries: Array> }; + }; + expect(json.data.inserted).toBe(2); + expect(json.data.entries[0]).toMatchObject({ + status: "inserted", + droppedTagKeys: ["test_archived_tag", "not_a_real_tag"], + droppedFactorKeys: ["factor_not_real"], + }); + expect(json.data.entries[1]).not.toHaveProperty("droppedTagKeys"); + expect(json.data.entries[1]).not.toHaveProperty("droppedFactorKeys"); + + // What the result says is what is on disk. + const first = await db.moodEntry.findUniqueOrThrow({ + where: { id: json.data.entries[0].id as string }, + select: { + tagLinks: { select: { moodTag: { select: { key: true } } } }, + }, + }); + expect(first.tagLinks.map((l) => l.moodTag.key).sort()).toEqual([ + "factor_work", + "movies", + ]); + } finally { + await db.moodTag.delete({ where: { id: archived.id } }); + } + }); + it("rejects an over-cap batch with 422 and the documented error code", async () => { const { POST } = await import("@/app/api/mood-entries/bulk/route"); const entries = Array.from({ length: 501 }, (_, i) => ({ From 5caa15f4e1ecf7aa82918928c97dc7325a504011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:05:35 +0200 Subject: [PATCH 5/8] fix(google-health): count sleep segments with a stage label the mapper does not know A segment whose stage label was unknown (SLEEP_STAGE_TYPE_UNSPECIFIED, or a label added upstream later) was skipped without a trace, so the night total read short and nothing said why. The mapper now counts those segments and keeps their labels, and the sleep sync puts both on its wide event. --- .../google-health/__tests__/mappers.test.ts | 32 +++++++++++++++++++ .../sync-sleep-morning-refresh.test.ts | 30 +++++++++++++++++ src/lib/google-health/mappers.ts | 31 ++++++++++++++++-- src/lib/google-health/sync-sleep.ts | 16 ++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index 49f3fa8ea..c20559389 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -34,6 +34,7 @@ import { mapRespiratoryRate, mapRestingHeartRate, mapSleepSession, + mapSleepSessionDetailed, mapSteps, mapVo2Max, mapWeight, @@ -376,6 +377,37 @@ describe("mapVo2Max — daily-vo2-max summary", () => { }); }); +describe("mapSleepSessionDetailed — unknown stage labels are counted", () => { + it("reports how many segments it skipped and which labels they carried", () => { + const session = mapSleepSessionDetailed({ + sleep: { + stages: [ + { + type: "DEEP", + startTime: "2026-06-02T02:00:00.000Z", + endTime: "2026-06-02T02:45:00.000Z", + }, + { + type: "SLEEP_STAGE_TYPE_UNSPECIFIED", + startTime: "2026-06-02T04:00:00.000Z", + endTime: "2026-06-02T04:10:00.000Z", + }, + { + type: "SLEEP_STAGE_TYPE_UNSPECIFIED", + startTime: "2026-06-02T04:10:00.000Z", + endTime: "2026-06-02T04:20:00.000Z", + }, + ], + }, + }); + expect(session.rows).toHaveLength(1); + expect(session.unknownStageSegments).toBe(2); + expect(session.unknownStageLabels).toEqual([ + "SLEEP_STAGE_TYPE_UNSPECIFIED", + ]); + }); +}); + describe("mapSleepSession — per-stage segments", () => { it("emits one SLEEP_DURATION row per known stage, skipping unknown labels", () => { const rows = mapSleepSession({ diff --git a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts index 4be90b284..4a9d99813 100644 --- a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts +++ b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts @@ -54,6 +54,7 @@ vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ maybeEnqueueMorningRefresh: maybeEnqueueMorningRefreshMock, })); +import { annotate } from "@/lib/logging/context"; import { syncUserSleep } from "../sync-sleep"; beforeEach(() => { @@ -72,6 +73,8 @@ beforeEach(() => { ], windowStart: new Date("2026-06-01T22:00:00.000Z"), windowEnd: sleepMeasuredAt, + unknownStageSegments: 0, + unknownStageLabels: [], }); upsertMock.mockImplementation( async ( @@ -125,6 +128,8 @@ describe("google-health syncUserSleep — S4 morning-refresh trigger", () => { ], windowStart: new Date("2026-06-01T22:00:00.000Z"), windowEnd: sleepMeasuredAt, + unknownStageSegments: 0, + unknownStageLabels: [], }); await syncUserSleep("user-1"); @@ -135,3 +140,28 @@ describe("google-health syncUserSleep — S4 morning-refresh trigger", () => { expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledWith("user-1", []); }); }); + +describe("google-health syncUserSleep — segments with an unknown stage", () => { + it("counts them on the wide event with their labels instead of dropping them silently", async () => { + fetchDataPointsMock.mockResolvedValue([{ raw: "a" }, { raw: "b" }]); + mapSleepSessionDetailedMock.mockReturnValue({ + rows: [], + windowStart: null, + windowEnd: null, + unknownStageSegments: 2, + unknownStageLabels: ["SLEEP_STAGE_TYPE_UNSPECIFIED"], + }); + + await syncUserSleep("user-1"); + + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ name: "googleHealth.sleep.sync" }), + meta: { + unknown_stage_segments: 4, + unknown_stage_labels: "SLEEP_STAGE_TYPE_UNSPECIFIED", + }, + }), + ); + }); +}); diff --git a/src/lib/google-health/mappers.ts b/src/lib/google-health/mappers.ts index a94d7abed..d73666dc6 100644 --- a/src/lib/google-health/mappers.ts +++ b/src/lib/google-health/mappers.ts @@ -955,6 +955,15 @@ export interface GoogleHealthSleepSession { /** Latest segment end across the session (UTC), or null if none map. */ windowEnd: Date | null; rows: GoogleHealthMappedMeasurement[]; + /** + * Segments skipped because their stage label is not one this mapper knows + * (`SLEEP_STAGE_TYPE_UNSPECIFIED`, or a label Google adds later). Counted + * rather than dropped in silence: the night total reads short by exactly + * these segments, and the sync's wide event is where that shows. + */ + unknownStageSegments: number; + /** The distinct unknown labels, for the wide event (at most five). */ + unknownStageLabels: string[]; } /** @@ -972,7 +981,8 @@ export interface GoogleHealthSleepSession { * duplicate rows the night-total then double-counted. A segment's start instant * is stable per block, and dropping the stage from the key means a mere * re-classification (LIGHT→DEEP on the same block) UPDATES the row in place - * rather than orphaning it. Unknown stage labels are skipped; a session with no + * rather than orphaning it. Unknown stage labels are skipped and counted + * (`unknownStageSegments`); a session with no * parseable segment yields an empty `rows` (and a null window). * * Segment timestamps can arrive OFFSET-LESS (local wall clock); `tz` (the user's @@ -989,10 +999,18 @@ export function mapSleepSessionDetailed( const rows: GoogleHealthMappedMeasurement[] = []; let windowStart: Date | null = null; let windowEnd: Date | null = null; + let unknownStageSegments = 0; + const unknownStageLabels = new Set(); for (const seg of segments) { const stage = mapGoogleHealthSleepStage(seg.stage); - if (!stage) continue; + if (!stage) { + unknownStageSegments += 1; + if (unknownStageLabels.size < 5) { + unknownStageLabels.add(seg.stage.slice(0, 40)); + } + continue; + } const mins = minutesBetween(seg.startTime, seg.endTime); if (mins === null || !(mins > 0)) continue; const start = parseLocalInstant(seg.startTime as string, tz); @@ -1010,7 +1028,14 @@ export function mapSleepSessionDetailed( }); } - return { anchor, windowStart, windowEnd, rows }; + return { + anchor, + windowStart, + windowEnd, + rows, + unknownStageSegments, + unknownStageLabels: [...unknownStageLabels], + }; } /** diff --git a/src/lib/google-health/sync-sleep.ts b/src/lib/google-health/sync-sleep.ts index 7c16469f7..7bd1bf4be 100644 --- a/src/lib/google-health/sync-sleep.ts +++ b/src/lib/google-health/sync-sleep.ts @@ -70,6 +70,12 @@ export async function syncUserSleep( const readings: GoogleHealthMeasurementUpsert[] = []; const replaceWindows: GoogleHealthSleepReplaceWindow[] = []; + // Segments whose stage label the mapper does not know are skipped, so the + // night total reads short by them. Counted across the page for the wide + // event, with the labels, so a new upstream stage shows up instead of + // quietly shortening nights. + let unknownStageSegments = 0; + const unknownStageLabels = new Set(); for (const point of points) { // Per-session throw-guard (mapper parity with sync-metrics): a single // malformed session must not abort the sibling sessions in the same page. @@ -85,6 +91,10 @@ export async function syncUserSleep( noteHardFailure("mapSleepSession"); continue; } + unknownStageSegments += session.unknownStageSegments; + for (const label of session.unknownStageLabels) { + if (unknownStageLabels.size < 5) unknownStageLabels.add(label); + } if (session.rows.length === 0) continue; for (const m of session.rows) { readings.push({ @@ -130,6 +140,12 @@ export async function syncUserSleep( annotate({ action: { name: "googleHealth.sleep.sync", details: { imported } }, + meta: { + unknown_stage_segments: unknownStageSegments, + ...(unknownStageSegments > 0 + ? { unknown_stage_labels: [...unknownStageLabels].join(",") } + : {}), + }, }); return imported; } From 5f548d331f5e50a6d3f270fdc6da7a5ccc710084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:05:35 +0200 Subject: [PATCH 6/8] fix(labs): say when a scanned PDF was read from its first pages only A PDF longer than the pages a scan renders was read from its first ten pages, and the cut was recorded only in the wide event. The person checking the extracted rows had no way to know that values on later pages were never read. The rasterizer now returns the PDF's page count, the extract response carries pageCoverage { read, total } when the cap applied, and the review screen says how many of the pages were read. --- docs/api/openapi.yaml | 17 ++++++++++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../labs/ocr/extract/__tests__/route.test.ts | 24 ++++++++++++++ src/app/api/labs/ocr/extract/route.ts | 17 +++++++++- .../labs/__tests__/ocr-review-dialog.test.tsx | 22 +++++++++++++ src/components/labs/ocr-review-dialog.tsx | 32 +++++++++++++++++++ .../documents/__tests__/rasterize-pdf.test.ts | 3 ++ src/lib/documents/rasterize-pdf.ts | 13 ++++++-- src/lib/openapi/routes/ocr.ts | 9 ++++++ src/lib/validations/labs-ocr.ts | 6 ++++ 16 files changed, 147 insertions(+), 3 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e6e09540c..3271e670b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -63012,6 +63012,23 @@ components: type: array items: $ref: "#/components/schemas/OcrExtractedRow" + pageCoverage: + description: "Present only when a PDF ran past the pages a scan reads: the rows come from the first `read` of `total` + pages. Absent when the whole document was read." + type: object + properties: + read: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + total: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + required: + - read + - total + additionalProperties: false required: - reportDate - providerType diff --git a/messages/de.json b/messages/de.json index 553d63b6c..6bf4db0c6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8992,6 +8992,7 @@ "reviewTitle": "Vor dem Speichern prüfen", "reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.", "foundCount": "{count} Werte gefunden", + "pagesCapped": "Nur die ersten {read} von {total} Seiten wurden gelesen. Werte auf späteren Seiten fehlen in dieser Liste.", "confirmRow": "Diesen Wert übernehmen", "analyteLabel": "Bezeichnung", "resultLabel": "Ergebnis", diff --git a/messages/en.json b/messages/en.json index 55d0718d6..04555344a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8992,6 +8992,7 @@ "reviewTitle": "Review before saving", "reviewHint": "Check each reading, then save the ones you want to keep.", "foundCount": "{count} readings found", + "pagesCapped": "Only the first {read} of {total} pages were read. Values on later pages are not in this list.", "confirmRow": "Include this reading", "analyteLabel": "Test name", "resultLabel": "Result", diff --git a/messages/es.json b/messages/es.json index 1718c1acc..c90525051 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8992,6 +8992,7 @@ "reviewTitle": "Revisar antes de guardar", "reviewHint": "Revisa cada valor y guarda los que quieras conservar.", "foundCount": "{count} valores encontrados", + "pagesCapped": "Solo se leyeron las primeras {read} de {total} páginas. Los valores de las páginas siguientes no están en esta lista.", "confirmRow": "Incluir este valor", "analyteLabel": "Nombre de la prueba", "resultLabel": "Resultado", diff --git a/messages/fr.json b/messages/fr.json index 0afad1da7..1ebea9c37 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8992,6 +8992,7 @@ "reviewTitle": "Vérifier avant d'enregistrer", "reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.", "foundCount": "{count} valeurs trouvées", + "pagesCapped": "Seules les {read} premières pages sur {total} ont été lues. Les valeurs des pages suivantes ne figurent pas dans cette liste.", "confirmRow": "Inclure cette valeur", "analyteLabel": "Nom de l'analyse", "resultLabel": "Résultat", diff --git a/messages/it.json b/messages/it.json index 883d3c4a1..c163f1bef 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8992,6 +8992,7 @@ "reviewTitle": "Controlla prima di salvare", "reviewHint": "Controlla ogni valore, poi salva quelli da conservare.", "foundCount": "{count} valori trovati", + "pagesCapped": "Sono state lette solo le prime {read} pagine su {total}. I valori delle pagine successive non sono in questo elenco.", "confirmRow": "Includi questo valore", "analyteLabel": "Nome dell'esame", "resultLabel": "Risultato", diff --git a/messages/ko.json b/messages/ko.json index f814ab916..08156e094 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8992,6 +8992,7 @@ "reviewTitle": "저장 전에 확인하기", "reviewHint": "측정값을 하나씩 확인하고, 남길 항목만 저장하세요.", "foundCount": "측정값 {count}개를 찾았어요", + "pagesCapped": "전체 {total}쪽 중 처음 {read}쪽만 읽었습니다. 이후 쪽의 값은 이 목록에 없습니다.", "confirmRow": "이 측정값 포함", "analyteLabel": "검사 항목", "resultLabel": "결과", diff --git a/messages/pl.json b/messages/pl.json index 7e45f4587..c0ecb344a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8992,6 +8992,7 @@ "reviewTitle": "Sprawdź przed zapisaniem", "reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.", "foundCount": "Znaleziono {count} wyników", + "pagesCapped": "Odczytano tylko pierwsze {read} z {total} stron. Wartości z dalszych stron nie ma na tej liście.", "confirmRow": "Uwzględnij ten wynik", "analyteLabel": "Nazwa badania", "resultLabel": "Wynik", diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index 4dcd20385..742700fb7 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -229,11 +229,15 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => { vi.mocked(rasterizePdf).mockResolvedValue({ ok: true, images: [{ mediaType: "image/jpeg", dataBase64: "cGFnZQ==" }], + pageCount: 1, }); vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never); const res = await POST(pdfReq()); expect(res.status).toBe(200); + // The whole PDF was read: no coverage note on the response. + const whole = (await res.json()) as { data: Record }; + expect(whole.data).not.toHaveProperty("pageCoverage"); expect(rasterizePdf).toHaveBeenCalledOnce(); // The rendered page images flow through as `input_image`s; no native PDF // document block is sent for a non-Anthropic provider. @@ -245,6 +249,26 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => { ); }); + it("says on the response when only the first pages of a long PDF were read", async () => { + const tenPages = Array.from({ length: 10 }, () => ({ + mediaType: "image/jpeg" as const, + dataBase64: "cGFnZQ==", + })); + vi.mocked(rasterizePdf).mockResolvedValue({ + ok: true, + images: tenPages, + pageCount: 23, + }); + vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never); + + const res = await POST(pdfReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { pageCoverage?: { read: number; total: number } }; + }; + expect(body.data.pageCoverage).toEqual({ read: 10, total: 23 }); + }); + it("hands the slot back when the upload is not an image or PDF", async () => { const form = new FormData(); form.append( diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 16ca28416..bb770f3b4 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -374,6 +374,7 @@ async function handleVisionExtract( dataBase64: string; }[]; let documents: { mediaType: "application/pdf"; dataBase64: string }[]; + let pageCoverage: { read: number; total: number } | undefined; if (mime === "application/pdf") { if (pick.pdfSupported) { @@ -404,6 +405,20 @@ async function handleVisionExtract( } images = raster.images; documents = []; + // A PDF longer than the page cap is read from its first pages only. + // That used to live in the wide event alone; the person reviewing the + // rows is the one who needs to know the rest of the report was not + // read. + if (raster.pageCount > raster.images.length) { + pageCoverage = { + read: raster.images.length, + total: raster.pageCount, + }; + annotate({ + action: { name: "labs.ocr.pagesCapped" }, + meta: { read: raster.images.length, total: raster.pageCount }, + }); + } } } else { images = [{ mediaType: mime, dataBase64: buffer.toString("base64") }]; @@ -431,7 +446,7 @@ async function handleVisionExtract( 0, { servedBy: pick.entry.providerType, reservedOwner: reservation.owner }, ); - return apiSuccess(result); + return apiSuccess(pageCoverage ? { ...result, pageCoverage } : result); } catch (err) { // Provider/extraction failure — the call may still have burned tokens, so // reconcile against the reserved estimate rather than refunding in full. diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx index 1ef88b1c9..852746337 100644 --- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx +++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx @@ -10,6 +10,7 @@ import { ApiError } from "@/lib/api/api-fetch"; import { extractErrorMessage, handleFilePickerChange, + OcrPageCoverageNote, OcrReviewDialog, } from "../ocr-review-dialog"; @@ -201,3 +202,24 @@ describe("extractErrorMessage", () => { ); }); }); + +describe(" — a long PDF read from its first pages", () => { + it("says how many of the pages were read", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain('data-slot="ocr-page-coverage"'); + expect(html).toContain("Only the first 10 of 23 pages were read."); + }); + + it("renders nothing when the whole document was read", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toBe(""); + }); +}); diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index 0a0b0a17c..707da5efa 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -138,6 +138,11 @@ export function OcrReviewDialog({ // because the person has not filled it in yet. Once shown they follow the // edits, so fixing a field clears its mark at once. const [showErrors, setShowErrors] = useState(false); + // Set when the server read only the first pages of a long PDF. + const [pageCoverage, setPageCoverage] = useState<{ + read: number; + total: number; + } | null>(null); const [focusRequest, setFocusRequest] = useState(0); const reviewListRef = useRef(null); @@ -184,6 +189,7 @@ export function OcrReviewDialog({ function reset() { setStage("pick"); setShowErrors(false); + setPageCoverage(null); setRows([]); setPickedFile(null); setVisitId(null); @@ -207,6 +213,7 @@ export function OcrReviewDialog({ return; } setRows(seeded); + setPageCoverage(data.pageCoverage ?? null); setStage("review"); }, onError: (err) => toast.error(extractErrorMessage(err, t)), @@ -353,6 +360,8 @@ export function OcrReviewDialog({ {t("labs.ocr.foundCount", { count: rows.length })}

+ + {showErrors && rowErrors.size > 0 ? (

); } + +/** + * The server read only the first pages of a long PDF (`pageCoverage` on the + * extract response). Said on the review screen, beside the rows, because the + * person checking the list is the one who would otherwise assume the whole + * report was read. Renders nothing when the whole document was read. + */ +export function OcrPageCoverageNote({ + coverage, +}: { + coverage: { read: number; total: number } | null; +}) { + const { t } = useTranslations(); + if (!coverage) return null; + return ( +

+ {t("labs.ocr.pagesCapped", { + read: coverage.read, + total: coverage.total, + })} +

+ ); +} diff --git a/src/lib/documents/__tests__/rasterize-pdf.test.ts b/src/lib/documents/__tests__/rasterize-pdf.test.ts index a00340e8a..cb124491b 100644 --- a/src/lib/documents/__tests__/rasterize-pdf.test.ts +++ b/src/lib/documents/__tests__/rasterize-pdf.test.ts @@ -90,6 +90,9 @@ describe("rasterizePdf", () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.images).toHaveLength(RASTER_MAX_PAGES); + // The page count travels with the result, so a caller can say the + // reading stopped short. + expect(result.pageCount).toBe(RASTER_MAX_PAGES + 4); }); it("honours a caller maxPages bound (thumbnail path renders page 1 only)", async () => { diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index 402f61695..f61e74fc3 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -74,7 +74,16 @@ export interface RasterImage { * would not render" (`render-failed` — malformed / encrypted / zero pages). */ export type RasterResult = - | { ok: true; images: RasterImage[] } + | { + ok: true; + images: RasterImage[]; + /** + * Pages in the PDF. Above `images.length` when the page cap cut the + * render short, which a caller showing the result has to say: the + * reading covers the first pages only. + */ + pageCount: number; + } | { ok: false; reason: "unsupported" | "render-failed" }; // Minimal structural types for the slice of the pdfjs API this module uses. A @@ -212,7 +221,7 @@ export async function rasterizePdf( action: { name: "documents.rasterize.ok" }, meta: { pages: images.length, cappedFrom: doc.numPages }, }); - return { ok: true, images }; + return { ok: true, images, pageCount: doc.numPages }; } catch (err) { annotate({ action: { name: "documents.rasterize.failed" }, diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 82ded9d09..f894cf8aa 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -85,6 +85,15 @@ const extractResponse = z reportDate: z.string().nullable(), providerType: z.string(), rows: z.array(extractedRow), + pageCoverage: z + .object({ + read: z.number().int().positive(), + total: z.number().int().positive(), + }) + .optional() + .describe( + "Present only when a PDF ran past the pages a scan reads: the rows come from the first `read` of `total` pages. Absent when the whole document was read.", + ), }) .meta({ id: "OcrExtractResponse", diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index aa18dd7e6..000a78ab5 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -132,6 +132,12 @@ export interface OcrExtractResponseDto { reportDate: string | null; providerType: string; rows: OcrExtractedRowDto[]; + /** + * Present only when a PDF was longer than the pages the scan reads + * (`RASTER_MAX_PAGES`): the rows come from the first `read` of `total` + * pages, and the review screen says so. + */ + pageCoverage?: { read: number; total: number }; } /** From 5e21363e0f2c4785f610125f171ce972148bad29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:06:39 +0200 Subject: [PATCH 7/8] docs(comments): correct four comments that describe gates and mints that no longer exist - /api/auth/me and the AuthUser type described disableCoach as the gate every Coach surface checks below a flags.coach short-circuit. Neither has been true since the AI capability resolution: it is the account's stored opt-out, and surfaces read ai.capabilities.coach or modules.coach. - /api/mcp/tokens claimed /api/tokens mints medication:ingest; that mint was removed, and the medication pair now comes from the per-medication endpoint toggle. - /api/meta/capabilities listed the token kinds as wildcard, medication ingest or MCP, missing measurements:write, workouts:write and documents:write. Comment only: the served scopeMintable answer for the FHIR face is unchanged and still right. --- src/app/api/auth/me/route.ts | 11 ++++++----- src/app/api/mcp/tokens/route.ts | 4 +++- src/app/api/meta/capabilities/route.ts | 12 +++++++----- src/hooks/use-auth.ts | 15 ++++++++------- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 2dfa2bc58..e150408eb 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -346,11 +346,12 @@ export const GET = apiHandler(async () => { // defaults rather than to `undefined`, so the switch is what renders. // No extra query — the session already loads the row this column is on. notificationPrefs: parseNotificationPrefs(user.notificationPrefs ?? null), - // v1.4.47 W3 — per-user Coach opt-out. Default `false` if the - // column is absent (partial-deploy rollback safety, see migration - // 0078 commentary). Every Coach mount point on the client checks - // `user.disableCoach` BELOW the operator-level `flags.coach` - // short-circuit; both gates must agree to paint the affordance. + // v1.4.47 W3 — the caller's own stored Coach opt-out, published for the + // switch that edits it. Default `false` if the column is absent + // (partial-deploy rollback safety, see migration 0078 commentary). It is + // not a gate: whether the Coach shows for the record on screen is the + // resolved `ai.capabilities.coach` and `modules.coach` below, which fold + // in the record's own opt-out, the operator's switches and the grant. disableCoach: user.disableCoach ?? false, // v1.7.0 — health-record export identity fields. All optional. fullName: user.fullName ?? null, diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts index 14ccdb37e..e79f93e6c 100644 --- a/src/app/api/mcp/tokens/route.ts +++ b/src/app/api/mcp/tokens/route.ts @@ -2,7 +2,9 @@ * MCP connector tokens — list + mint. * * The MCP settings card mints a DEDICATED `health:read`-scoped Bearer here (NOT - * the `medication:ingest` scope the generic `/api/tokens` mints, and NEVER the + * one of the write scopes the other token surfaces mint — the per-medication + * ingest pair `medication:ingest` + `medication::ingest`, + * `measurements:write`, `workouts:write`, `documents:write` — and NEVER the * `["*"]` wildcard). The raw `hlk_` value is shown once. This is the manual / * stdio / power-user path; the OAuth bridge (`/api/mcp/oauth/*`) mints the same * `health:read` scope automatically for cloud connectors. Both surface here so diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts index f698a3a84..5b442dc17 100644 --- a/src/app/api/meta/capabilities/route.ts +++ b/src/app/api/meta/capabilities/route.ts @@ -109,11 +109,13 @@ export const GET = apiHandler(async () => { // Bearer scope. All sourced from the canonical `rest.ts` constants. restBaseUrl: "/api/fhir", // The face checks a narrow `fhir:read` scope, and NO code path mints a - // token carrying it — every mint grants a wildcard, the medication - // ingest scope, or an MCP scope. So the face is reachable by the owner's - // cookie session or a wildcard device token, and by nothing else. It is - // reported that way rather than advertising a credential a client cannot - // obtain. + // token carrying it — every mint grants a wildcard (login, passkey, + // refresh), the per-medication ingest pair (`medication:ingest` + + // `medication::ingest`), `measurements:write`, `workouts:write`, + // `documents:write`, or an MCP scope (`health:read`, `health:write`). + // So the face is reachable by the owner's cookie session or a wildcard + // device token, and by nothing else. It is reported that way rather than advertising a credential a + // client cannot obtain. scopeMintable: false, resourceTypes: FHIR_REST_RESOURCE_TYPES, operations: [FHIR_EVERYTHING_OPERATION], diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 65096bd96..dae619122 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -144,13 +144,14 @@ export interface AuthUser { */ dateFormat: DateFormatPreference; /** - * v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach - * mount point (``, ``, the - * inline `` pill, the `/targets` page CTA) - * renders nothing. The gate sits BELOW the operator-level - * `flags.coach` short-circuit — both must agree to render the - * affordance. Defaults to `false` when the field is absent (e.g. - * stale /me payload from a partial-deploy rollback). + * v1.4.47 W3 — the signed-in person's own stored Coach opt-out. It is + * the value the Activate-Coach switch and the module toggle edit, and + * nothing else should read it: it belongs to the account, not to the + * record on screen, and it is only one of the layers that decide whether + * the Coach is there. Every Coach surface gates on the resolved answers + * instead — `ai.capabilities.coach` (`useAiCapability("coach")`) for + * the affordances, `modules.coach` for the Coach's settings and tuning. + * Defaults to `false` when the field is absent (a stale /me payload). */ disableCoach: boolean; /** From 414b099972235f27f3902b9aa7dc7ea200ba78bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:09:28 +0200 Subject: [PATCH 8/8] test(guard): freeze client reads of server-resolved fields A per-field registry of the answers the server resolves for the record on screen (the blood-pressure target band, intakeActionable and courseStatus, the AI capability, the module surfaces) and the ingredient shapes that rebuild them in the browser: getBpTargets() on the viewer's age, a dose offered from active and trackIntake, course state from endsOn against the clock, the raw disableCoach column, server resolvers imported into client code. A match in client code fails unless the file is allowlisted with the reason it is not a rebuild; the switches that edit a stored value are the allowlisted readers. The walk carries a floor, every pattern is checked against a sample of the shape it catches, a stale allowlist entry fails, and the total match count must be non-zero. Putting back each of the three rebuilds this release removed fails the guard. --- .../resolved-field-consumer-guard.test.ts | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 src/__tests__/resolved-field-consumer-guard.test.ts diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts new file mode 100644 index 000000000..b7461f861 --- /dev/null +++ b/src/__tests__/resolved-field-consumer-guard.test.ts @@ -0,0 +1,289 @@ +/** + * Server-resolved fields are read, never rebuilt from their ingredients. + * + * Some answers depend on more than the browser can see: the record on screen + * (which may be a managed or shared one, not the signed-in account), the + * person's own targets, the operator's switches, the user's clock. The server + * resolves each of them once and publishes the result; a client that rebuilds + * one from the parts it happens to hold gets a plausible answer that is wrong + * in exactly the cases nobody tests. Three of those shipped at once: + * + * - the blood-pressure chart drew its target zones from `getBpTargets()` on + * the signed-in account's date of birth, losing the user's own band and, + * inside a managed record, using the viewer's age; + * - the intake pickers preselected on `active`, so an ended course could be + * the default dose; + * - Settings → Coach gated its tuning on the account's raw `disableCoach`, + * which answers for the viewer, not for the record. + * + * Each entry below names one resolved field and the ingredient patterns that + * rebuild it. A match in client code (`src/components`, `src/hooks`, and every + * `"use client"` module under `src/app` outside the API) fails, unless the + * exact file is allowlisted with the reason it is not a re-derivation — the + * switch that edits a stored value has to read that value. + * + * The guard cannot pass by reading nothing: the walk carries a floor, every + * allowlisted file must still match (a stale entry fails), every rule's + * pattern is checked against a sample of the shape it exists to catch, and + * the total match count across the tree must be non-zero. + * + * Its honest limit: the matchers are line-shaped. A re-derivation spread over + * several statements (an `active` check on one line, the `trackIntake` check + * on the next) is not seen. The rules catch the shapes that shipped; review + * catches the rest. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { stripComments, walkSourceFiles } from "./helpers/source-files"; + +const ROOT = process.cwd(); + +interface ResolvedFieldRule { + /** The server-resolved field clients must read. */ + field: string; + /** Where the resolved value comes from. */ + servedBy: string; + /** Ingredient shapes that rebuild the field in the browser. */ + patterns: RegExp[]; + /** A line each pattern must match, proving the matcher is live. */ + samples: string[]; + /** + * A line that reads the resolved field as well is not a rebuild of it; the + * rule skips lines matching this. + */ + unlessLine?: RegExp; + /** Files (relative to the repo root) allowed to match, and why. */ + allow: Record; + /** + * When set, the rule reads exactly these files instead of the client tree: + * for a pattern too common to ban everywhere (`.active`) that is a rebuild + * only where a dose is being offered. Each file must exist. + */ + scope?: string[]; +} + +const RULES: ResolvedFieldRule[] = [ + { + field: "blood-pressure target band", + servedBy: + "`/api/insights/targets` (BLOOD_PRESSURE range + bpDiastolic.range), resolved for the record on screen with the user's own band first", + patterns: [ + /\bgetBpTargets\s*\(/, + /\bgetBpTargetsByAge\s*\(/, + /\bresolveEffectiveBpTargets\s*\(/, + /\bresolveBpTargetOverride\s*\(/, + ], + samples: [ + "const t = getBpTargets(new Date(user.dateOfBirth));", + "getBpTargetsByAge(age, sex)", + "resolveEffectiveBpTargets(profile, thresholds)", + "resolveBpTargetOverride(profile, thresholds)", + ], + allow: {}, + }, + { + field: "intakeActionable / courseStatus", + servedBy: + "the medication list and detail reads (`src/lib/medications/intake-actionable.ts`)", + patterns: [ + // Offering a dose from `active` and `trackIntake` alone. + /\bactive\b[^;\n]*\btrackIntake\b/, + /\btrackIntake\b[^;\n]*\bactive\b/, + // Course state from the end date against the clock. + /\b(endsOn|endDate)\b[^;\n]*(<=?|>=?)[^;\n]*\b(now|today|Date\.now\(\)|new Date\(\))/, + /\b(now|today|Date\.now\(\)|new Date\(\))[^;\n]*(<=?|>=?)[^;\n]*\b(endsOn|endDate)\b/, + // The server's own resolvers, imported into the browser. + /\bcourseStatusAt\s*\(/, + /\bresolveIntakeActionability\s*\(/, + ], + samples: [ + "const canTake = med.active && med.trackIntake !== false;", + "if (m.trackIntake && m.active) offer();", + "const ended = med.endsOn != null && new Date(med.endsOn) < new Date();", + "const ended = today > endsOn;", + "courseStatusAt(med, now, tz)", + "resolveIntakeActionability(med, now, tz)", + ], + unlessLine: /\bintakeActionable\b|\bcourseStatus\b/, + allow: {}, + }, + { + field: "intakeActionable in the dose pickers' default", + servedBy: + "`intakeActionable` on the medication list read (`pickDefaultMedicationId`)", + // Listing active medications is fine anywhere (the dialog still lists + // ended courses for back-filling). Choosing the dose to OFFER from + // `active` is the rebuild, and these are the files that choose it. + scope: [ + "src/lib/medications/default-medication.ts", + "src/components/medications/log-intake-dialog.tsx", + "src/components/dashboard/medication-intake-quick-add.tsx", + ], + patterns: [/\.active\b/], + samples: ["const actives = options.filter((m) => m.active);"], + unlessLine: /\bintakeActionable\b/, + allow: {}, + }, + { + field: "ai capability (coach and the rest)", + servedBy: + "`ai.capabilities` on `/api/auth/me`, read through `useAiCapability`", + patterns: [ + /\.disableCoach\b/, + /\bflags\??\.coach\b/, + /from\s+["']@\/lib\/ai\/capabilities\/(resolve|load|gate|egress)["']/, + ], + samples: [ + "const coachEnabled = !user?.disableCoach;", + "if (flags?.coach && x) show();", + 'import { resolveAiCapability } from "@/lib/ai/capabilities/resolve";', + ], + allow: { + "src/components/settings/ai/disable-coach-card.tsx": + "the Activate-Coach switch: it edits the stored opt-out, so its checked state is that value", + "src/components/settings/modules-section.tsx": + "the module toggle row for the Coach: its checked state is the stored opt-out it writes", + "src/components/settings/managed-record-settings-section.tsx": + "the managed record's own settings form: the field's default is the record's stored opt-out", + "src/hooks/use-auth.ts": + "the `/me` payload normaliser: coerces the field against a stale payload, decides nothing", + }, + }, + { + field: "module surfaces", + servedBy: + "`modules` on `/api/auth/me` (record-scoped, grant-masked), read through `isSurfaceVisible` / `useNavModules`", + patterns: [ + /\bisModuleEnabled\s*\(/, + /\bresolveModuleMap\s*\(/, + /\bmoduleAvailability\b/, + ], + samples: [ + "if (await isModuleEnabled(userId, 'cycle')) {}", + "const modules = await resolveModuleMap(user.id);", + "const off = user.moduleAvailability?.cycle === false;", + ], + allow: { + "src/components/settings/modules-section.tsx": + "the Modules hub: it shows the operator-off state of each toggle row, which is what `moduleAvailability` is published for", + "src/hooks/use-auth.ts": + "the `/me` payload normaliser: types and coerces the field, decides nothing", + }, + }, +]; + +function isClientModule(rel: string, source: string): boolean { + if (rel.startsWith("src/components/") || rel.startsWith("src/hooks/")) { + return true; + } + return /^\s*["']use client["']/.test(stripComments(source).trimStart()); +} + +function clientFiles(): Array<{ rel: string; code: string }> { + const roots: Array<{ dir: string; floor: number }> = [ + { dir: "src/components", floor: 900 }, + { dir: "src/hooks", floor: 50 }, + { dir: "src/app", floor: 900 }, + ]; + const out: Array<{ rel: string; code: string }> = []; + for (const { dir, floor } of roots) { + for (const file of walkSourceFiles(join(ROOT, dir), { floor })) { + const rel = `${dir}/${file}`; + if (rel.startsWith("src/app/api/")) continue; + if (rel.includes("/__tests__/") || /\.test\.tsx?$/.test(rel)) continue; + const source = readFileSync(join(ROOT, rel), "utf8"); + if (!isClientModule(rel, source)) continue; + out.push({ rel, code: stripComments(source) }); + } + } + return out; +} + +const FILES = clientFiles(); + +function scopedFiles(scope: string[]): Array<{ rel: string; code: string }> { + return scope.map((rel) => ({ + rel, + code: stripComments(readFileSync(join(ROOT, rel), "utf8")), + })); +} + +function matchesFor(rule: ResolvedFieldRule): Map { + const hits = new Map(); + for (const { rel, code } of rule.scope ? scopedFiles(rule.scope) : FILES) { + code.split("\n").forEach((line, index) => { + if (rule.unlessLine?.test(line)) return; + if (!rule.patterns.some((pattern) => pattern.test(line))) return; + const list = hits.get(rel) ?? []; + list.push(`${rel}:${index + 1}: ${line.trim()}`); + hits.set(rel, list); + }); + } + return hits; +} + +describe("resolved-field consumer guard", () => { + it("walks a client tree of the size it expects", () => { + // Components, hooks and the `"use client"` pages come to roughly 885 + // modules; a set far below that means the directive check or the walk + // broke, not that the tree got cleaner. + expect(FILES.length).toBeGreaterThan(700); + }); + + for (const rule of RULES) { + describe(rule.field, () => { + it("every pattern still matches the shape it exists to catch", () => { + for (const sample of rule.samples) { + expect( + rule.patterns.some((pattern) => pattern.test(sample)), + `no pattern matched the sample: ${sample}`, + ).toBe(true); + expect(rule.unlessLine?.test(sample) ?? false).toBe(false); + } + // And each pattern earns its place with at least one sample. + for (const pattern of rule.patterns) { + expect( + rule.samples.some((sample) => pattern.test(sample)), + `pattern ${pattern} has no sample`, + ).toBe(true); + } + }); + + it(`is read from ${rule.servedBy}, never rebuilt in client code`, () => { + const hits = matchesFor(rule); + const offenders = [...hits.entries()] + .filter(([rel]) => !(rel in rule.allow)) + .flatMap(([, lines]) => lines); + expect( + offenders, + `client code rebuilds "${rule.field}" from its ingredients. ` + + `Read the server's resolved value instead (${rule.servedBy}), ` + + `or allowlist the file with the reason it is not a rebuild.`, + ).toEqual([]); + }); + + it("has no stale allowlist entry", () => { + const hits = matchesFor(rule); + const stale = Object.keys(rule.allow).filter((rel) => !hits.has(rel)); + expect( + stale, + "allowlisted files that no longer match — remove the entry", + ).toEqual([]); + }); + }); + } + + it("reads a non-empty set of matches across the tree", () => { + // The allowlisted readers are real matches; a guard whose matchers found + // nothing anywhere would pass on any tree. + const total = RULES.reduce( + (sum, rule) => + sum + [...matchesFor(rule).values()].reduce((n, l) => n + l.length, 0), + 0, + ); + expect(total).toBeGreaterThanOrEqual(6); + }); +});