From b562587c45d06a164dfe8f5fb676819c81ae9a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:16:11 +0200 Subject: [PATCH 1/5] feat(cycle): name the start a period start folds in and the starts a removal gives back --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- src/app/api/cycle/cycles/[id]/route.ts | 12 +- src/app/api/cycle/day-logs/[id]/route.ts | 16 +- src/app/api/cycle/period/route.ts | 15 +- src/components/cycle/use-cycle.ts | 68 ++++- src/lib/cycle/cycle-boundaries.ts | 6 + src/lib/cycle/delete-outcome.ts | 30 +++ src/lib/documents/ai-runs/http.ts | 10 +- src/lib/http/__tests__/prefer.test.ts | 39 +++ src/lib/http/prefer.ts | 22 ++ src/lib/openapi/routes/cycle.ts | 50 ++-- .../cycle-absorbed-restored.test.ts | 234 ++++++++++++++++++ 18 files changed, 481 insertions(+), 49 deletions(-) create mode 100644 src/lib/cycle/delete-outcome.ts create mode 100644 src/lib/http/__tests__/prefer.test.ts create mode 100644 src/lib/http/prefer.ts create mode 100644 tests/integration/cycle-absorbed-restored.test.ts diff --git a/messages/de.json b/messages/de.json index f2f5c30ba..6e756d71e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Periodenbeginn gespeichert", "periodEndSaved": "Periodenende gespeichert", "startedPeriodOnDate": "Periode hat an diesem Tag begonnen", - "endedPeriodOnDate": "Periode an diesem Tag beendet" + "endedPeriodOnDate": "Periode an diesem Tag beendet", + "startAbsorbed": "Periodenbeginn gespeichert. Dein Beginn am {absorbed} wurde mit dem {date} zusammengeführt.", + "startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da." }, "cycleReminders": { "periodSoonTitle": "Periode steht wahrscheinlich bevor", diff --git a/messages/en.json b/messages/en.json index 0d53008ba..f8452cf4d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Period start saved", "periodEndSaved": "End of period saved", "startedPeriodOnDate": "My period started this day", - "endedPeriodOnDate": "My period ended this day" + "endedPeriodOnDate": "My period ended this day", + "startAbsorbed": "Period start saved. Your start on {absorbed} was merged into {date}.", + "startRestored": "Saved. The start on {dates} is back." }, "cycleReminders": { "periodSoonTitle": "Period likely soon", diff --git a/messages/es.json b/messages/es.json index 0b50e9a0f..708fa2729 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Inicio del periodo guardado", "periodEndSaved": "Fin del periodo guardado", "startedPeriodOnDate": "Mi periodo empezó este día", - "endedPeriodOnDate": "Mi periodo terminó este día" + "endedPeriodOnDate": "Mi periodo terminó este día", + "startAbsorbed": "Inicio del periodo guardado. Tu inicio del {absorbed} se ha unido al del {date}.", + "startRestored": "Guardado. El inicio del {dates} vuelve a estar." }, "cycleReminders": { "periodSoonTitle": "El periodo llegará pronto", diff --git a/messages/fr.json b/messages/fr.json index 72666ef78..28c551f9e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Début des règles enregistré", "periodEndSaved": "Fin des règles enregistrée", "startedPeriodOnDate": "Mes règles ont commencé ce jour-là", - "endedPeriodOnDate": "Mes règles se sont terminées ce jour-là" + "endedPeriodOnDate": "Mes règles se sont terminées ce jour-là", + "startAbsorbed": "Début des règles enregistré. Votre début du {absorbed} a été fusionné avec celui du {date}.", + "startRestored": "Enregistré. Le début du {dates} est de retour." }, "cycleReminders": { "periodSoonTitle": "Règles probablement bientôt", diff --git a/messages/it.json b/messages/it.json index 2bb621f5c..6ef3ef883 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Inizio delle mestruazioni salvato", "periodEndSaved": "Fine delle mestruazioni salvata", "startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno", - "endedPeriodOnDate": "Le mestruazioni sono finite questo giorno" + "endedPeriodOnDate": "Le mestruazioni sono finite questo giorno", + "startAbsorbed": "Inizio delle mestruazioni salvato. Il tuo inizio del {absorbed} è stato unito a quello del {date}.", + "startRestored": "Salvato. L’inizio del {dates} è di nuovo presente." }, "cycleReminders": { "periodSoonTitle": "Ciclo probabilmente in arrivo", diff --git a/messages/ko.json b/messages/ko.json index 6129f6db7..a90b85329 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "생리 시작을 저장했어요", "periodEndSaved": "생리 종료를 저장했어요", "startedPeriodOnDate": "이 날 생리를 시작했어요", - "endedPeriodOnDate": "이 날 생리가 끝났어요" + "endedPeriodOnDate": "이 날 생리가 끝났어요", + "startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.", + "startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요." }, "cycleReminders": { "periodSoonTitle": "생리가 곧 시작될 것 같아요", diff --git a/messages/pl.json b/messages/pl.json index 6d71a7276..6b417cc1f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Początek miesiączki zapisany", "periodEndSaved": "Koniec miesiączki zapisany", "startedPeriodOnDate": "Miesiączka zaczęła się tego dnia", - "endedPeriodOnDate": "Miesiączka skończyła się tego dnia" + "endedPeriodOnDate": "Miesiączka skończyła się tego dnia", + "startAbsorbed": "Początek miesiączki zapisany. Twój początek z {absorbed} został połączony z {date}.", + "startRestored": "Zapisano. Początek z {dates} jest z powrotem." }, "cycleReminders": { "periodSoonTitle": "Miesiączka prawdopodobnie wkrótce", diff --git a/src/app/api/cycle/cycles/[id]/route.ts b/src/app/api/cycle/cycles/[id]/route.ts index 088c6c7a0..705c511a0 100644 --- a/src/app/api/cycle/cycles/[id]/route.ts +++ b/src/app/api/cycle/cycles/[id]/route.ts @@ -2,6 +2,9 @@ * `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle * (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a * tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated. + * With `Prefer: return=representation` (v1.43, RFC 7240) the answer is 200 + * with what the delete changed — the removed cycle and the starts it gave + * back — so a client can say "the start on 17 Sep is back". * * The delete also hands the span back to the preceding cycle. Opening a cycle * closes the one before it, so removing it without re-deriving that boundary @@ -18,6 +21,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiError, getClientIp } from "@/lib/api-response"; +import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries"; @@ -44,7 +48,7 @@ export const DELETE = apiHandler( } // Already a tombstone: the delete is done, and nothing is left to move. if (existing.deletedAt !== null) { - return new Response(null, { status: 204 }); + return cycleDeleteOutcome(request, null, []); } // Tombstone + re-anchor as one unit: a neighbour re-derived against a @@ -99,6 +103,10 @@ export const DELETE = apiHandler( meta: { reanchored_prior: reanchored !== null }, }); - return new Response(null, { status: 204 }); + return cycleDeleteOutcome( + request, + removed?.cycleId ?? null, + removed?.restored ?? [], + ); }, ); diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 4c92fe9a2..4db316019 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -3,7 +3,8 @@ * (ios-contract §2.A). * `DELETE /api/cycle/day-logs/{id}` — soft-delete (ios-contract §2.F): * set `deletedAt` + bump `syncVersion`, emit a tombstone on the next - * sync page. 204. Idempotent. + * sync page. 204. Idempotent. With `Prefer: return=representation` 200 + * with the removed cycle and the starts given back (v1.43). * * Both are gated (`cycle.disabled` 403) and owner-scoped (a row owned by * another user 404s). @@ -34,6 +35,7 @@ import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { replaceSymptomLinks } from "@/lib/cycle/day-log-write"; import { cycleDayLogPatchSchema } from "@/lib/validations/cycle"; import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto"; +import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome"; type RouteParams = { params: Promise<{ id: string }> }; @@ -176,11 +178,13 @@ export const PATCH = apiHandler( row, ); let removedCycleId: string | null = null; + let restored: Array<{ cycleId: string; startDate: string }> = []; if (!openedCycleId && !stillBleeds(row)) { const removed = await prisma.$transaction((db) => removeCycleStartedOn(db, user.id, existing.date), ); removedCycleId = removed?.cycleId ?? null; + restored = removed?.restored ?? []; } await auditLog("cycle.day-log.update", { @@ -221,7 +225,9 @@ export const PATCH = apiHandler( }, }); - return apiSuccess(toCycleDayLogDTO(row)); + // v1.43 (ios #116 item 9) — the starts this edit gave back by clearing + // the flow on a start day; empty when it removed none. + return apiSuccess({ ...toCycleDayLogDTO(row), restored }); }, ); @@ -303,7 +309,11 @@ export const DELETE = apiHandler( }, }); - return new Response(null, { status: 204 }); + return cycleDeleteOutcome( + request, + removed?.cycleId ?? null, + removed?.restored ?? [], + ); }, ); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 80b276b3f..d95ee0b3a 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -7,7 +7,8 @@ * and writes the boundary `CycleDayLog(flow=MEDIUM)`. `action:"end"` * stamps the current cycle's `periodEndDate` and writes the boundary * day-log. Convenience over §2.A; returns - * `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO }`. + * `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO, absorbed }`, where + * `absorbed` names a later start this one folded in (v1.43, null otherwise). */ import { NextRequest } from "next/server"; @@ -221,12 +222,22 @@ async function postPeriod(request: NextRequest): Promise { return apiSuccess({ cycle: toMenstrualCycleDTO(cycleRow), dayLog: toCycleDayLogDTO(dayLogRow), + // v1.43 (ios #116 item 9) — a later start inside the same period that + // this start folded in, so the client can say which date stopped being a + // start. Null when nothing was folded; present on every `start`. + absorbed: + txResult.moved.absorbedCycleId && txResult.moved.absorbedStartDate + ? { + cycleId: txResult.moved.absorbedCycleId, + startDate: txResult.moved.absorbedStartDate, + } + : null, }); } /** `end` with no preceding cycle is a no-op the client should not hit. */ function apiSuccessNoCycle(): Response { - return apiSuccess({ cycle: null, dayLog: null }); + return apiSuccess({ cycle: null, dayLog: null, absorbed: null }); } /** diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 7b561dc7e..dd2f902f7 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -23,6 +23,7 @@ import { import { useTranslations } from "@/lib/i18n/context"; import { randomId } from "@/lib/random-id"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { cycleDependentKeys, @@ -318,10 +319,33 @@ export interface CycleDayLogPatch { note?: string | null; } +/** + * A period start the server folded in or gave back (v1.43). The server names + * it; the toast only says it, so the person learns which date moved without + * comparing the calendar before and after. + */ +export interface CycleStartRef { + cycleId: string; + startDate: string; +} + +/** The toast after a write that may have given starts back. */ +function useRestoredStartsMessage() { + const { t } = useTranslations(); + const formatDay = useCalendarDate(); + return (fallback: string, restored: readonly CycleStartRef[] | undefined) => + restored && restored.length > 0 + ? t("cycle.startRestored", { + dates: restored.map((r) => formatDay(r.startDate)).join(", "), + }) + : fallback; +} + /** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */ export function usePatchDayLog() { const qc = useQueryClient(); const { t } = useTranslations(); + const restoredMessage = useRestoredStartsMessage(); return useMutation({ mutationFn: async ({ id, @@ -330,11 +354,17 @@ export function usePatchDayLog() { id: string; patch: CycleDayLogPatch; }) => { - return apiPatch(`/api/cycle/day-logs/${id}`, patch); + return apiPatch( + `/api/cycle/day-logs/${id}`, + patch, + ); }, - onSuccess: () => { + onSuccess: (data) => { void invalidateKeys(qc, cycleDependentKeys); - toastWrittenOutcome("success", t("cycle.saveSuccess")); + toastWrittenOutcome( + "success", + restoredMessage(t("cycle.saveSuccess"), data?.restored), + ); }, }); } @@ -342,17 +372,26 @@ export function usePatchDayLog() { export function useStartPeriod() { const qc = useQueryClient(); const { t } = useTranslations(); + const formatDay = useCalendarDate(); return useMutation({ mutationFn: async (date: string) => { - return apiPost("/api/cycle/period", { + return apiPost<{ absorbed?: CycleStartRef | null }>("/api/cycle/period", { action: "start", date, loggedAt: new Date().toISOString(), }); }, - onSuccess: () => { + onSuccess: (data, date) => { void invalidateKeys(qc, cycleDependentKeys); - toastWrittenOutcome("success", t("cycle.periodStartSaved")); + toastWrittenOutcome( + "success", + data?.absorbed + ? t("cycle.startAbsorbed", { + absorbed: formatDay(data.absorbed.startDate), + date: formatDay(date), + }) + : t("cycle.periodStartSaved"), + ); }, // v1.16.4 — period boundaries had no failure signal at all (the sheet // simply stayed open); a toast names the rejection. @@ -386,13 +425,24 @@ export function useEndPeriod() { export function useDeleteDayLog() { const qc = useQueryClient(); const { t } = useTranslations(); + const restoredMessage = useRestoredStartsMessage(); return useMutation({ mutationFn: async (id: string) => { - await apiDelete(`/api/cycle/day-logs/${id}`); + // Ask for what the delete changed: removing a start day can give back a + // start it had folded in, and the toast names it. A server that does + // not know the preference answers 204, read as nothing given back. + return apiDelete<{ restored?: CycleStartRef[] } | null>( + `/api/cycle/day-logs/${id}`, + undefined, + { headers: { Prefer: "return=representation" } }, + ); }, - onSuccess: () => { + onSuccess: (data) => { void invalidateKeys(qc, cycleDependentKeys); - toastWrittenOutcome("success", t("cycle.deleteSuccess")); + toastWrittenOutcome( + "success", + restoredMessage(t("cycle.deleteSuccess"), data?.restored), + ); }, onError: (failure) => showErrorToast(t("cycle.deleteError"), { cause: failure }), diff --git a/src/lib/cycle/cycle-boundaries.ts b/src/lib/cycle/cycle-boundaries.ts index 660c5ed73..337213aee 100644 --- a/src/lib/cycle/cycle-boundaries.ts +++ b/src/lib/cycle/cycle-boundaries.ts @@ -366,6 +366,8 @@ export async function removeCycleStartedOn( cycleId: string; reanchored: ReanchoredCycle | null; restoredCycleIds: string[]; + /** The same starts with their dates, for the answer to the client. */ + restored: Array<{ cycleId: string; startDate: string }>; } | null> { const opened = await db.menstrualCycle.findFirst({ where: { userId, deletedAt: null, startDate: date }, @@ -432,6 +434,10 @@ export async function removeCycleStartedOn( cycleId: opened.id, reanchored, restoredCycleIds: restored.map((row) => row.id), + restored: restored.map((row) => ({ + cycleId: row.id, + startDate: row.startDate, + })), }; } diff --git a/src/lib/cycle/delete-outcome.ts b/src/lib/cycle/delete-outcome.ts new file mode 100644 index 000000000..d164e4123 --- /dev/null +++ b/src/lib/cycle/delete-outcome.ts @@ -0,0 +1,30 @@ +/** + * The answer to a delete that can take a period start with it (v1.43, ios + * #116 item 9). + * + * Without a preference the delete answers 204, exactly as before, which is + * what every client predating this relies on. With `Prefer: + * return=representation` (RFC 7240) it answers 200 with the cycle it removed + * and the starts that removal gave back — a start folded into the removed one + * stands again — and says so in `Preference-Applied`. + */ +import { apiSuccess } from "@/lib/api-response"; +import { hasPreference, RETURN_REPRESENTATION } from "@/lib/http/prefer"; + +export interface CycleRestoredStart { + cycleId: string; + startDate: string; +} + +export function cycleDeleteOutcome( + request: Request, + removedCycleId: string | null, + restored: readonly CycleRestoredStart[], +): Response { + if (!hasPreference(request, RETURN_REPRESENTATION)) { + return new Response(null, { status: 204 }); + } + const response = apiSuccess({ removedCycleId, restored: [...restored] }); + response.headers.set("Preference-Applied", RETURN_REPRESENTATION); + return response; +} diff --git a/src/lib/documents/ai-runs/http.ts b/src/lib/documents/ai-runs/http.ts index 642c0bab9..6a75966f0 100644 --- a/src/lib/documents/ai-runs/http.ts +++ b/src/lib/documents/ai-runs/http.ts @@ -4,6 +4,7 @@ * response the synchronous route has always given. */ import { apiError, apiSuccess } from "@/lib/api-response"; +import { hasPreference } from "@/lib/http/prefer"; import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import type { AiRunFailure } from "./store"; @@ -20,14 +21,7 @@ import { * is what a client that has not adopted runs relies on. */ export function prefersRespondAsync(request: Request): boolean { - const header = request.headers.get("prefer"); - if (!header) return false; - return header - .split(",") - .some( - (preference) => - preference.split(";")[0]?.trim().toLowerCase() === "respond-async", - ); + return hasPreference(request, "respond-async"); } /** The 202 for a queued run, with the poll address and the applied preference. */ diff --git a/src/lib/http/__tests__/prefer.test.ts b/src/lib/http/__tests__/prefer.test.ts new file mode 100644 index 000000000..3a2210264 --- /dev/null +++ b/src/lib/http/__tests__/prefer.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { hasPreference, RETURN_REPRESENTATION } from "../prefer"; + +function req(prefer?: string): Request { + return new Request("http://localhost/x", { + headers: prefer === undefined ? {} : { prefer }, + }); +} + +describe("hasPreference", () => { + it("is false without the header", () => { + expect(hasPreference(req(), RETURN_REPRESENTATION)).toBe(false); + }); + + it("finds a valued preference among several, with parameters and case", () => { + expect( + hasPreference( + req("respond-async, Return=Representation; foo=bar"), + RETURN_REPRESENTATION, + ), + ).toBe(true); + expect( + hasPreference(req("return = representation"), "return=representation"), + ).toBe(true); + }); + + it("does not take a different value of the same preference", () => { + expect(hasPreference(req("return=minimal"), RETURN_REPRESENTATION)).toBe( + false, + ); + }); + + it("reads a bare token", () => { + expect(hasPreference(req("respond-async; wait=10"), "respond-async")).toBe( + true, + ); + }); +}); diff --git a/src/lib/http/prefer.ts b/src/lib/http/prefer.ts new file mode 100644 index 000000000..697ff1fcc --- /dev/null +++ b/src/lib/http/prefer.ts @@ -0,0 +1,22 @@ +/** + * RFC 7240 `Prefer` header reading. A preference is the token before its + * first `;` (parameters follow it), several preferences are comma-separated, + * and a token without `=` stands alone (`respond-async`) while one with `=` + * carries a value (`return=representation`). Compared case-insensitively. + */ +export function hasPreference(request: Request, preference: string): boolean { + const header = request.headers.get("prefer"); + if (!header) return false; + const wanted = preference.toLowerCase(); + return header.split(",").some( + (entry) => + entry + .split(";")[0] + ?.trim() + .toLowerCase() + .replace(/\s*=\s*/, "=") === wanted, + ); +} + +/** `Prefer: return=representation`: answer a write with what it changed. */ +export const RETURN_REPRESENTATION = "return=representation"; diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 354bf29a0..d60b379f1 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -333,6 +333,30 @@ const cycleDeleteOutcomeEnvelope = dataEnvelope( "CycleDeleteOutcomeEnvelope", ); +/** `Prefer: return=representation` on the two deletes that can remove a start. */ +const preferReturnRepresentationParameter = { + name: "Prefer", + in: "header" as const, + required: false, + schema: { type: "string" as const, example: "return=representation" }, + description: + "v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed (`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route answers 204 with no body, as before.", +}; + +/** The 200 a delete answers with when the request preferred a representation. */ +const cycleDeleteOutcomeResponse = { + description: + "v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a day that opened no cycle or a repeated delete) and the starts the removal gave back.", + content: { + "application/json": { schema: cycleDeleteOutcomeEnvelope }, + }, + headers: z.object({ + "Preference-Applied": z.literal("return=representation").meta({ + description: "The route honoured `Prefer: return=representation`.", + }), + }), +}; + const cyclePeriodResponse = z.object({ cycle: menstrualCycleDto.nullable(), dayLog: cycleDayLogDto.nullable(), @@ -340,7 +364,7 @@ const cyclePeriodResponse = z.object({ .nullable() .optional() .describe( - 'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.', + 'v1.43 — a later start within the period length that this start folded in (it stops being a start of its own), or null. Present on every `action: "start"` answer; null on `end`.', ), }); @@ -787,7 +811,7 @@ export const cyclePaths: NonNullable = { .array(cycleRestoredStart) .optional() .describe( - "v1.43 — starts given back because this edit cleared the flow on a start day.", + "v1.43 — starts given back because this edit cleared the flow on a start day; empty when it gave back none. Absent on older servers.", ), }), ), @@ -808,16 +832,11 @@ export const cyclePaths: NonNullable = { tags: ["Cycle"], summary: "Soft-delete a cycle day-log (v1.15.0)", description: - "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.", + "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. A day that opened a cycle takes that cycle with it, which gives back any start that cycle had folded in. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.", requestParams: { path: z.object({ id: z.string() }) }, + parameters: [preferReturnRepresentationParameter], responses: { - "200": { - description: - "v1.43 — with `Prefer: return=representation`: what the delete changed.", - content: { - "application/json": { schema: cycleDeleteOutcomeEnvelope }, - }, - }, + "200": cycleDeleteOutcomeResponse, "204": { description: "Soft-deleted (no body)." }, "404": { description: "Day-log not found / not owned.", @@ -970,16 +989,11 @@ export const cyclePaths: NonNullable = { tags: ["Cycle"], summary: "Soft-delete a menstrual cycle (v1.15.0)", description: - "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.", + "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. Any start this cycle had folded in stands again. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.", requestParams: { path: z.object({ id: z.string() }) }, + parameters: [preferReturnRepresentationParameter], responses: { - "200": { - description: - "v1.43 — with `Prefer: return=representation`: what the delete changed.", - content: { - "application/json": { schema: cycleDeleteOutcomeEnvelope }, - }, - }, + "200": cycleDeleteOutcomeResponse, "204": { description: "Soft-deleted (no body)." }, "404": { description: "Cycle not found / not owned.", diff --git a/tests/integration/cycle-absorbed-restored.test.ts b/tests/integration/cycle-absorbed-restored.test.ts new file mode 100644 index 000000000..0d807a61c --- /dev/null +++ b/tests/integration/cycle-absorbed-restored.test.ts @@ -0,0 +1,234 @@ +/** + * The cycle writes name the starts they fold in and give back (ios #116 + * item 9). + * + * A start logged a few days before an existing one is the same period entered + * in the other order, so the later start is folded in. Taking the earlier one + * back gives the later one back. Both used to reach only the audit row; the + * client could refetch the grid but could not say which date moved. These + * pin the answers: `absorbed` on the start, `restored` on the PATCH that + * clears a start day's flow, and the opt-in 200 on the two deletes, with the + * 204 unchanged for a client that does not ask. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-cycle-absorbed-integration-32-bytes-min-1234567890"; +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const USER_ID = "user-cycle-absorbed"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "cycle-absorbed", + email: "cycle-absorbed@example.test", + gender: "FEMALE", + }, + }); + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function request( + path: string, + method: string, + body?: unknown, + headers: Record = {}, +): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json", ...headers }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +async function startPeriod(date: string): Promise<{ + cycle: { id: string; startDate: string } | null; + absorbed: { cycleId: string; startDate: string } | null; +}> { + const { POST } = await import("@/app/api/cycle/period/route"); + const res = await POST( + request("/api/cycle/period", "POST", { + action: "start", + date, + loggedAt: `${date}T08:00:00.000Z`, + }), + ); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +async function cycleIdOn(date: string): Promise { + const row = await getPrismaClient().menstrualCycle.findFirstOrThrow({ + where: { userId: USER_ID, startDate: date }, + select: { id: true }, + }); + return row.id; +} + +const PREFER = { prefer: "return=representation" }; + +describe("absorbed and restored cycle starts", () => { + it("names the later start a start folds in, and null when it folds none", async () => { + const first = await startPeriod("2026-09-17"); + expect(first.absorbed).toBeNull(); + const later = await cycleIdOn("2026-09-17"); + + const earlier = await startPeriod("2026-09-14"); + expect(earlier.absorbed).toEqual({ + cycleId: later, + startDate: "2026-09-17", + }); + }); + + it("gives the folded start back on a cycle delete with Prefer, as 200", async () => { + await startPeriod("2026-09-17"); + const later = await cycleIdOn("2026-09-17"); + await startPeriod("2026-09-14"); + const earlier = await cycleIdOn("2026-09-14"); + + const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route"); + const res = await DELETE( + request(`/api/cycle/cycles/${earlier}`, "DELETE", undefined, PREFER), + { params: Promise.resolve({ id: earlier }) }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("preference-applied")).toBe("return=representation"); + expect((await res.json()).data).toEqual({ + removedCycleId: earlier, + restored: [{ cycleId: later, startDate: "2026-09-17" }], + }); + // The calendar shows 17 Sep again. + const live = await getPrismaClient().menstrualCycle.findMany({ + where: { userId: USER_ID, deletedAt: null }, + select: { startDate: true }, + }); + expect(live.map((c) => c.startDate)).toEqual(["2026-09-17"]); + }); + + it("keeps answering 204 without a body when the request does not ask", async () => { + await startPeriod("2026-09-17"); + await startPeriod("2026-09-14"); + const earlier = await cycleIdOn("2026-09-14"); + + const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route"); + const res = await DELETE( + request(`/api/cycle/cycles/${earlier}`, "DELETE"), + { params: Promise.resolve({ id: earlier }) }, + ); + expect(res.status).toBe(204); + expect(res.headers.get("preference-applied")).toBeNull(); + expect(await res.text()).toBe(""); + }); + + it("answers a repeated delete with nothing removed and nothing restored", async () => { + await startPeriod("2026-09-17"); + const id = await cycleIdOn("2026-09-17"); + const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route"); + await DELETE(request(`/api/cycle/cycles/${id}`, "DELETE"), { + params: Promise.resolve({ id }), + }); + const again = await DELETE( + request(`/api/cycle/cycles/${id}`, "DELETE", undefined, PREFER), + { params: Promise.resolve({ id }) }, + ); + expect(again.status).toBe(200); + expect((await again.json()).data).toEqual({ + removedCycleId: null, + restored: [], + }); + }); + + it("gives the folded start back on a day-log delete with Prefer", async () => { + await startPeriod("2026-09-17"); + const later = await cycleIdOn("2026-09-17"); + await startPeriod("2026-09-14"); + const earlier = await cycleIdOn("2026-09-14"); + const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: USER_ID, date: "2026-09-14" }, + select: { id: true }, + }); + + const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route"); + const res = await DELETE( + request(`/api/cycle/day-logs/${dayLog.id}`, "DELETE", undefined, { + prefer: "handling=lenient, return=representation", + }), + { params: Promise.resolve({ id: dayLog.id }) }, + ); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual({ + removedCycleId: earlier, + restored: [{ cycleId: later, startDate: "2026-09-17" }], + }); + }); + + it("names the start given back when an edit clears the flow on a start day", async () => { + await startPeriod("2026-09-17"); + const later = await cycleIdOn("2026-09-17"); + await startPeriod("2026-09-14"); + const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: USER_ID, date: "2026-09-14" }, + select: { id: true }, + }); + + const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route"); + const res = await PATCH( + request(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: "NONE" }), + { params: Promise.resolve({ id: dayLog.id }) }, + ); + expect(res.status).toBe(200); + const data = (await res.json()).data; + expect(data.restored).toEqual([ + { cycleId: later, startDate: "2026-09-17" }, + ]); + expect(data.id).toBe(dayLog.id); + }); + + it("answers an edit that removes no start with an empty list", async () => { + await startPeriod("2026-09-17"); + const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: USER_ID, date: "2026-09-17" }, + select: { id: true }, + }); + const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route"); + const res = await PATCH( + request(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: "HEAVY" }), + { params: Promise.resolve({ id: dayLog.id }) }, + ); + expect((await res.json()).data.restored).toEqual([]); + }); +}); From b91328d67ed549987e6d6bc83d31c55b1ceeed20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:18:58 +0200 Subject: [PATCH 2/5] feat(records): publish an age- and sex-aware benchmark beside each personal record --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../personal-records/__tests__/route.test.ts | 75 ++++++++ src/app/api/personal-records/route.ts | 51 +++++- .../insights/personal-record-badge.tsx | 24 +++ src/lib/openapi/routes/measurements.ts | 107 +---------- .../__tests__/record-benchmark.test.ts | 124 +++++++++++++ src/lib/personal-records/record-benchmark.ts | 166 ++++++++++++++++++ 13 files changed, 455 insertions(+), 113 deletions(-) create mode 100644 src/lib/personal-records/__tests__/record-benchmark.test.ts create mode 100644 src/lib/personal-records/record-benchmark.ts diff --git a/messages/de.json b/messages/de.json index 6e756d71e..7136e471a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2669,7 +2669,8 @@ "assessmentPreparing": "Einschätzung wird vorbereitet…", "personalRecord": { "badge": "PR", - "tooltip": "Persönlicher Rekord: {value} {unit} am {date}" + "tooltip": "Persönlicher Rekord: {value} {unit} am {date}", + "benchmark": "Typisch in dem Alter: {low} bis {high} {unit} ({source})" }, "loadError": "Auswertungen konnten nicht geladen werden", "heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen", diff --git a/messages/en.json b/messages/en.json index f8452cf4d..978148004 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2669,7 +2669,8 @@ "bmiEmptyAction": "Open account settings", "personalRecord": { "badge": "PR", - "tooltip": "Personal record: {value} {unit} on {date}" + "tooltip": "Personal record: {value} {unit} on {date}", + "benchmark": "Typical at that age: {low} to {high} {unit} ({source})" }, "loadError": "Failed to load insights", "heroPersonalBaseline": "Based on your last 90 days", diff --git a/messages/es.json b/messages/es.json index 708fa2729..29ea0677d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2669,7 +2669,8 @@ "bmiEmptyAction": "Abrir ajustes de cuenta", "personalRecord": { "badge": "PR", - "tooltip": "Récord personal: {value} {unit} el {date}" + "tooltip": "Récord personal: {value} {unit} el {date}", + "benchmark": "Lo típico a esa edad: de {low} a {high} {unit} ({source})" }, "loadError": "No se han podido cargar los análisis", "heroPersonalBaseline": "Basado en tus últimos 90 días", diff --git a/messages/fr.json b/messages/fr.json index 28c551f9e..7beb6c029 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2669,7 +2669,8 @@ "bmiEmptyAction": "Ouvrir les paramètres du compte", "personalRecord": { "badge": "PR", - "tooltip": "Record personnel : {value} {unit} le {date}" + "tooltip": "Record personnel : {value} {unit} le {date}", + "benchmark": "Valeur typique à cet âge : de {low} à {high} {unit} ({source})" }, "loadError": "Impossible de charger les analyses", "heroPersonalBaseline": "Basé sur tes 90 derniers jours", diff --git a/messages/it.json b/messages/it.json index 6ef3ef883..cd9ba3c3b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2669,7 +2669,8 @@ "bmiEmptyAction": "Apri impostazioni account", "personalRecord": { "badge": "PR", - "tooltip": "Record personale: {value} {unit} il {date}" + "tooltip": "Record personale: {value} {unit} il {date}", + "benchmark": "Tipico a quell’età: da {low} a {high} {unit} ({source})" }, "loadError": "Impossibile caricare le analisi", "heroPersonalBaseline": "Basato sugli ultimi 90 giorni", diff --git a/messages/ko.json b/messages/ko.json index a90b85329..223c4ff1a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2669,7 +2669,8 @@ "bmiEmptyAction": "계정 설정 열기", "personalRecord": { "badge": "신기록", - "tooltip": "개인 최고 기록: {date} {value}{unit}" + "tooltip": "개인 최고 기록: {date} {value}{unit}", + "benchmark": "그 나이의 일반적인 범위: {low}~{high} {unit} ({source})" }, "loadError": "인사이트를 불러오지 못했어요", "heroPersonalBaseline": "최근 90일 기준", diff --git a/messages/pl.json b/messages/pl.json index 6b417cc1f..ae3cf4116 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2669,7 +2669,8 @@ "bmiEmptyAction": "Otwórz ustawienia konta", "personalRecord": { "badge": "PR", - "tooltip": "Rekord osobisty: {value} {unit} dnia {date}" + "tooltip": "Rekord osobisty: {value} {unit} dnia {date}", + "benchmark": "Typowo w tym wieku: od {low} do {high} {unit} ({source})" }, "loadError": "Nie udało się załadować analiz", "heroPersonalBaseline": "Na podstawie ostatnich 90 dni", diff --git a/src/app/api/personal-records/__tests__/route.test.ts b/src/app/api/personal-records/__tests__/route.test.ts index 30ce136d4..df54373ba 100644 --- a/src/app/api/personal-records/__tests__/route.test.ts +++ b/src/app/api/personal-records/__tests__/route.test.ts @@ -6,6 +6,8 @@ vi.mock("@/lib/db", () => ({ personalRecord: { findMany: vi.fn(), }, + user: { findUnique: vi.fn() }, + measurement: { findFirst: vi.fn() }, }, })); @@ -186,4 +188,77 @@ describe("GET /api/personal-records", () => { expect(body.data[0].value).toBe(47.2); expect(body.data[0].metricType).toBe("VO2_MAX"); }); + + // v1.43 (ios #116 item 5) — every stored field stays, and the band for the + // age the record was set at rides beside it. + it("projects every stored field and adds the benchmark", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const createdAt = new Date("2026-05-02T00:00:00.000Z"); + vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([ + { + id: "pr-1", + userId: "user-1", + metricType: "VO2_MAX", + metricSlot: null, + direction: "MAX", + value: 47.2, + unit: "mL/(kg·min)", + achievedAt: new Date("2026-05-01T07:00:00.000Z"), + sourceMeasurementId: "m-1", + source: "APPLE_HEALTH", + externalId: "x-1", + createdAt, + }, + { + id: "pr-2", + userId: "user-1", + metricType: "ACTIVITY_STEPS", + metricSlot: null, + direction: "MAX", + value: 21000, + unit: "steps", + achievedAt: new Date("2026-04-01T07:00:00.000Z"), + sourceMeasurementId: null, + source: "APPLE_HEALTH", + externalId: null, + createdAt, + }, + ] as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: new Date("1981-03-10T00:00:00.000Z"), + gender: "MALE", + heightCm: 180, + } as never); + const res = await GET(req("http://localhost/api/personal-records")); + const body = (await res.json()) as { data: Array> }; + expect(Object.keys(body.data[0]).sort()).toEqual( + [ + "achievedAt", + "benchmark", + "createdAt", + "direction", + "externalId", + "id", + "metricSlot", + "metricType", + "source", + "sourceMeasurementId", + "unit", + "userId", + "value", + ].sort(), + ); + // 45 when the record was set: between the 40s and 50s male FRIEND bands. + expect(body.data[0].benchmark).toMatchObject({ + unit: "mL/(kg·min)", + source: "friend_2015", + }); + const band = body.data[0].benchmark as { low: number; high: number }; + expect(band.low).toBeGreaterThan(31); + expect(band.high).toBeLessThan(49); + // A step record has no cited norm. + expect(body.data[1].benchmark).toBeNull(); + // No six-minute walk on the page, so no weight read. + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/personal-records/route.ts b/src/app/api/personal-records/route.ts index 6b7793b1b..ab3ba4623 100644 --- a/src/app/api/personal-records/route.ts +++ b/src/app/api/personal-records/route.ts @@ -21,6 +21,11 @@ * * Response envelope (matches the project-wide `apiSuccess` contract): * { data: PersonalRecord[], error: null } + * + * v1.43 (ios #116 item 5) — the route projects each row instead of passing the + * Prisma result through. Every stored field stays on the wire, and each row + * gains `benchmark`: the cited age- and sex-aware band for the age the record + * was set at (`resolveRecordBenchmark`), or null. */ import { prisma } from "@/lib/db"; import type { NextRequest } from "next/server"; @@ -28,6 +33,11 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { listPersonalRecordsSchema } from "@/lib/validations/measurement"; +import { + mayCarryBenchmark, + resolveRecordBenchmark, +} from "@/lib/personal-records/record-benchmark"; +import { toProfileSex } from "@/lib/profile/sex"; export const dynamic = "force-dynamic"; @@ -61,5 +71,44 @@ export const GET = apiHandler(async (request: NextRequest) => { take: limit, }); - return apiSuccess(records); + const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType)); + const profile = wantsBand + ? await prisma.user.findUnique({ + where: { id: user.id }, + select: { dateOfBirth: true, gender: true, heightCm: true }, + }) + : null; + // Only the six-minute-walk equation needs a weight. + const weight = + profile && records.some((r) => r.metricType === "SIX_MINUTE_WALK_DISTANCE") + ? await prisma.measurement.findFirst({ + where: { userId: user.id, type: "WEIGHT", deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }) + : null; + const benchmarkProfile = { + dateOfBirth: profile?.dateOfBirth ?? null, + sex: toProfileSex(profile?.gender) ?? undefined, + heightCm: profile?.heightCm ?? null, + weightKg: weight?.value ?? null, + }; + + return apiSuccess( + records.map((r) => ({ + id: r.id, + userId: r.userId, + metricType: r.metricType, + metricSlot: r.metricSlot, + direction: r.direction, + value: r.value, + unit: r.unit, + achievedAt: r.achievedAt, + sourceMeasurementId: r.sourceMeasurementId, + source: r.source, + externalId: r.externalId, + createdAt: r.createdAt, + benchmark: profile ? resolveRecordBenchmark(r, benchmarkProfile) : null, + })), + ); }); diff --git a/src/components/insights/personal-record-badge.tsx b/src/components/insights/personal-record-badge.tsx index b6d163a81..086433a70 100644 --- a/src/components/insights/personal-record-badge.tsx +++ b/src/components/insights/personal-record-badge.tsx @@ -41,6 +41,17 @@ interface PersonalRecordRow { value: number; unit: string; achievedAt: string; + /** + * v1.43 — the cited band for the age the record was set at, resolved on + * the server; null (or absent on an older server) when none applies. + */ + benchmark?: { + low: number; + high: number; + unit: string; + source: string; + sourceLabel: string; + } | null; } interface PersonalRecordBadgeProps { @@ -146,6 +157,19 @@ export function PersonalRecordBadge({ data-slot="insights-pr-badge-tooltip" > {tooltipText} + {fresh.benchmark ? ( + + {t("insights.personalRecord.benchmark", { + low: fmt.number(fresh.benchmark.low, 1), + high: fmt.number(fresh.benchmark.high, 1), + unit: fresh.benchmark.unit, + source: fresh.benchmark.sourceLabel, + })} + + ) : null} ); diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 5385ac359..d145a24bf 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -9,7 +9,6 @@ import { seriesChange, seriesReferenceLine, trendVerdict, - recordBenchmark, } from "./trend-verdict"; import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; @@ -595,86 +594,8 @@ export const sleepRhythmResponse = z "Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.", }); -// ── Personal records ───────────────────────────────────────────────── -// -// `GET /api/personal-records` shipped as a schema-only contract so the native -// client could build its query path against a stable shape before the -// detection worker landed. It answers the stored rows verbatim — including -// `userId` and the provenance foreign key — because the handler passes the -// Prisma result straight to `apiSuccess` with no projection. - -const personalRecordQuery = z - .object({ - metricType: measurementTypeEnum - .optional() - .describe( - "Restrict to one measurement type. A value outside the enum is DROPPED rather than refused — the read then covers every metric, which is indistinguishable from omitting the parameter.", - ), - limit: z.coerce - .number() - .int() - .min(1) - .max(500) - .optional() - .default(100) - .describe( - "Row cap, 1..500, default 100. A non-integer, zero, negative or non-numeric value falls back to the default and a value above the ceiling clamps to 500 — none of them 422.", - ), - }) - .meta({ id: "PersonalRecordQuery" }); - -const personalRecordResource = z - .object({ - id: z.string(), - userId: z - .string() - .describe( - "The RECORD owner's id, which is not the caller's id on a delegated read.", - ), - metricType: measurementTypeEnum, - metricSlot: z - .string() - .nullable() - .describe( - "Per-sport-type bucket for workout-driven records (e.g. `running_5km_time`). Null for a plain measurement-driven record, where the metric type alone is the dimension.", - ), - direction: z - .enum(["MAX", "MIN"]) - .describe( - "Which way is better for this metric: `MAX` for steps / VO2 max / HRV / distance / daylight, `MIN` for resting heart rate / body fat / audio exposure.", - ), - value: z.number(), - unit: z.string(), - achievedAt: z.iso - .datetime({ offset: true }) - .describe( - "When the record was set. The list orders on this, newest first.", - ), - sourceMeasurementId: z - .string() - .nullable() - .describe( - "The measurement row that achieved the record. Nulled rather than cascaded when that measurement is deleted, so the historical fact survives its source.", - ), - source: measurementSourceEnum, - externalId: z.string().nullable(), - createdAt: z.iso - .datetime({ offset: true }) - .describe( - "When the detection worker wrote the row — distinct from `achievedAt`, which is when the reading happened.", - ), - benchmark: recordBenchmark - .nullable() - .optional() - .describe( - "v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null otherwise.", - ), - }) - .meta({ - id: "PersonalRecord", - description: - "One personal record: the best value a metric (optionally within a per-sport slot) has reached, in the direction that counts as better for it.", - }); +// `GET /api/personal-records` is documented once, in `./awards.ts`, beside the +// other read of earned bests. // ── Time-series adapter (iOS chart source) ─────────────────────────── // @@ -1255,28 +1176,4 @@ export const measurementPaths: NonNullable = { }, }, }, - "/api/personal-records": { - get: { - tags: ["Measurements"], - summary: "List the record's personal records", - description: - "Returns the account's personal-record rows, `achievedAt` descending. Both query parameters are FORGIVING rather than strict: an unrecognised `metricType` is dropped (the read falls back to every metric) and an unusable `limit` clamps to the default instead of 422-ing, so a loosely-typed client filter cannot take the page down. Rows are the stored entities verbatim, including `userId` and the provenance `sourceMeasurementId`. Delegable at READ level over the `measurements` section. Not module-gated: the list spans metrics from several modules and is filtered by the caller's `metricType`, not by the account's module map — a record achieved under a metric whose module is now off is still returned. Cookie or Bearer auth.", - requestParams: { query: personalRecordQuery }, - responses: { - ...recordRefusal(), - "200": { - description: "The personal-record rows (possibly empty).", - content: { - "application/json": { - schema: dataEnvelope( - z.array(personalRecordResource), - "PersonalRecordListEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, }; diff --git a/src/lib/personal-records/__tests__/record-benchmark.test.ts b/src/lib/personal-records/__tests__/record-benchmark.test.ts new file mode 100644 index 000000000..2b5058c09 --- /dev/null +++ b/src/lib/personal-records/__tests__/record-benchmark.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import { ageAt, resolveRecordBenchmark } from "../record-benchmark"; + +const DOB_1981 = new Date("1981-06-15T00:00:00.000Z"); +const at = (iso: string) => new Date(iso); + +const profile = { + dateOfBirth: DOB_1981, + sex: "MALE" as const, + heightCm: 180, + weightKg: 80, +}; + +function record( + metricType: Parameters[0]["metricType"], + unit: string, + achievedAt = "2026-07-01T08:00:00.000Z", + metricSlot: string | null = null, +) { + return { metricType, unit, metricSlot, achievedAt: at(achievedAt) }; +} + +describe("ageAt", () => { + it("counts whole years up to the day before the birthday", () => { + expect(ageAt(DOB_1981, at("2026-06-14T23:00:00.000Z"))).toBe(44); + expect(ageAt(DOB_1981, at("2026-06-15T00:00:00.000Z"))).toBe(45); + expect(ageAt(null, at("2026-06-15T00:00:00.000Z"))).toBeNull(); + }); +}); + +describe("resolveRecordBenchmark", () => { + it("gives a 45-year-old man's VO2 max record the FRIEND band in its unit", () => { + const band = resolveRecordBenchmark( + record("VO2_MAX", "mL/(kg·min)"), + profile, + ); + expect(band).not.toBeNull(); + expect(band!.unit).toBe("mL/(kg·min)"); + expect(band!.source).toBe("friend_2015"); + expect(band!.sourceLabel).toContain("Kaminsky"); + // Between the 40s (35–45) and 50s (31–41) bands. + expect(band!.low).toBeGreaterThanOrEqual(31); + expect(band!.low).toBeLessThanOrEqual(35); + expect(band!.high).toBeGreaterThanOrEqual(41); + expect(band!.high).toBeLessThanOrEqual(45); + }); + + it("uses the age the record was set at, not today's", () => { + const old = resolveRecordBenchmark( + record("VO2_MAX", "mL/(kg·min)", "2004-07-01T08:00:00.000Z"), + profile, + ); + // 23 then: below the 20s bracket centre, so the 20s male band itself. + expect(old).toMatchObject({ low: 42, high: 53 }); + }); + + it("returns null for a metric without a cited norm", () => { + expect( + resolveRecordBenchmark(record("ACTIVITY_STEPS", "steps"), profile), + ).toBeNull(); + }); + + it("returns null without a date of birth", () => { + expect( + resolveRecordBenchmark(record("VO2_MAX", "mL/(kg·min)"), { + ...profile, + dateOfBirth: null, + }), + ).toBeNull(); + }); + + it("returns null for a sex-split table without a sex", () => { + expect( + resolveRecordBenchmark(record("VO2_MAX", "mL/(kg·min)"), { + ...profile, + sex: undefined, + }), + ).toBeNull(); + }); + + it("returns null for a record stored in another unit, never converted on a guess", () => { + expect( + resolveRecordBenchmark(record("GRIP_STRENGTH", "lb"), profile), + ).toBeNull(); + }); + + it("accepts a spelling of the canonical unit", () => { + expect( + resolveRecordBenchmark(record("RESPIRATORY_RATE", "count/min"), profile), + ).toMatchObject({ unit: "count/min", source: "who_pals" }); + }); + + it("returns null for a per-sport slot", () => { + expect( + resolveRecordBenchmark( + record("WALKING_RUNNING_DISTANCE", "m", undefined, "running_5km_time"), + profile, + ), + ).toBeNull(); + }); + + it("places a six-minute walk between 80 % of the predicted distance and the prediction", () => { + const band = resolveRecordBenchmark( + record("SIX_MINUTE_WALK_DISTANCE", "m"), + profile, + ); + // Enright & Sherrill, man, 45 y, 180 cm, 80 kg. + const predicted = 7.57 * 180 - 5.02 * 45 - 1.76 * 80 - 309; + expect(band).toEqual({ + low: Math.round(predicted * 0.8), + high: Math.round(predicted), + unit: "m", + source: "enright_sherrill_1998", + sourceLabel: "Enright & Sherrill 1998", + }); + expect( + resolveRecordBenchmark(record("SIX_MINUTE_WALK_DISTANCE", "m"), { + ...profile, + weightKg: null, + }), + ).toBeNull(); + }); +}); diff --git a/src/lib/personal-records/record-benchmark.ts b/src/lib/personal-records/record-benchmark.ts new file mode 100644 index 000000000..6d1310240 --- /dev/null +++ b/src/lib/personal-records/record-benchmark.ts @@ -0,0 +1,166 @@ +/** + * The comparison band published beside a personal record (v1.43, ios #116 + * item 5). + * + * The native client used to place a record against hard-coded population + * values that knew neither the person's age nor their sex. The server owns the + * cited norms (`lookupNormalRange` in `src/lib/insights/derived/norms.ts`, the + * Enright & Sherrill equation for the six-minute walk), so it resolves the band + * here and the clients show it as delivered, or nothing when it is null. + * + * The band is for the age the person had when the record was set: a record + * from ten years ago is compared with what was typical then, not with today's + * bracket. Null whenever no honest band exists: a metric without a cited norm, + * no date of birth, a sex the table has no row for, a per-sport workout slot, + * or a record stored in a unit other than the norm's (the band is never + * converted on a guess). + * + * Pure: the caller reads the profile and the weight the walk equation needs. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { + lookupNormalRange, + predictSixMinuteWalkDistance, + type NormSex, +} from "@/lib/insights/derived/norms"; +import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; +import { getUnitForType } from "@/lib/measurements/unit-map"; + +export interface RecordBenchmark { + low: number; + high: number; + unit: string; + /** Stable identifier of the cited norm. */ + source: string; + /** The citation as a reader sees it. */ + sourceLabel: string; +} + +/** The profile facts the norms are keyed on. */ +export interface RecordBenchmarkProfile { + dateOfBirth: Date | null; + sex: NormSex; + heightCm: number | null; + /** Latest weight in kg, for the six-minute-walk equation only. */ + weightKg: number | null; +} + +interface NormCitation { + metricId: MetricStatusMetricId; + source: string; + sourceLabel: string; +} + +const CITED_NORMS: Partial> = { + VO2_MAX: { + metricId: "VO2_MAX", + source: "friend_2015", + sourceLabel: "FRIEND registry (Kaminsky et al. 2015)", + }, + RESTING_HEART_RATE: { + metricId: "RESTING_HEART_RATE", + source: "fleming_2011", + sourceLabel: "Fleming et al. 2011, AHA", + }, + RESPIRATORY_RATE: { + metricId: "RESPIRATORY_RATE", + source: "who_pals", + sourceLabel: "WHO / PALS reference", + }, + GRIP_STRENGTH: { + metricId: "GRIP_STRENGTH", + source: "dodds_2014", + sourceLabel: "Dodds et al. 2014, EWGSOP2", + }, +}; + +const SIX_MINUTE_WALK: { source: string; sourceLabel: string } = { + source: "enright_sherrill_1998", + sourceLabel: "Enright & Sherrill 1998", +}; + +/** + * The share of the predicted distance the six-minute-walk card already calls + * "green" (`six-minute-walk.ts`). The band runs from there to the predicted + * distance, so the record and the card speak about the same expectation. + */ +const SIX_MINUTE_WALK_LOW_SHARE = 0.8; + +/** Spellings of one canonical unit that writers have used. */ +const UNIT_ALIASES: Record = { + "breaths/min": ["count/min", "/min", "breaths/min", "brpm"], +}; + +function unitKey(unit: string): string { + return unit.toLowerCase().replace(/[^a-z0-9%]/g, ""); +} + +function sameUnit(type: MeasurementType, stored: string): boolean { + const canonical = getUnitForType(type); + const accepted = [canonical, ...(UNIT_ALIASES[canonical] ?? [])]; + return accepted.some((u) => unitKey(u) === unitKey(stored)); +} + +/** Whole years between a date of birth and an instant. */ +export function ageAt(dateOfBirth: Date | null, at: Date): number | null { + if (!dateOfBirth || Number.isNaN(dateOfBirth.getTime())) return null; + let age = at.getUTCFullYear() - dateOfBirth.getUTCFullYear(); + const beforeBirthday = + at.getUTCMonth() < dateOfBirth.getUTCMonth() || + (at.getUTCMonth() === dateOfBirth.getUTCMonth() && + at.getUTCDate() < dateOfBirth.getUTCDate()); + if (beforeBirthday) age -= 1; + return age >= 0 ? age : null; +} + +const round1 = (n: number) => Math.round(n * 10) / 10; + +export function resolveRecordBenchmark( + record: { + metricType: MeasurementType; + metricSlot: string | null; + unit: string; + achievedAt: Date; + }, + profile: RecordBenchmarkProfile, +): RecordBenchmark | null { + // A per-sport slot (a 5 km time) has no population norm here. + if (record.metricSlot !== null) return null; + if (!sameUnit(record.metricType, record.unit)) return null; + const age = ageAt(profile.dateOfBirth, record.achievedAt); + // Every band below is age-stratified; without an age none is honest. + if (age === null) return null; + + if (record.metricType === "SIX_MINUTE_WALK_DISTANCE") { + const predicted = predictSixMinuteWalkDistance( + age, + profile.heightCm, + profile.weightKg, + profile.sex, + ); + if (predicted === null) return null; + return { + low: Math.round(predicted * SIX_MINUTE_WALK_LOW_SHARE), + high: Math.round(predicted), + unit: record.unit, + ...SIX_MINUTE_WALK, + }; + } + + const citation = CITED_NORMS[record.metricType]; + if (!citation) return null; + const band = lookupNormalRange(citation.metricId, age, profile.sex); + if (!band) return null; + return { + low: round1(band.low), + high: round1(band.high), + unit: record.unit, + source: citation.source, + sourceLabel: citation.sourceLabel, + }; +} + +/** Whether a record of this type can carry a band at all (query planning). */ +export function mayCarryBenchmark(type: MeasurementType): boolean { + return type === "SIX_MINUTE_WALK_DISTANCE" || type in CITED_NORMS; +} From 79f7eb199b44550a218223e58107343ab8b67562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:39:11 +0200 Subject: [PATCH 3/5] feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server --- messages/de.json | 23 ++ messages/en.json | 23 ++ messages/es.json | 23 ++ messages/fr.json | 23 ++ messages/it.json | 23 ++ messages/ko.json | 23 ++ messages/pl.json | 23 ++ .../dashboard/summary/__tests__/route.test.ts | 12 + src/app/api/dashboard/summary/route.ts | 90 ++++- .../insights/targets/__tests__/route.test.ts | 72 ++++ .../series/__tests__/route.test.ts | 111 ++++++ src/app/api/measurements/series/route.ts | 69 +++- .../api/mood/insights/__tests__/route.test.ts | 33 ++ src/app/api/mood/insights/route.ts | 12 +- src/app/insights/mood/page.tsx | 11 +- src/app/page-client.tsx | 45 ++- .../insights/derived/vitals-dashboard.tsx | 26 +- .../mood/mood-insights-breakdowns.tsx | 11 + .../insights/mood/mood-insights-shared.tsx | 6 + .../insights/mood/mood-patterns.tsx | 111 ++++++ src/lib/analytics/effective-range.ts | 7 +- src/lib/dashboard/snapshot.ts | 21 ++ .../__tests__/mood-pattern-cards.test.ts | 232 ++++++++++++ src/lib/insights/derived/baseline.ts | 46 ++- src/lib/insights/mood-aggregates.ts | 51 ++- src/lib/insights/mood-pattern-cards.ts | 352 ++++++++++++++++++ .../__tests__/series-change.test.ts | 67 ++++ .../__tests__/series-reference-lines.test.ts | 117 ++++++ src/lib/measurements/series-change.ts | 88 +++++ .../measurements/series-reference-lines.ts | 178 +++++++++ src/lib/openapi/routes/insights/schemas.ts | 2 +- .../targets/__tests__/trend-verdict.test.ts | 204 ++++++++++ src/lib/targets/build-response.ts | 49 +++ src/lib/targets/trend-verdict.ts | 312 ++++++++++++++++ src/lib/targets/types.ts | 8 + 35 files changed, 2454 insertions(+), 50 deletions(-) create mode 100644 src/components/insights/mood/mood-patterns.tsx create mode 100644 src/lib/insights/__tests__/mood-pattern-cards.test.ts create mode 100644 src/lib/insights/mood-pattern-cards.ts create mode 100644 src/lib/measurements/__tests__/series-change.test.ts create mode 100644 src/lib/measurements/__tests__/series-reference-lines.test.ts create mode 100644 src/lib/measurements/series-change.ts create mode 100644 src/lib/measurements/series-reference-lines.ts create mode 100644 src/lib/targets/__tests__/trend-verdict.test.ts create mode 100644 src/lib/targets/trend-verdict.ts diff --git a/messages/de.json b/messages/de.json index 7136e471a..f30261f62 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4224,6 +4224,21 @@ "learning": "Lernphase: {entries} von {threshold} Tagen erfasst. Ein Vergleich erscheint, sobald genug davon zusammengekommen sind.", "noPattern": "In den bisher erfassten {entries} Tagen ist noch nichts gleichmäßig genug, um einen Tag damit zu vergleichen. Wenn du auch den Kontext eines Tages festhältst, wird mehr davon vergleichbar.", "featureValue": "{field}: {value}" + }, + "patterns": { + "title": "Muster in deiner Stimmung", + "basedOn": "Grundlage: {subject} und {comparison} Tage. Ein Muster, keine Ursache.", + "previousDayHigher": "Nach einem Tag unter deinem Üblichen ist der nächste Tag meist besser ({subject} gegenüber {comparison} nach einem Tag darüber).", + "previousDayLower": "Ein Tag unter deinem Üblichen wirkt meist in den nächsten Tag hinein ({subject} gegenüber {comparison} nach einem Tag darüber).", + "weekendHigher": "Am Wochenende ist deine Stimmung meist besser ({subject} gegenüber {comparison} unter der Woche).", + "weekendLower": "Am Wochenende ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} unter der Woche).", + "bestWeekday": "{weekday} ist meist dein bester Tag ({subject} gegenüber {comparison} an den übrigen Tagen).", + "notesHigher": "An Tagen mit Notiz ist deine Stimmung meist besser ({subject} gegenüber {comparison} ohne).", + "notesLower": "An Tagen mit Notiz ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} ohne).", + "delayedTagHigher": "Am Tag nach „{tag}“ ist deine Stimmung meist besser ({subject} gegenüber {comparison} sonst).", + "delayedTagLower": "Am Tag nach „{tag}“ ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} sonst).", + "multipleEntriesHigher": "An Tagen mit mehreren Einträgen ist deine Stimmung meist besser ({subject} gegenüber {comparison} an Tagen mit einem).", + "multipleEntriesLower": "An Tagen mit mehreren Einträgen ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} an Tagen mit einem)." } }, "derived": { @@ -12840,5 +12855,13 @@ "lastUpdated": "Zuletzt aktualisiert: {date}", "project": "Projekt", "credits": "Danksagungen" + }, + "series": { + "reference": { + "targetUpper": "Dein Ziel, Obergrenze", + "targetLower": "Dein Ziel, Untergrenze", + "clinicalUpper": "Klinische Obergrenze", + "clinicalLower": "Klinische Untergrenze" + } } } diff --git a/messages/en.json b/messages/en.json index 978148004..146ad753d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4224,6 +4224,21 @@ "learning": "Learning phase: {entries} of {threshold} days recorded. A comparison appears once there are enough of them.", "noPattern": "Nothing in the {entries} days recorded so far is steady enough to compare a day against. When you also capture a day's context, more of it becomes comparable.", "featureValue": "{field}: {value}" + }, + "patterns": { + "title": "Patterns in your mood", + "basedOn": "Based on {subject} and {comparison} days. A pattern, not a cause.", + "previousDayHigher": "After a below-usual day, the next day tends to be better ({subject} against {comparison} after an above-usual day).", + "previousDayLower": "A below-usual day tends to carry over into the next one ({subject} against {comparison} after an above-usual day).", + "weekendHigher": "Your mood tends to be higher at the weekend ({subject} against {comparison} on weekdays).", + "weekendLower": "Your mood tends to be lower at the weekend ({subject} against {comparison} on weekdays).", + "bestWeekday": "{weekday} tends to be your best day ({subject} against {comparison} on the other days).", + "notesHigher": "On days with a note your mood tends to be higher ({subject} against {comparison} without one).", + "notesLower": "On days with a note your mood tends to be lower ({subject} against {comparison} without one).", + "delayedTagHigher": "The day after “{tag}”, your mood tends to be higher ({subject} against {comparison} otherwise).", + "delayedTagLower": "The day after “{tag}”, your mood tends to be lower ({subject} against {comparison} otherwise).", + "multipleEntriesHigher": "On days you log more than once, your mood tends to be higher ({subject} against {comparison} on single-entry days).", + "multipleEntriesLower": "On days you log more than once, your mood tends to be lower ({subject} against {comparison} on single-entry days)." } }, "derived": { @@ -12840,5 +12855,13 @@ "lastUpdated": "Last updated: {date}", "project": "Project", "credits": "Credits" + }, + "series": { + "reference": { + "targetUpper": "Your target, upper limit", + "targetLower": "Your target, lower limit", + "clinicalUpper": "Clinical upper limit", + "clinicalLower": "Clinical lower limit" + } } } diff --git a/messages/es.json b/messages/es.json index 29ea0677d..8f07b72b5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4224,6 +4224,21 @@ "learning": "Fase de aprendizaje: {entries} de {threshold} días registrados. La comparación aparece cuando haya suficientes.", "noPattern": "En los {entries} días registrados hasta ahora no hay nada lo bastante constante para comparar un día. Cuando anotas también el contexto del día, se puede comparar una parte mayor.", "featureValue": "{field}: {value}" + }, + "patterns": { + "title": "Patrones en tu estado de ánimo", + "basedOn": "Basado en {subject} y {comparison} días. Un patrón, no una causa.", + "previousDayHigher": "Después de un día por debajo de lo habitual, el siguiente suele ser mejor ({subject} frente a {comparison} tras un día por encima).", + "previousDayLower": "Un día por debajo de lo habitual suele arrastrarse al siguiente ({subject} frente a {comparison} tras un día por encima).", + "weekendHigher": "Tu estado de ánimo suele ser mejor el fin de semana ({subject} frente a {comparison} entre semana).", + "weekendLower": "Tu estado de ánimo suele ser peor el fin de semana ({subject} frente a {comparison} entre semana).", + "bestWeekday": "{weekday} suele ser tu mejor día ({subject} frente a {comparison} el resto de días).", + "notesHigher": "Los días con una nota tu estado de ánimo suele ser mejor ({subject} frente a {comparison} sin nota).", + "notesLower": "Los días con una nota tu estado de ánimo suele ser peor ({subject} frente a {comparison} sin nota).", + "delayedTagHigher": "El día después de «{tag}», tu estado de ánimo suele ser mejor ({subject} frente a {comparison} en otros casos).", + "delayedTagLower": "El día después de «{tag}», tu estado de ánimo suele ser peor ({subject} frente a {comparison} en otros casos).", + "multipleEntriesHigher": "Los días que registras más de una vez, tu estado de ánimo suele ser mejor ({subject} frente a {comparison} con un solo registro).", + "multipleEntriesLower": "Los días que registras más de una vez, tu estado de ánimo suele ser peor ({subject} frente a {comparison} con un solo registro)." } }, "derived": { @@ -12840,5 +12855,13 @@ "lastUpdated": "Última actualización: {date}", "project": "Proyecto", "credits": "Créditos" + }, + "series": { + "reference": { + "targetUpper": "Tu objetivo, límite superior", + "targetLower": "Tu objetivo, límite inferior", + "clinicalUpper": "Límite clínico superior", + "clinicalLower": "Límite clínico inferior" + } } } diff --git a/messages/fr.json b/messages/fr.json index 7beb6c029..1eca293b5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4224,6 +4224,21 @@ "learning": "Phase d'apprentissage : {entries} journées sur {threshold} enregistrées. La comparaison apparaît une fois qu'il y en a assez.", "noPattern": "Dans les {entries} journées enregistrées jusqu'ici, rien n'est assez régulier pour comparer une journée. Quand tu notes aussi le contexte d'une journée, tu en rends davantage comparable.", "featureValue": "{field} : {value}" + }, + "patterns": { + "title": "Tendances de votre humeur", + "basedOn": "Sur la base de {subject} et {comparison} jours. Une tendance, pas une cause.", + "previousDayHigher": "Après une journée en dessous de l’habitude, le lendemain est souvent meilleur ({subject} contre {comparison} après une journée au-dessus).", + "previousDayLower": "Une journée en dessous de l’habitude a tendance à se prolonger le lendemain ({subject} contre {comparison} après une journée au-dessus).", + "weekendHigher": "Votre humeur a tendance à être meilleure le week-end ({subject} contre {comparison} en semaine).", + "weekendLower": "Votre humeur a tendance à être plus basse le week-end ({subject} contre {comparison} en semaine).", + "bestWeekday": "{weekday} est souvent votre meilleur jour ({subject} contre {comparison} les autres jours).", + "notesHigher": "Les jours avec une note, votre humeur a tendance à être meilleure ({subject} contre {comparison} sans note).", + "notesLower": "Les jours avec une note, votre humeur a tendance à être plus basse ({subject} contre {comparison} sans note).", + "delayedTagHigher": "Le lendemain de « {tag} », votre humeur a tendance à être meilleure ({subject} contre {comparison} sinon).", + "delayedTagLower": "Le lendemain de « {tag} », votre humeur a tendance à être plus basse ({subject} contre {comparison} sinon).", + "multipleEntriesHigher": "Les jours où vous notez plusieurs fois, votre humeur a tendance à être meilleure ({subject} contre {comparison} avec une seule saisie).", + "multipleEntriesLower": "Les jours où vous notez plusieurs fois, votre humeur a tendance à être plus basse ({subject} contre {comparison} avec une seule saisie)." } }, "derived": { @@ -12840,5 +12855,13 @@ "lastUpdated": "Dernière mise à jour : {date}", "project": "Projet", "credits": "Crédits" + }, + "series": { + "reference": { + "targetUpper": "Votre objectif, limite haute", + "targetLower": "Votre objectif, limite basse", + "clinicalUpper": "Limite clinique haute", + "clinicalLower": "Limite clinique basse" + } } } diff --git a/messages/it.json b/messages/it.json index cd9ba3c3b..9fc11b0b5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4224,6 +4224,21 @@ "learning": "Fase di apprendimento: {entries} giornate su {threshold} registrate. Il confronto compare quando ce ne sono abbastanza.", "noPattern": "Nelle {entries} giornate registrate finora non c'è nulla di abbastanza costante per confrontare una giornata. Quando registri anche il contesto della giornata, ne rendi confrontabile una parte maggiore.", "featureValue": "{field}: {value}" + }, + "patterns": { + "title": "Schemi nel tuo umore", + "basedOn": "Basato su {subject} e {comparison} giorni. Uno schema, non una causa.", + "previousDayHigher": "Dopo un giorno sotto la tua media, il giorno seguente tende a essere migliore ({subject} contro {comparison} dopo un giorno sopra).", + "previousDayLower": "Un giorno sotto la tua media tende a trascinarsi in quello seguente ({subject} contro {comparison} dopo un giorno sopra).", + "weekendHigher": "Il tuo umore tende a essere migliore nel fine settimana ({subject} contro {comparison} nei giorni feriali).", + "weekendLower": "Il tuo umore tende a essere peggiore nel fine settimana ({subject} contro {comparison} nei giorni feriali).", + "bestWeekday": "{weekday} tende a essere il tuo giorno migliore ({subject} contro {comparison} negli altri giorni).", + "notesHigher": "Nei giorni con una nota il tuo umore tende a essere migliore ({subject} contro {comparison} senza).", + "notesLower": "Nei giorni con una nota il tuo umore tende a essere peggiore ({subject} contro {comparison} senza).", + "delayedTagHigher": "Il giorno dopo «{tag}», il tuo umore tende a essere migliore ({subject} contro {comparison} negli altri casi).", + "delayedTagLower": "Il giorno dopo «{tag}», il tuo umore tende a essere peggiore ({subject} contro {comparison} negli altri casi).", + "multipleEntriesHigher": "Nei giorni in cui registri più volte, il tuo umore tende a essere migliore ({subject} contro {comparison} con una sola voce).", + "multipleEntriesLower": "Nei giorni in cui registri più volte, il tuo umore tende a essere peggiore ({subject} contro {comparison} con una sola voce)." } }, "derived": { @@ -12840,5 +12855,13 @@ "lastUpdated": "Ultimo aggiornamento: {date}", "project": "Progetto", "credits": "Riconoscimenti" + }, + "series": { + "reference": { + "targetUpper": "Il tuo obiettivo, limite superiore", + "targetLower": "Il tuo obiettivo, limite inferiore", + "clinicalUpper": "Limite clinico superiore", + "clinicalLower": "Limite clinico inferiore" + } } } diff --git a/messages/ko.json b/messages/ko.json index 223c4ff1a..09171a7a7 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4224,6 +4224,21 @@ "learning": "학습 중이에요: {threshold}일 중 {entries}일 기록했어요. 충분히 모이면 비교가 나타나요.", "noPattern": "지금까지 기록한 {entries}일 중에는 하루를 견줄 만큼 일정한 패턴이 없어요. 하루의 맥락까지 함께 남기면 비교할 수 있는 게 늘어나요.", "featureValue": "{field}: {value}" + }, + "patterns": { + "title": "기분 패턴", + "basedOn": "{subject}일과 {comparison}일 기준. 원인이 아닌 패턴이에요.", + "previousDayHigher": "평소보다 낮은 날 다음 날은 대체로 더 나아요 (평소보다 높은 날 다음의 {comparison} 대비 {subject}).", + "previousDayLower": "평소보다 낮은 날은 다음 날까지 이어지는 경향이 있어요 (평소보다 높은 날 다음의 {comparison} 대비 {subject}).", + "weekendHigher": "주말에는 기분이 대체로 더 좋아요 (평일 {comparison} 대비 {subject}).", + "weekendLower": "주말에는 기분이 대체로 더 낮아요 (평일 {comparison} 대비 {subject}).", + "bestWeekday": "{weekday}이 대체로 가장 좋은 날이에요 (다른 날 {comparison} 대비 {subject}).", + "notesHigher": "메모를 남긴 날에는 기분이 대체로 더 좋아요 (메모 없는 날 {comparison} 대비 {subject}).", + "notesLower": "메모를 남긴 날에는 기분이 대체로 더 낮아요 (메모 없는 날 {comparison} 대비 {subject}).", + "delayedTagHigher": "'{tag}' 다음 날에는 기분이 대체로 더 좋아요 (그 외 {comparison} 대비 {subject}).", + "delayedTagLower": "'{tag}' 다음 날에는 기분이 대체로 더 낮아요 (그 외 {comparison} 대비 {subject}).", + "multipleEntriesHigher": "하루에 여러 번 기록한 날에는 기분이 대체로 더 좋아요 (한 번 기록한 날 {comparison} 대비 {subject}).", + "multipleEntriesLower": "하루에 여러 번 기록한 날에는 기분이 대체로 더 낮아요 (한 번 기록한 날 {comparison} 대비 {subject})." } }, "derived": { @@ -12840,5 +12855,13 @@ "lastUpdated": "마지막 업데이트: {date}", "project": "프로젝트", "credits": "크레딧" + }, + "series": { + "reference": { + "targetUpper": "내 목표 상한", + "targetLower": "내 목표 하한", + "clinicalUpper": "임상 기준 상한", + "clinicalLower": "임상 기준 하한" + } } } diff --git a/messages/pl.json b/messages/pl.json index ae3cf4116..cb5a9a4b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4224,6 +4224,21 @@ "learning": "Faza uczenia: zapisano {entries} z {threshold} dni. Porównanie pojawi się, gdy będzie ich dość.", "noPattern": "W zapisanych dotąd {entries} dniach nic nie jest na tyle regularne, by porównać z nimi dzień. Gdy zapisujesz też kontekst dnia, więcej da się porównać.", "featureValue": "{field}: {value}" + }, + "patterns": { + "title": "Wzorce w twoim nastroju", + "basedOn": "Na podstawie {subject} i {comparison} dni. Wzorzec, nie przyczyna.", + "previousDayHigher": "Po dniu poniżej twojej normy następny dzień jest zwykle lepszy ({subject} wobec {comparison} po dniu powyżej normy).", + "previousDayLower": "Dzień poniżej twojej normy zwykle przenosi się na następny ({subject} wobec {comparison} po dniu powyżej normy).", + "weekendHigher": "W weekendy twój nastrój jest zwykle lepszy ({subject} wobec {comparison} w dni robocze).", + "weekendLower": "W weekendy twój nastrój jest zwykle gorszy ({subject} wobec {comparison} w dni robocze).", + "bestWeekday": "{weekday} to zwykle twój najlepszy dzień ({subject} wobec {comparison} w pozostałe dni).", + "notesHigher": "W dni z notatką twój nastrój jest zwykle lepszy ({subject} wobec {comparison} bez notatki).", + "notesLower": "W dni z notatką twój nastrój jest zwykle gorszy ({subject} wobec {comparison} bez notatki).", + "delayedTagHigher": "Dzień po „{tag}” twój nastrój jest zwykle lepszy ({subject} wobec {comparison} w pozostałe dni).", + "delayedTagLower": "Dzień po „{tag}” twój nastrój jest zwykle gorszy ({subject} wobec {comparison} w pozostałe dni).", + "multipleEntriesHigher": "W dni z kilkoma wpisami twój nastrój jest zwykle lepszy ({subject} wobec {comparison} przy jednym wpisie).", + "multipleEntriesLower": "W dni z kilkoma wpisami twój nastrój jest zwykle gorszy ({subject} wobec {comparison} przy jednym wpisie)." } }, "derived": { @@ -12840,5 +12855,13 @@ "lastUpdated": "Ostatnia aktualizacja: {date}", "project": "Projekt", "credits": "Podziękowania" + }, + "series": { + "reference": { + "targetUpper": "Twój cel, górna granica", + "targetLower": "Twój cel, dolna granica", + "clinicalUpper": "Kliniczna górna granica", + "clinicalLower": "Kliniczna dolna granica" + } } } diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index e61286e5b..df46851ab 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -367,6 +367,18 @@ describe("GET /api/dashboard/summary", () => { expect(bp?.allTimeCount).toBe(28); expect(bp?.lastSeenAt).toBe(sixtyDaysAgo.toISOString()); expect(bp?.sparkline).toEqual([130, 132]); + // v1.43 (ios #116 item 3) — a 40-year-old's age band is 120–129: the + // sparkline's mean sits above it, so the rise is a setback. + const judged = bp as unknown as { + direction: unknown; + trendSentiment: string; + }; + expect(judged.direction).toEqual({ + direction: "up-bad", + targetPosition: "above", + basis: "profile_default", + }); + expect(judged.trendSentiment).toBe("negative"); }); it("does NOT emit the BP tile when the account has zero readings ever (REG-11)", async () => { diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 26d76552f..5a0a78d98 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -61,6 +61,13 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { + resolveTrendVerdict, + trendSentimentOfTrend, + type TrendSentiment, + type TrendVerdict, + type TrendVerdictContext, +} from "@/lib/targets/trend-verdict"; import { type Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; @@ -185,6 +192,69 @@ interface MetricCard { * at all. */ lastSeenAt: string | null; + /** + * v1.43 (ios #116 item 3) — which way a change of this metric counts for + * the person, against their own target or a profile default + * (`src/lib/targets/trend-verdict.ts`). + */ + direction?: TrendVerdict | null; + /** v1.43 — `direction` applied to this card's `trend`. */ + trendSentiment?: TrendSentiment; +} + +/** + * The verdict key behind each card: the measurement type it reads, or the + * derived key for the two cards that are not one. + */ +const VERDICT_KEY: Record = { + weight: "WEIGHT", + bloodPressure: "BLOOD_PRESSURE_SYS", + pulse: "PULSE", + bodyFat: "BODY_FAT", + glucose: "BLOOD_GLUCOSE", + sleep: "SLEEP_DURATION", + steps: "ACTIVITY_STEPS", + totalBodyWater: "TOTAL_BODY_WATER", + boneMass: "BONE_MASS", + oxygenSaturation: "OXYGEN_SATURATION", + mood: "MOOD", + bmi: "BMI", +}; + +/** + * Lay the verdict over each card. The reference placed against the band is + * the mean of the card's own seven-day sparkline, else its latest value, the + * seven-day average the web tiles place. Sleep cards are in hours and the + * verdict reads minutes. + */ +function withVerdicts( + cards: MetricCard[], + ctx: Omit, +): MetricCard[] { + const referenceOf = (card: MetricCard | undefined): number | null => { + if (!card) return null; + const spark = card.sparkline.filter((v) => Number.isFinite(v)); + return spark.length > 0 + ? spark.reduce((sum, v) => sum + v, 0) / spark.length + : card.latestValue; + }; + // BMI is judged by the weight it is derived from (the target is a weight). + const weightReference = referenceOf(cards.find((c) => c.kind === "weight")); + return cards.map((card) => { + const reference = card.kind === "bmi" ? weightReference : referenceOf(card); + const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], { + ...ctx, + referenceValue: + reference !== null && card.kind === "sleep" + ? reference * 60 + : reference, + }); + return { + ...card, + direction, + trendSentiment: trendSentimentOfTrend(card.trend, direction), + }; + }); } /** @@ -945,6 +1015,17 @@ async function buildDashboardSummary( const t = getServerTranslator(ctx.locale).t; + // v1.43 — the record's profile and own targets, for the card verdicts. + const verdictProfile = await prisma.user.findUnique({ + where: { id: userId }, + select: { + dateOfBirth: true, + gender: true, + heightCm: true, + thresholdsJson: true, + }, + }); + annotate({ meta: timings }); return { @@ -962,7 +1043,14 @@ async function buildDashboardSummary( takenToday, }, highlightInsight: null, - metrics, + metrics: withVerdicts(metrics, { + profile: { + dateOfBirth: verdictProfile?.dateOfBirth ?? null, + gender: verdictProfile?.gender ?? null, + heightCm: verdictProfile?.heightCm ?? null, + }, + thresholdsJson: verdictProfile?.thresholdsJson ?? null, + }), // v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values // the Sleep page's `/api/sleep/rhythm` read returns. sleepRhythm, diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index 3fe45c806..ed3041d2a 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -533,6 +533,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--success)", }, source: "WHO BMI", + direction: { + direction: "up-bad", + targetPosition: null, + basis: "metric_default", + }, + trendSentiment: "neutral", ...fourDaysInRange, }, { @@ -548,6 +554,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--info)", }, source: "ESH 2023", + direction: { + direction: "hold", + targetPosition: "inside", + basis: "profile_default", + }, + trendSentiment: "positive", ...fourDaysInRange, }, { @@ -563,6 +575,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--success)", }, source: "ESH 2023", + direction: { + direction: "up-good", + targetPosition: null, + basis: "metric_default", + }, + trendSentiment: "neutral", ...fourDaysInRange, }, { @@ -578,6 +596,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--warning)", }, source: "CDC/NCHS", + direction: { + direction: "up-good", + targetPosition: "below", + basis: "profile_default", + }, + trendSentiment: "neutral", daysInRange7d: 0, daysLogged7d: 4, daysInRange30d: 0, @@ -600,6 +624,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--success)", }, source: "AASM/SRS", + direction: { + direction: "up-good", + targetPosition: null, + basis: "metric_default", + }, + trendSentiment: "neutral", ...fourDaysInRange, }, { @@ -615,6 +645,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--success)", }, source: "WHO", + direction: { + direction: "up-bad", + targetPosition: null, + basis: "metric_default", + }, + trendSentiment: "neutral", ...fourDaysInRange, }, { @@ -630,6 +666,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--warning)", }, source: "ACE", + direction: { + direction: "hold", + targetPosition: "inside", + basis: "profile_default", + }, + trendSentiment: "positive", ...fourDaysInRange, }, { @@ -645,6 +687,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--success)", }, source: "Saint-Maurice JAMA 2020", + direction: { + direction: "up-good", + targetPosition: null, + basis: "metric_default", + }, + trendSentiment: "neutral", ...fourDaysInRange, }, { @@ -657,6 +705,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { range: { min: 90, max: 100 }, classification: null, source: "7-day", + direction: { + direction: "up-good", + targetPosition: null, + basis: "metric_default", + }, + trendSentiment: "neutral", details: { medications: [ { @@ -688,6 +742,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--success)", }, source: "Mood entries", + direction: { + direction: "up-good", + targetPosition: null, + basis: "metric_default", + }, + trendSentiment: "neutral", ...fiveDaysInRange, }, { @@ -703,6 +763,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--success)", }, source: "Mood entries", + direction: { + direction: "neutral", + targetPosition: null, + basis: "metric_default", + }, + trendSentiment: "neutral", ...fiveDaysInRange, }, { @@ -718,6 +784,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { color: "var(--success)", }, source: "ADA 2024 / DDG", + direction: { + direction: "neutral", + targetPosition: null, + basis: "metric_default", + }, + trendSentiment: "neutral", daysInRange7d: 3, daysLogged7d: 3, daysInRange30d: 3, diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 2d82924ff..1d8be88a7 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -536,6 +536,117 @@ describe("GET /api/measurements/series", () => { ); }); +describe("GET /api/measurements/series — resolved judgements (v1.43, ios #116)", () => { + function bpDays(values: Array<[string, number]>) { + vi.mocked(prisma.measurement.findMany).mockImplementation((( + args: unknown, + ) => { + const a = args as { where: { type: string } }; + if (a.where.type !== "BLOOD_PRESSURE_SYS") return Promise.resolve([]); + return Promise.resolve( + values.map(([day, value], i) => ({ + id: `s${i}`, + value, + measuredAt: new Date(`${day}T08:00:00.000Z`), + })), + ); + }) as never); + } + + it("judges the window's change against the person's own target and draws its lines", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mg/dL", + dateOfBirth: null, + gender: null, + heightCm: null, + locale: "en", + thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } }, + } as never); + bpDays([ + ["2026-05-01", 128], + ["2026-05-02", 130], + ["2026-05-03", 134], + ]); + const res = await GET(req("kind=bloodPressure&days=30")); + const body = (await res.json()) as { + data: { + direction: unknown; + change: unknown; + referenceLines: Array>; + }; + }; + expect(body.data.direction).toEqual({ + direction: "up-bad", + targetPosition: "above", + basis: "personal_target", + }); + expect(body.data.change).toEqual({ + from: 128, + to: 134, + delta: 6, + sentiment: "negative", + }); + expect( + body.data.referenceLines.map((l) => [ + l.appliesTo, + l.kind, + l.value, + l.source, + ]), + ).toEqual([ + ["value", "lower", 115, "personal"], + ["value", "upper", 125, "personal"], + ["secondary", "upper", 90, "clinical"], + ]); + expect(body.data.referenceLines[0].label).toBe("Your target, lower limit"); + }); + + it("calls the same rise progress for someone below their target", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mg/dL", + dateOfBirth: null, + gender: null, + heightCm: null, + locale: "en", + thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } }, + } as never); + bpDays([ + ["2026-05-01", 100], + ["2026-05-03", 106], + ]); + const res = await GET(req("kind=bloodPressure&days=30")); + const body = (await res.json()) as { + data: { change: { sentiment: string } }; + }; + expect(body.data.change.sentiment).toBe("positive"); + }); + + it("has no change under two days and the clinical glucose lines in mmol/L", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mmol/L", + locale: "de", + thresholdsJson: null, + } as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { id: "g1", value: 100, measuredAt: new Date("2026-05-01T08:00:00Z") }, + ] as never); + const res = await GET(req("kind=glucose&days=30")); + const body = (await res.json()) as { + data: { + change: unknown; + direction: { direction: string }; + referenceLines: Array<{ value: number; label: string }>; + }; + }; + expect(body.data.change).toBeNull(); + expect(body.data.referenceLines.map((l) => l.value)).toEqual([3.9, 10]); + expect(body.data.referenceLines[0].label).toBe("Klinische Untergrenze"); + }); +}); + describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)", () => { it("day-buckets glucose in SQL beyond the 90-day raw window", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index a60dd7992..72901ddb9 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -7,6 +7,12 @@ * * The `kind=bloodPressure` case pairs systolic + diastolic by * `measuredAt` so iOS can render the dual-line chart with one fetch. + * + * v1.43 (ios #116 items 3 and 4) — the answer also carries what a client + * would otherwise decide itself: `direction` (which way a change of this + * metric counts for this person), `change` (first and last day value of the + * window, judged by that direction) and `referenceLines` (the person's own + * target lines, else the clinical ones, in the unit of the points). */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -37,6 +43,15 @@ import { seriesRowsFrom } from "@/lib/measurements/series-canonical"; import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql"; import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; +import { resolveTrendVerdict } from "@/lib/targets/trend-verdict"; +import { + computeSeriesChange, + seriesDayValues, + seriesReferenceValue, +} from "@/lib/measurements/series-change"; +import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; /** * v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its @@ -199,7 +214,7 @@ function stdDev(values: number[]): number { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read", "measurements"); + const { user, actor } = await requireRecordAuth("read", "measurements"); const rawQuery = Object.fromEntries(request.nextUrl.searchParams); const parsed = querySchema.safeParse(rawQuery); @@ -814,6 +829,55 @@ export const GET = apiHandler(async (request: NextRequest) => { const summary = dataPoints.length > 0 ? summarize(dataPoints) : null; const values = dataPoints.map((p) => p.value); + // v1.43 — the resolved judgements. The profile and targets are the + // RECORD's (the chart is theirs); the labels are in the language of the + // account reading it. + const [record, reader, seriesTz] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { + dateOfBirth: true, + gender: true, + heightCm: true, + thresholdsJson: true, + glucoseUnit: true, + locale: true, + }, + }), + actor.id === user.id + ? null + : prisma.user.findUnique({ + where: { id: actor.id }, + select: { locale: true }, + }), + resolveUserTimezone(user.id), + ]); + const rangeProfile = { + dateOfBirth: record?.dateOfBirth ?? null, + gender: record?.gender ?? null, + heightCm: record?.heightCm ?? null, + }; + const verdictType = KIND_TO_TYPE[kind]; + const dayValues = seriesDayValues(verdictType, points, seriesTz); + const reference = seriesReferenceValue(dayValues); + const direction = resolveTrendVerdict(verdictType, { + profile: rangeProfile, + thresholdsJson: record?.thresholdsJson ?? null, + // The verdict reads stored units: sleep is served in hours here and + // stored in minutes. Glucose has no band of its own on this chart. + referenceValue: + reference !== null && kind === "sleep" ? reference * 60 : reference, + }); + const translator = getServerTranslator( + await resolveJobLocale((reader ?? record)?.locale ?? null), + ); + const referenceLines = resolveSeriesReferenceLines(kind, { + profile: rangeProfile, + thresholdsJson: record?.thresholdsJson ?? null, + glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null), + t: (key) => translator.t(key), + }); + annotate({ action: { name: "measurements.series" }, meta: { kind, days, count: points.length }, @@ -823,6 +887,9 @@ export const GET = apiHandler(async (request: NextRequest) => { kind, unit, points, + direction, + change: computeSeriesChange(dayValues, direction), + referenceLines, stats: statsOverride ?? (summary diff --git a/src/app/api/mood/insights/__tests__/route.test.ts b/src/app/api/mood/insights/__tests__/route.test.ts index 55836ce48..a00ef9dbf 100644 --- a/src/app/api/mood/insights/__tests__/route.test.ts +++ b/src/app/api/mood/insights/__tests__/route.test.ts @@ -41,6 +41,22 @@ const AGGREGATE = { tags: [], stability: year, stabilityByWindow: { 30: month, 90: null, 180: year, 365: year }, + patternsByWindow: { + 30: [], + 90: [], + 180: [], + 365: [ + { + id: "weekend", + kind: "weekend", + strength: "strong", + direction: "higher", + values: { subject: 4.2, comparison: 3.1, delta: 1.1 }, + sampleSize: { subject: 100, comparison: 250 }, + qValue: 0.001, + }, + ], + }, }; const call = (query = "") => @@ -78,6 +94,23 @@ describe("GET /api/mood/insights — stability per period", () => { expect(body.data.stabilityWindowDays).toBe(90); }); + it("serves the patterns of the same window, without the cache's internals", async () => { + const year = await (await call()).json(); + expect(year.data.patterns).toEqual([ + { + id: "weekend", + kind: "weekend", + strength: "strong", + direction: "higher", + values: { subject: 4.2, comparison: 3.1, delta: 1.1 }, + sampleSize: { subject: 100, comparison: 250 }, + }, + ]); + expect(year.data).not.toHaveProperty("patternsByWindow"); + const month = await (await call("?days=30")).json(); + expect(month.data.patterns).toEqual([]); + }); + it("refuses a window it does not offer", async () => { const res = await call("?days=45"); expect(res.status).toBe(422); diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts index d856af2fd..c44d885c1 100644 --- a/src/app/api/mood/insights/route.ts +++ b/src/app/api/mood/insights/route.ts @@ -12,6 +12,8 @@ import { type MoodStabilityWindow, } from "@/lib/insights/mood-aggregates"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; export const dynamic = "force-dynamic"; @@ -33,7 +35,8 @@ export const dynamic = "force-dynamic"; */ /** * `?days=N` cuts `stability` to the trailing N days, so a period picker shows - * the steadiness of the period it shows. Without it the score covers the + * the steadiness of the period it shows. Since v1.43 it cuts `patterns` the + * same way. Without it the score covers the * whole year, as it always has; `stabilityWindowDays` names the window the * served score covers either way. */ @@ -75,5 +78,10 @@ export const GET = apiHandler(async (request: Request) => { }, }); - return apiSuccess(moodInsightsWire(result, stabilityWindowDays)); + // v1.43 — `patterns` follows the same `?days` window, and a catalogue + // tag in a pattern is named in the reader's language. + const translator = getServerTranslator(await resolveJobLocale(user.locale)); + return apiSuccess( + moodInsightsWire(result, stabilityWindowDays, (key) => translator.t(key)), + ); }); diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index b4b544dde..3438c7d53 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -4,6 +4,8 @@ import { QueryClient, } from "@tanstack/react-query"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { getUnswitchedSession } from "@/lib/auth/acting-carrier"; import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; import { @@ -91,10 +93,17 @@ export default async function InsightsMoodPage() { ), ]); const queryClient = new QueryClient(); + const translator = getServerTranslator( + await resolveJobLocale(user.locale), + ); if (aggregates) { queryClient.setQueryData( queryKeys.moodInsights(), - JSON.parse(JSON.stringify(moodInsightsWire(aggregates, 365))), + JSON.parse( + JSON.stringify( + moodInsightsWire(aggregates, 365, (key) => translator.t(key)), + ), + ), ); } if (series) { diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 023839f2d..1e32be413 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -169,6 +169,7 @@ import { resolveWeightTrend, weightTrendReferenceKg, } from "@/lib/targets/weight-trend"; +import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; import { toProfileSex } from "@/lib/profile/sex"; import { apiGet } from "@/lib/api/api-fetch"; @@ -785,6 +786,15 @@ export default function DashboardPageClient({ clientBandProfile.weightTargetOverride, weightTrendReferenceKg(w), ); + // v1.43 — every other tile's direction comes resolved from the server + // (`tiles.directions`, against the record's own targets and profile). The + // snapshot-disabled path has no resolved answer and renders neutral rather + // than guessing a polarity. + const tileDirections = snapshotEnabled + ? snapshotQuery.data?.tiles.directions + : undefined; + const directionOf = (key: string): TrendDirectionSentiment => + tileDirections?.[key]?.direction ?? "neutral"; const bpTargets = bands.bpTargets; const weightRange = bands.weightRange; // The range hint tooltip prints its band bounds in the display unit, so scale @@ -1093,7 +1103,7 @@ export default function DashboardPageClient({ slope30={sys?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(sys)} icon={Heart} - directionSentiment="up-bad" + directionSentiment={directionOf("BLOOD_PRESSURE_SYS")} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(sys)} staleDays={tileStaleDays("BLOOD_PRESSURE_SYS")} @@ -1135,7 +1145,7 @@ export default function DashboardPageClient({ slope30={dia?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(dia)} icon={Heart} - directionSentiment="up-bad" + directionSentiment={directionOf("BLOOD_PRESSURE_DIA")} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(dia)} staleDays={tileStaleDays("BLOOD_PRESSURE_DIA")} @@ -1177,6 +1187,9 @@ export default function DashboardPageClient({ slope30={pulseTileSummary?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(pulseTileSummary)} icon={TrendingUp} + directionSentiment={directionOf( + hasRestingHr ? "RESTING_HEART_RATE" : "PULSE", + )} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(pulseTileSummary)} staleDays={tileStaleDays( @@ -1201,7 +1214,7 @@ export default function DashboardPageClient({ slope30={bf?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(bf)} icon={Percent} - directionSentiment="up-bad" + directionSentiment={directionOf("BODY_FAT")} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(bf)} staleDays={tileStaleDays("BODY_FAT")} @@ -1224,7 +1237,7 @@ export default function DashboardPageClient({ slope30={moodSummary?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(moodSummary)} icon={Smile} - directionSentiment="up-good" + directionSentiment={directionOf("MOOD")} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(moodSummary)} /> @@ -1246,7 +1259,7 @@ export default function DashboardPageClient({ slope30={sleepSummaryHours?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(sleepSummaryHours)} icon={Moon} - directionSentiment="up-good" + directionSentiment={directionOf("SLEEP_DURATION")} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(sleepSummaryHours)} staleDays={tileStaleDays("SLEEP_DURATION")} @@ -1282,7 +1295,7 @@ export default function DashboardPageClient({ slope30={stepsSummary?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(stepsSummary)} icon={Footprints} - directionSentiment="up-good" + directionSentiment={directionOf("ACTIVITY_STEPS")} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(stepsSummary)} staleDays={tileStaleDays("ACTIVITY_STEPS")} @@ -1312,7 +1325,7 @@ export default function DashboardPageClient({ slope30={vo2Summary?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(vo2Summary)} icon={Gauge} - directionSentiment="up-good" + directionSentiment={directionOf("VO2_MAX")} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(vo2Summary)} staleDays={tileStaleDays("VO2_MAX")} @@ -1354,7 +1367,7 @@ export default function DashboardPageClient({ slope30={hrvSummary?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(hrvSummary)} icon={HeartPulse} - directionSentiment="up-good" + directionSentiment={directionOf(hrvType)} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(hrvSummary)} // The type that actually backs the tile — reading freshness @@ -1380,7 +1393,7 @@ export default function DashboardPageClient({ slope30={spo2Summary?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(spo2Summary)} icon={Droplets} - directionSentiment="up-good" + directionSentiment={directionOf("OXYGEN_SATURATION")} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(spo2Summary)} staleDays={tileStaleDays("OXYGEN_SATURATION")} @@ -1403,7 +1416,7 @@ export default function DashboardPageClient({ slope30={respRateSummary?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(respRateSummary)} icon={Wind} - directionSentiment="neutral" + directionSentiment={directionOf("RESPIRATORY_RATE")} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(respRateSummary)} staleDays={tileStaleDays("RESPIRATORY_RATE")} @@ -1429,7 +1442,7 @@ export default function DashboardPageClient({ summaryToTrend7Delta(wristTempSummary), )} icon={Thermometer} - directionSentiment="neutral" + directionSentiment={directionOf("WRIST_TEMPERATURE")} compareBaseline={compareBaseline} compareDelta={tdd( "WRIST_TEMPERATURE", @@ -1461,7 +1474,7 @@ export default function DashboardPageClient({ summaryToTrend7Delta(muscleMassSummary), )} icon={Dumbbell} - directionSentiment="up-good" + directionSentiment={directionOf("MUSCLE_MASS")} compareBaseline={compareBaseline} compareDelta={tdd( "MUSCLE_MASS", @@ -1490,7 +1503,7 @@ export default function DashboardPageClient({ summaryToTrend7Delta(bodyWaterSummary), )} icon={Droplet} - directionSentiment="neutral" + directionSentiment={directionOf("TOTAL_BODY_WATER")} compareBaseline={compareBaseline} compareDelta={tdd( "TOTAL_BODY_WATER", @@ -1519,7 +1532,7 @@ export default function DashboardPageClient({ summaryToTrend7Delta(boneMassSummary), )} icon={Bone} - directionSentiment="neutral" + directionSentiment={directionOf("BONE_MASS")} compareBaseline={compareBaseline} compareDelta={tdd( "BONE_MASS", @@ -1548,7 +1561,7 @@ export default function DashboardPageClient({ slope30={waterIntakeSummary?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(waterIntakeSummary)} icon={GlassWater} - directionSentiment="neutral" + directionSentiment={directionOf("NUTRIENT_WATER")} compareBaseline={compareBaseline} compareDelta={tileCompareDelta(waterIntakeSummary)} staleDays={tileStaleDays("NUTRIENT_WATER")} @@ -1639,7 +1652,7 @@ export default function DashboardPageClient({ slope30={null} trend7Delta={bpSufficient ? bpTrendDelta : null} icon={Target} - directionSentiment="up-good" + directionSentiment={directionOf("BLOOD_PRESSURE_IN_TARGET")} compareBaseline={compareBaseline} compareDelta={bpSufficient ? bpCompareDelta : null} /> diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 3a1926eac..1201bb12f 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -28,7 +28,6 @@ import { } from "./use-dashboard-derived"; import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry"; import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout"; -import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; // Type-only — the compute payloads never drag the server graph into the bundle. import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline"; import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age"; @@ -64,26 +63,6 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types"; // moved to `use-dashboard-derived.ts` so the page can own the one shared batch // and feed it to both the wellness strip and this grid. Imported above. -/** - * Up-is-bad for the vitals where a rise is unfavourable. Weight is not one of - * them: which way it should move depends on the person's own target (#1006, - * `src/lib/targets/weight-trend.ts`), and this grid has no target in hand, so - * it stays neutral rather than assuming a fall is good. - */ -const UP_BAD_VITALS = new Set([ - "RESTING_HEART_RATE", - "BODY_TEMPERATURE", - "BLOOD_GLUCOSE", -]); -/** Up-is-good for the vitals where a rise is favourable. */ -const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]); - -function vitalSentiment(type: string): TrendDirectionSentiment { - if (UP_BAD_VITALS.has(type)) return "up-bad"; - if (UP_GOOD_VITALS.has(type)) return "up-good"; - return "neutral"; -} - interface DashboardProps { /** * The shared overview derived batch (owned by the page so the wellness @@ -306,7 +285,10 @@ function BaselineTile({ icon={Icon} series={displaySeries} framing={framing} - directionSentiment={vitalSentiment(type)} + // v1.43 — resolved on the server against the person's own target + // or profile default; an older server sends none and the tile + // stays neutral rather than assuming a polarity. + directionSentiment={v.direction?.direction ?? "neutral"} provenance={ // v1.29.1 muted this on VITALS_BASELINE tiles because the always-on // "typical range = median ± MAD" caption repeated across every vital diff --git a/src/components/insights/mood/mood-insights-breakdowns.tsx b/src/components/insights/mood/mood-insights-breakdowns.tsx index 4c304be52..29bf0eb16 100644 --- a/src/components/insights/mood/mood-insights-breakdowns.tsx +++ b/src/components/insights/mood/mood-insights-breakdowns.tsx @@ -30,6 +30,7 @@ import { Gauge, Grid3x3, Link2, + Repeat, Tag, Tags, TrendingUp, @@ -110,6 +111,7 @@ import { MoodStabilityTile } from "./mood-stability-tile"; import { MoodTagInfluence } from "./mood-tag-influence"; import { MoodTagMetricCrosstab } from "./mood-tag-metric-crosstab"; import { MoodFactorMetricCrosstab } from "./mood-factor-metric-crosstab"; +import { MoodPatterns } from "./mood-patterns"; export function MoodInsightsBreakdowns({ data, @@ -151,6 +153,7 @@ export function MoodInsightsBreakdowns({ const factorCrosstabRows = data.factorCrosstab ?? []; const hasFactorCrosstab = factorCrosstabRows.length > 0; const dimensionSummaries = data.dimensions ?? []; + const patterns = data.patterns ?? []; const hasDimensions = dimensionSummaries.some((d) => d.present); return ( @@ -184,6 +187,14 @@ export function MoodInsightsBreakdowns({ renders nothing when both halves are empty. */} + {/* v1.43 — the patterns the server found and gated; the card stays + away when none passed. */} + {patterns.length > 0 && ( + + + + )} + {/* v1.37 — the five level-A dimensions. Renders only once at least one of them carries a value; an account that has never opened the detail section sees the page it saw before. */} diff --git a/src/components/insights/mood/mood-insights-shared.tsx b/src/components/insights/mood/mood-insights-shared.tsx index fe4842984..40e42f763 100644 --- a/src/components/insights/mood/mood-insights-shared.tsx +++ b/src/components/insights/mood/mood-insights-shared.tsx @@ -30,6 +30,7 @@ import type { MoodBetterDayFactor } from "./mood-better-days"; import type { MoodTagMetricCrosstabRow } from "./mood-tag-metric-crosstab"; import type { MoodFactorMetricCrosstabRow } from "./mood-factor-metric-crosstab"; import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab"; +import type { MoodPatternRow } from "./mood-patterns"; export interface MoodInsightsResponse { summary: { @@ -65,6 +66,11 @@ export interface MoodInsightsResponse { // rollout; the live endpoint always populates it. factorCrosstab?: MoodFactorMetricCrosstabRow[]; narratives: MoodNarrativeItem[]; + /** + * v1.43 — the gated patterns the server found over the requested window. + * Optional to tolerate a payload from before it existed. + */ + patterns?: MoodPatternRow[]; correlations: { sleep: MoodMetricCorrelationData; steps: MoodMetricCorrelationData; diff --git a/src/components/insights/mood/mood-patterns.tsx b/src/components/insights/mood/mood-patterns.tsx new file mode 100644 index 000000000..69700dd90 --- /dev/null +++ b/src/components/insights/mood/mood-patterns.tsx @@ -0,0 +1,111 @@ +"use client"; + +/** + * v1.43 (ios #116 item 6) — the mood patterns the server found. + * + * Nothing here decides whether a pattern exists: every row is one the server + * published after its gate (eight days a side, Welch, false discovery + * control), computed on the mood day. This component only phrases each one. + * Rows render in the order delivered; a kind this build does not know is + * skipped rather than guessed at. + */ +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +export interface MoodPatternRow { + id: string; + kind: string; + strength: "weak" | "moderate" | "strong"; + direction: "higher" | "lower" | "none"; + values: { + subject: number; + comparison: number; + delta: number; + weekday?: number; + tagKey?: string; + tagLabel?: string; + }; + sampleSize: { subject: number; comparison: number }; +} + +const WEEKDAY_KEYS = [ + "charts.weekdaysFull.mon", + "charts.weekdaysFull.tue", + "charts.weekdaysFull.wed", + "charts.weekdaysFull.thu", + "charts.weekdaysFull.fri", + "charts.weekdaysFull.sat", + "charts.weekdaysFull.sun", +] as const; + +/** The sentence key per kind and direction; `none` cannot pass the gate. */ +const SENTENCE_KEYS: Record = + { + previous_day: { + higher: "insights.mood.patterns.previousDayHigher", + lower: "insights.mood.patterns.previousDayLower", + }, + weekend: { + higher: "insights.mood.patterns.weekendHigher", + lower: "insights.mood.patterns.weekendLower", + }, + best_weekday: { + higher: "insights.mood.patterns.bestWeekday", + lower: null, + }, + notes: { + higher: "insights.mood.patterns.notesHigher", + lower: "insights.mood.patterns.notesLower", + }, + delayed_tag: { + higher: "insights.mood.patterns.delayedTagHigher", + lower: "insights.mood.patterns.delayedTagLower", + }, + multiple_entries: { + higher: "insights.mood.patterns.multipleEntriesHigher", + lower: "insights.mood.patterns.multipleEntriesLower", + }, + }; + +export function MoodPatterns({ patterns }: { patterns: MoodPatternRow[] }) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const rows = patterns.flatMap((pattern) => { + const keys = SENTENCE_KEYS[pattern.kind]; + if (!keys || pattern.direction === "none") return []; + const key = pattern.direction === "higher" ? keys.higher : keys.lower; + if (!key) return []; + const weekdayKey = + pattern.values.weekday !== undefined + ? WEEKDAY_KEYS[pattern.values.weekday] + : undefined; + return [ + { + id: pattern.id, + sentence: t(key, { + subject: fmt.number(pattern.values.subject, 1), + comparison: fmt.number(pattern.values.comparison, 1), + weekday: weekdayKey ? t(weekdayKey) : "", + tag: pattern.values.tagLabel ?? pattern.values.tagKey ?? "", + }), + basedOn: t("insights.mood.patterns.basedOn", { + subject: pattern.sampleSize.subject, + comparison: pattern.sampleSize.comparison, + }), + }, + ]; + }); + + if (rows.length === 0) return null; + + return ( +
    + {rows.map((row) => ( +
  • +

    {row.sentence}

    +

    {row.basedOn}

    +
  • + ))} +
+ ); +} diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index 16fdd2587..38f02113b 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -332,7 +332,12 @@ export function resolveWeightTargetOverride( return { min, max }; } -function resolveThresholdOverride( +/** + * Narrow one metric's own target out of a raw `User.thresholdsJson`, the same + * way `resolveWeightTargetOverride` does for weight. `null` for anything that + * is not a well-formed, ordered pair of finite numbers. + */ +export function resolveThresholdOverride( thresholdsJson: unknown, metric: ThresholdMetric, ): { min: number; max: number } | null { diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 53579cd84..4d404906e 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -64,6 +64,10 @@ import { weightTrendReferenceKg, type WeightTrendJudgement, } from "@/lib/targets/weight-trend"; +import { + resolveSummaryVerdicts, + type TrendVerdict, +} from "@/lib/targets/trend-verdict"; import { probeRollupCoverage, typesWithReadingsInFoldWindow, @@ -452,6 +456,15 @@ export interface DashboardSnapshot { * builder always sets it. */ weightTrend?: WeightTrendJudgement; + /** + * v1.43 (ios #116 item 3) — per measurement type (and `MOOD`, + * `BLOOD_PRESSURE_IN_TARGET`), which way a change counts for this + * person, against their own target or a profile default where one + * applies (`src/lib/targets/trend-verdict.ts`). Every tile colours its + * arrow and deltas from this; `weightTrend` stays for older clients. + * Optional on the type so older cached snapshots stay valid. + */ + directions?: Record; }; /** Thick phase — null on a rollup-coverage miss. */ extras: DashboardSnapshotExtras | null; @@ -1496,6 +1509,14 @@ export async function buildDashboardSnapshot( weightTargetOverride, weightTrendReferenceKg(slim.summaries.WEIGHT), ), + directions: resolveSummaryVerdicts(slim.summaries, { + profile: { + dateOfBirth: user.dateOfBirth, + gender: user.gender, + heightCm: user.heightCm ?? null, + }, + thresholdsJson: user.thresholdsJson, + }), }, extras: extrasResult?.extras ?? null, medsToday, diff --git a/src/lib/insights/__tests__/mood-pattern-cards.test.ts b/src/lib/insights/__tests__/mood-pattern-cards.test.ts new file mode 100644 index 000000000..e6fecc746 --- /dev/null +++ b/src/lib/insights/__tests__/mood-pattern-cards.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "vitest"; + +import type { MoodAggregateEntry } from "@/lib/insights/mood-aggregates"; +import { shiftDateKey } from "@/lib/tz/format"; + +import { + computeMoodPatterns, + moodPatternsWire, + PATTERN_MIN_DAYS_PER_SIDE, + weekdayOfKey, +} from "../mood-pattern-cards"; + +const TODAY = "2026-10-11"; // a Sunday + +/** A deterministic pseudo-random stream (mulberry32). */ +function rng(seed: number): () => number { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function entry( + date: string, + score: number, + extra: Partial = {}, +): MoodAggregateEntry { + return { + date, + score, + tags: null, + moodLoggedAt: new Date(`${date}T18:00:00.000Z`), + ...extra, + }; +} + +function days(n: number, score: (date: string, i: number) => number) { + return Array.from({ length: n }, (_, i) => { + const date = shiftDateKey(TODAY, -(n - 1 - i)); + return entry(date, score(date, i)); + }); +} + +function run(entries: MoodAggregateEntry[], noteDays = new Set()) { + return computeMoodPatterns({ + entries, + noteDays, + todayKey: TODAY, + windowDays: 365, + }); +} + +describe("computeMoodPatterns", () => { + it("finds a clear weekend effect and names it", () => { + const r = rng(7); + const entries = days(120, (date) => + Math.min( + 5, + Math.max(1, (weekdayOfKey(date) >= 5 ? 4.2 : 3) + (r() - 0.5)), + ), + ); + const patterns = run(entries); + const weekend = patterns.find((p) => p.kind === "weekend"); + expect(weekend).toBeDefined(); + expect(weekend).toMatchObject({ + id: "weekend", + direction: "higher", + strength: "strong", + }); + expect(weekend!.sampleSize.subject).toBeGreaterThanOrEqual( + PATTERN_MIN_DAYS_PER_SIDE, + ); + expect(weekend!.values.subject).toBeGreaterThan(weekend!.values.comparison); + // Saturday or Sunday is the best weekday, and only one is published. + const best = patterns.filter((p) => p.kind === "best_weekday"); + expect(best.length).toBeLessThanOrEqual(1); + if (best[0]) expect(best[0].values.weekday).toBeGreaterThanOrEqual(5); + }); + + it("finds nothing in noise", () => { + for (const seed of [1, 2, 3, 4, 5]) { + const r = rng(seed); + const entries = days(180, () => 1 + Math.round(r() * 4)); + expect(run(entries), `seed ${seed}`).toEqual([]); + } + }); + + it("holds every comparison to eight days a side", () => { + // Twelve days, all with a strong weekend split: too few weekend days. + const r = rng(16); + const entries = days( + 12, + (date) => (weekdayOfKey(date) >= 5 ? 4.6 : 1.4) + (r() - 0.5) * 0.4, + ); + expect(run(entries).find((p) => p.kind === "weekend")).toBeUndefined(); + }); + + it("compares days with a note against days without", () => { + const r = rng(11); + const entries = days( + 90, + (_, i) => (i % 3 === 0 ? 2 : 4) + (r() - 0.5) * 0.4, + ); + const noteDays = new Set( + entries.filter((_, i) => i % 3 === 0).map((e) => e.date), + ); + const notes = run(entries, noteDays).find((p) => p.kind === "notes"); + expect(notes).toMatchObject({ id: "notes", direction: "lower" }); + }); + + it("compares days with several entries against single-entry days", () => { + const r = rng(12); + const base = days( + 90, + (_, i) => (i % 2 === 0 ? 4.5 : 3) + (r() - 0.5) * 0.4, + ); + const extra = base + .filter((_, i) => i % 2 === 0) + .map((e) => entry(e.date, e.score)); + const multi = run([...base, ...extra]).find( + (p) => p.kind === "multiple_entries", + ); + expect(multi).toMatchObject({ direction: "higher" }); + }); + + it("reads a tag's association with the NEXT day", () => { + const r = rng(13); + const tag = { + key: "alcohol", + categoryKey: "health", + labelKey: "mood.tag.alcohol", + icon: null, + }; + const tagged = new Set(); + const entries = days(120, (_, i) => { + // The day after a tagged day is low. + return tagged.has(i - 1) ? 2 + r() * 0.4 : 4 + r() * 0.4; + }); + entries.forEach((e, i) => { + if (i % 4 === 0) { + tagged.add(i); + e.structuredTags = [tag]; + } + }); + // Recompute the scores now that the tag days are known. + for (let i = 0; i < entries.length; i += 1) { + entries[i].score = tagged.has(i - 1) ? 2 + r() * 0.4 : 4 + r() * 0.4; + } + const delayed = run(entries).find((p) => p.kind === "delayed_tag"); + expect(delayed).toMatchObject({ + id: "delayed_tag:alcohol", + direction: "lower", + values: { tagKey: "alcohol" }, + }); + }); + + it("reads the day after a below-median day", () => { + const r = rng(14); + const entries: MoodAggregateEntry[] = []; + let prevLow = false; + for (let i = 0; i < 150; i += 1) { + const date = shiftDateKey(TODAY, -(149 - i)); + // Persistent mood: a low day is followed by another low-ish day. + const low: boolean = r() < (prevLow ? 0.85 : 0.15); + entries.push(entry(date, low ? 2 + r() * 0.5 : 4 + r() * 0.5)); + prevLow = low; + } + const previous = run(entries).find((p) => p.kind === "previous_day"); + expect(previous).toMatchObject({ id: "previous_day", direction: "lower" }); + }); + + it("only counts days inside the window", () => { + const r = rng(15); + const entries = days( + 200, + (date) => (weekdayOfKey(date) >= 5 ? 4.5 : 2.5) + (r() - 0.5) * 0.4, + ); + const recent = computeMoodPatterns({ + entries, + noteDays: new Set(), + todayKey: TODAY, + windowDays: 30, + }).find((p) => p.kind === "weekend"); + expect(recent).toBeDefined(); + expect(recent!.sampleSize.subject).toBeGreaterThanOrEqual(8); + expect(recent!.sampleSize.subject + recent!.sampleSize.comparison).toBe(30); + }); +}); + +describe("moodPatternsWire", () => { + it("names a catalogue tag in the reader's language and a custom one verbatim, and drops q", () => { + const wire = moodPatternsWire( + [ + { + id: "delayed_tag:alcohol", + kind: "delayed_tag", + strength: "moderate", + direction: "lower", + values: { + subject: 2.1, + comparison: 4, + delta: -1.9, + tagKey: "alcohol", + }, + sampleSize: { subject: 20, comparison: 80 }, + tag: { labelKey: "mood.tag.alcohol", label: null }, + qValue: 0.01, + }, + { + id: "delayed_tag:custom:x", + kind: "delayed_tag", + strength: "weak", + direction: "higher", + values: { subject: 4, comparison: 3, delta: 1, tagKey: "custom:x" }, + sampleSize: { subject: 9, comparison: 50 }, + tag: { labelKey: "custom:x", label: "Choir" }, + qValue: 0.08, + }, + ], + (key) => `t:${key}`, + ); + expect(wire[0].values.tagLabel).toBe("t:mood.tag.alcohol"); + expect(wire[1].values.tagLabel).toBe("Choir"); + expect(wire[0]).not.toHaveProperty("qValue"); + expect(wire[0]).not.toHaveProperty("tag"); + expect(moodPatternsWire(undefined, (k) => k)).toEqual([]); + }); +}); diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index 4aa3ea559..f17913712 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -28,6 +28,10 @@ * statistics helpers below are exported so the unit test can assert the * composed-bucket baseline matches the raw-DAY baseline within tolerance. */ +import { + resolveTrendVerdict, + type TrendVerdict, +} from "@/lib/targets/trend-verdict"; import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { readDayAggregates } from "@/lib/measurements/day-aggregates"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; @@ -91,6 +95,14 @@ export interface VitalsBaselineValue { * band is already computed from (no extra read). */ series: number[]; + /** + * v1.43 (ios #116 item 3) — which way a change of this vital counts for + * the person, with the band's centre placed against their own target or a + * profile default (`src/lib/targets/trend-verdict.ts`). The tile colours + * its delta from this instead of a fixed polarity. Absent when the caller + * passed no profile facts to judge with. + */ + direction?: TrendVerdict; } /** Caller-supplied profile (read once per request, never re-fetched here). */ @@ -109,6 +121,13 @@ export interface BaselineProfile { * metric (weight ÷ height²); `null` when the profile has no height. */ heightCm?: number | null; + /** + * v1.43 — the date of birth and the person's own targets, which the trend + * verdict on `VITALS_BASELINE` is judged with. Optional: a caller that does + * not read them gets no verdict rather than a wrong one. + */ + dateOfBirth?: Date | null; + thresholdsJson?: unknown; } /** @@ -127,13 +146,20 @@ export async function loadBaselineProfile( ): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, - select: { dateOfBirth: true, gender: true, heightCm: true }, + select: { + dateOfBirth: true, + gender: true, + heightCm: true, + thresholdsJson: true, + }, }); const sex = toProfileSex(user?.gender); return { ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), sex, heightCm: user?.heightCm ?? null, + dateOfBirth: user?.dateOfBirth ?? null, + thresholdsJson: user?.thresholdsJson ?? null, }; } @@ -311,7 +337,7 @@ export async function readDayMeanSeries( */ export async function computeVitalsBaseline( userId: string, - _profile: BaselineProfile, + profile: BaselineProfile, opts: VitalsBaselineOpts, ): Promise> { const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; @@ -403,8 +429,22 @@ export async function computeVitalsBaseline( // means the band is built from, capped to the recent window. const series = points.slice(-SPARKLINE_MAX_POINTS).map((p) => p.mean); + // Judged only when the caller read the facts it is judged with; the + // nightly engines that never show a delta pass a bare profile. + const direction = + profile.dateOfBirth !== undefined + ? resolveTrendVerdict(type, { + profile: { + dateOfBirth: profile.dateOfBirth, + gender: profile.sex, + heightCm: profile.heightCm ?? null, + }, + thresholdsJson: profile.thresholdsJson ?? null, + referenceValue: band.center, + }) + : undefined; return buildOk({ - value: { type, ...band, series }, + value: { type, ...band, series, ...(direction ? { direction } : {}) }, coverage: cov, confidence, provenance: { inputs: [String(type)], source, windowDays, computedAt }, diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index ad4549276..d8f4e1fa7 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -18,6 +18,11 @@ * importing from here unchanged. */ +import { + computeMoodPatterns, + moodPatternsWire, + type MoodPatternFinding, +} from "@/lib/insights/mood-pattern-cards"; import { prisma } from "@/lib/db"; import { significantPearsonCorrelation, @@ -512,6 +517,13 @@ export interface MoodAggregates { * `stability` and does not publish this map. */ stabilityByWindow: Record; + /** + * v1.43 (ios #116 item 6) — the gated mood patterns per selectable window + * (`mood-pattern-cards.ts`). Cached with the aggregate like the stability + * map; the route serves the chosen window as `patterns`. Optional so an + * aggregate cached before it existed still reads (as none found). + */ + patternsByWindow?: Record; tags: TagSummaryRow[]; /** * v1.38 — how the day's pleasantness sat on days carrying each context @@ -579,6 +591,11 @@ export function computeMoodAggregates(args: { * calendar day. Defaults to Berlin for legacy / test callers. */ tz?: string; + /** + * v1.43 — the mood days that carry a note, for the `notes` pattern. Read + * as a separate projection so the note text itself is never loaded here. + */ + noteDays?: ReadonlySet; }): MoodAggregates { const { entries, measurements, now } = args; const userPriorityJson = args.userPriorityJson ?? null; @@ -633,6 +650,18 @@ export function computeMoodAggregates(args: { ), ]), ) as Record; + const todayKey = dayOffsetToDayKey(now, 0, tz); + const patternsByWindow = Object.fromEntries( + MOOD_STABILITY_WINDOWS.map((days) => [ + days, + computeMoodPatterns({ + entries, + noteDays: args.noteDays ?? new Set(), + todayKey, + windowDays: days, + }), + ]), + ) as Record; const tags = computeTagSummary(entries, now); const structuredTags = computeStructuredTagSummary(entries, now); const tagInfluence = computeTagInfluence(entries, now); @@ -715,6 +744,7 @@ export function computeMoodAggregates(args: { timeOfDay, stability, stabilityByWindow, + patternsByWindow, tags, structuredTags, tagInfluence, @@ -921,9 +951,20 @@ export async function fetchMoodAggregates( }); } - const [userPriorityJson, tz] = await Promise.all([ + // v1.43 — which mood days carry a note, without reading a single note. + const [userPriorityJson, tz, noteRows] = await Promise.all([ loadUserSourcePriority(userId), resolveUserTimezone(userId), + prisma.moodEntry.findMany({ + where: { + userId, + deletedAt: null, + moodLoggedAt: { gte: windowCutoff }, + OR: [{ noteEncrypted: { not: null } }, { note: { not: null } }], + }, + select: { date: true }, + distinct: ["date"], + }), ]); const aggregate = computeMoodAggregates({ @@ -932,6 +973,7 @@ export async function fetchMoodAggregates( now, userPriorityJson, tz, + noteDays: new Set(noteRows.map((row) => row.date)), }); const tagEvidence = aggregate.tagMetricCrosstab.map((row) => ({ @@ -995,8 +1037,10 @@ export async function fetchMoodAggregates( export function moodInsightsWire( result: MoodAggregates, stabilityWindowDays: MoodStabilityWindow, + /** Names a pattern's tag in the reader's language (catalogue tags). */ + t: (key: string) => string, ) { - const { stabilityByWindow, ...wire } = result; + const { stabilityByWindow, patternsByWindow, ...wire } = result; return { ...wire, stability: @@ -1004,5 +1048,8 @@ export function moodInsightsWire( ? result.stability : (stabilityByWindow?.[stabilityWindowDays] ?? null), stabilityWindowDays, + // v1.43 — the patterns of the same window; an aggregate cached before + // they existed reads as none found until its next rebuild. + patterns: moodPatternsWire(patternsByWindow?.[stabilityWindowDays], t), }; } diff --git a/src/lib/insights/mood-pattern-cards.ts b/src/lib/insights/mood-pattern-cards.ts new file mode 100644 index 000000000..7355d9afd --- /dev/null +++ b/src/lib/insights/mood-pattern-cards.ts @@ -0,0 +1,352 @@ +/** + * Mood patterns the server finds and publishes (v1.43, ios #116 item 6). + * + * The native client used to compute six pattern cards itself (the day after + * a low day, weekends, the best weekday, days with a note, delayed tag + * effects, several entries a day) on the device calendar and without any + * statistical gate, and removed them. They are computed here instead, on the + * account's mood day (`MoodEntry.date`, anchored to the entry's own zone), + * and only the ones that pass the gate are published: + * + * - at least `PATTERN_MIN_DAYS_PER_SIDE` days on each side of a comparison; + * - a Welch two-sample t-test on the two groups of daily means (the same + * test the tag influence board uses); + * - Benjamini-Hochberg over every comparison tested in the window, kept at + * q ≤ `PATTERN_FDR_Q` (the false discovery rate the mood crosstabs use). + * + * An empty list means nothing passed, not that something failed. Every + * comparison is a daily-mean comparison, so a day with five entries weighs + * as much as a day with one. Observational only: the copy a client renders + * says "tends to", never "because". + * + * Pure; the caller hands over the entries and which days carry a note. + */ +import { welchTTest } from "@/lib/insights/correlations"; +import { benjaminiHochberg } from "@/lib/insights/correlation-discovery"; +import type { + MoodAggregateEntry, + StructuredTagRef, +} from "@/lib/insights/mood-aggregates"; +import { round } from "@/lib/insights/status-shared"; +import { shiftDateKey } from "@/lib/tz/format"; + +export const PATTERN_MIN_DAYS_PER_SIDE = 8; +export const PATTERN_FDR_Q = 0.1; + +export type MoodPatternKind = + | "previous_day" + | "weekend" + | "best_weekday" + | "notes" + | "delayed_tag" + | "multiple_entries"; + +/** A pattern as the aggregate caches it: the tag's label is resolved at the wire. */ +export interface MoodPatternFinding { + id: string; + kind: MoodPatternKind; + strength: "weak" | "moderate" | "strong"; + direction: "higher" | "lower" | "none"; + values: { + subject: number; + comparison: number; + delta: number; + weekday?: number; + tagKey?: string; + }; + sampleSize: { subject: number; comparison: number }; + /** For `delayed_tag`: how to name the tag (custom label first). */ + tag?: { labelKey: string; label: string | null }; + /** BH-adjusted q, kept for ordering and tests; not on the wire. */ + qValue: number; +} + +/** The wire shape (`MoodPattern` in OpenAPI). */ +export interface MoodPatternWire { + id: string; + kind: MoodPatternKind; + strength: "weak" | "moderate" | "strong"; + direction: "higher" | "lower" | "none"; + values: { + subject: number; + comparison: number; + delta: number; + weekday?: number; + tagKey?: string; + tagLabel?: string; + }; + sampleSize: { subject: number; comparison: number }; +} + +interface MoodDay { + date: string; + mean: number; + entries: number; + hasNote: boolean; + tags: Map; +} + +interface Candidate { + id: string; + kind: MoodPatternKind; + subject: number[]; + comparison: number[]; + weekday?: number; + tag?: StructuredTagRef; +} + +/** 0 = Monday … 6 = Sunday, read off the day key itself. */ +export function weekdayOfKey(dateKey: string): number { + const d = new Date(`${dateKey}T12:00:00Z`); + return (d.getUTCDay() + 6) % 7; +} + +function collapseDays( + entries: readonly MoodAggregateEntry[], + noteDays: ReadonlySet, +): Map { + const days = new Map< + string, + { sum: number; count: number; tags: Map } + >(); + for (const entry of entries) { + const day = days.get(entry.date) ?? { + sum: 0, + count: 0, + tags: new Map(), + }; + day.sum += entry.score; + day.count += 1; + for (const ref of entry.structuredTags ?? []) day.tags.set(ref.key, ref); + days.set(entry.date, day); + } + const out = new Map(); + for (const [date, day] of days) { + out.set(date, { + date, + mean: day.sum / day.count, + entries: day.count, + hasNote: noteDays.has(date), + tags: day.tags, + }); + } + return out; +} + +function median(values: readonly number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function mean(values: readonly number[]): number { + return values.reduce((s, v) => s + v, 0) / values.length; +} + +/** Cohen's d on the pooled SD, banded at the conventional 0.5 / 0.8. */ +function strengthOf( + subject: readonly number[], + comparison: readonly number[], +): MoodPatternFinding["strength"] { + const ms = mean(subject); + const mc = mean(comparison); + const vs = + subject.reduce((s, v) => s + (v - ms) ** 2, 0) / (subject.length - 1); + const vc = + comparison.reduce((s, v) => s + (v - mc) ** 2, 0) / (comparison.length - 1); + const pooled = + ((subject.length - 1) * vs + (comparison.length - 1) * vc) / + (subject.length + comparison.length - 2); + const d = pooled > 0 ? Math.abs(ms - mc) / Math.sqrt(pooled) : 0; + if (d >= 0.8) return "strong"; + if (d >= 0.5) return "moderate"; + return "weak"; +} + +function candidates(days: Map): Candidate[] { + const list = [...days.values()].sort((a, b) => + a.date < b.date ? -1 : a.date > b.date ? 1 : 0, + ); + const out: Candidate[] = []; + + // The day after a day below the person's own median, against the day after + // one above it. Days on the median itself belong to neither side. + const med = list.length > 0 ? median(list.map((d) => d.mean)) : 0; + const afterLow: number[] = []; + const afterHigh: number[] = []; + for (const day of list) { + const prev = days.get(shiftDateKey(day.date, -1)); + if (!prev) continue; + if (prev.mean < med) afterLow.push(day.mean); + else if (prev.mean > med) afterHigh.push(day.mean); + } + out.push({ + id: "previous_day", + kind: "previous_day", + subject: afterLow, + comparison: afterHigh, + }); + + // Saturday and Sunday against Monday to Friday. + out.push({ + id: "weekend", + kind: "weekend", + subject: list.filter((d) => weekdayOfKey(d.date) >= 5).map((d) => d.mean), + comparison: list.filter((d) => weekdayOfKey(d.date) < 5).map((d) => d.mean), + }); + + // Each weekday against the rest. All seven are tested so the selection of + // the best one is paid for in the false discovery control; only the best + // can be published. + for (let weekday = 0; weekday < 7; weekday += 1) { + out.push({ + id: `weekday:${weekday}`, + kind: "best_weekday", + weekday, + subject: list + .filter((d) => weekdayOfKey(d.date) === weekday) + .map((d) => d.mean), + comparison: list + .filter((d) => weekdayOfKey(d.date) !== weekday) + .map((d) => d.mean), + }); + } + + out.push({ + id: "notes", + kind: "notes", + subject: list.filter((d) => d.hasNote).map((d) => d.mean), + comparison: list.filter((d) => !d.hasNote).map((d) => d.mean), + }); + + out.push({ + id: "multiple_entries", + kind: "multiple_entries", + subject: list.filter((d) => d.entries >= 2).map((d) => d.mean), + comparison: list.filter((d) => d.entries === 1).map((d) => d.mean), + }); + + // A tag's association with the NEXT day's mood: the day after a tagged day + // against the day after an untagged one. Only consecutive logged days count. + const tagRefs = new Map(); + for (const day of list) { + for (const [key, ref] of day.tags) + if (!tagRefs.has(key)) tagRefs.set(key, ref); + } + for (const [key, ref] of tagRefs) { + const afterTag: number[] = []; + const afterOther: number[] = []; + for (const day of list) { + const prev = days.get(shiftDateKey(day.date, -1)); + if (!prev) continue; + if (prev.tags.has(key)) afterTag.push(day.mean); + else afterOther.push(day.mean); + } + out.push({ + id: `delayed_tag:${key}`, + kind: "delayed_tag", + tag: ref, + subject: afterTag, + comparison: afterOther, + }); + } + return out; +} + +/** + * The gated patterns over the entries whose mood day falls inside the + * trailing `windowDays` ending at `todayKey`. + */ +export function computeMoodPatterns(args: { + entries: readonly MoodAggregateEntry[]; + noteDays: ReadonlySet; + todayKey: string; + windowDays: number; +}): MoodPatternFinding[] { + const fromKey = shiftDateKey(args.todayKey, -(args.windowDays - 1)); + const inWindow = args.entries.filter( + (e) => e.date >= fromKey && e.date <= args.todayKey, + ); + const days = collapseDays(inWindow, args.noteDays); + + const tested: Array<{ c: Candidate; p: number }> = []; + for (const c of candidates(days)) { + if ( + c.subject.length < PATTERN_MIN_DAYS_PER_SIDE || + c.comparison.length < PATTERN_MIN_DAYS_PER_SIDE + ) { + continue; + } + const welch = welchTTest(c.subject, c.comparison); + // No spread on either side: nothing a test can stand behind. + if (welch.status !== "ok") continue; + tested.push({ c, p: welch.pValue }); + } + if (tested.length === 0) return []; + + const q = benjaminiHochberg(tested.map((t) => t.p)); + const passed: MoodPatternFinding[] = []; + tested.forEach(({ c }, i) => { + if (q[i] > PATTERN_FDR_Q) return; + const subject = mean(c.subject); + const comparison = mean(c.comparison); + const delta = subject - comparison; + passed.push({ + id: c.kind === "best_weekday" ? "best_weekday" : c.id, + kind: c.kind, + strength: strengthOf(c.subject, c.comparison), + direction: delta > 0 ? "higher" : delta < 0 ? "lower" : "none", + values: { + subject: round(subject, 2), + comparison: round(comparison, 2), + delta: round(delta, 2), + ...(c.weekday !== undefined ? { weekday: c.weekday } : {}), + ...(c.tag ? { tagKey: c.tag.key } : {}), + }, + sampleSize: { + subject: c.subject.length, + comparison: c.comparison.length, + }, + ...(c.tag + ? { tag: { labelKey: c.tag.labelKey, label: c.tag.label ?? null } } + : {}), + qValue: q[i], + }); + }); + + // One best weekday at most: the highest one that passed and is above the + // rest. A weekday that is only significantly LOWER is not a best weekday. + const weekdays = passed + .filter((p) => p.kind === "best_weekday" && p.direction === "higher") + .sort((a, b) => b.values.subject - a.values.subject); + const kept = passed.filter((p) => p.kind !== "best_weekday"); + if (weekdays.length > 0) kept.push(weekdays[0]); + + const STRENGTH_RANK = { strong: 0, moderate: 1, weak: 2 } as const; + return kept.sort( + (a, b) => + STRENGTH_RANK[a.strength] - STRENGTH_RANK[b.strength] || + Math.abs(b.values.delta) - Math.abs(a.values.delta) || + a.id.localeCompare(b.id), + ); +} + +/** The wire form: the tag named in the reader's language, no internal fields. */ +export function moodPatternsWire( + findings: readonly MoodPatternFinding[] | undefined, + t: (key: string) => string, +): MoodPatternWire[] { + return (findings ?? []).map((f) => ({ + id: f.id, + kind: f.kind, + strength: f.strength, + direction: f.direction, + values: { + ...f.values, + ...(f.tag ? { tagLabel: f.tag.label ?? t(f.tag.labelKey) } : {}), + }, + sampleSize: f.sampleSize, + })); +} diff --git a/src/lib/measurements/__tests__/series-change.test.ts b/src/lib/measurements/__tests__/series-change.test.ts new file mode 100644 index 000000000..9941dfbd7 --- /dev/null +++ b/src/lib/measurements/__tests__/series-change.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTrendVerdict } from "@/lib/targets/trend-verdict"; + +import { + computeSeriesChange, + seriesDayValues, + seriesReferenceValue, +} from "../series-change"; + +const TZ = "Europe/Berlin"; +const upBad = resolveTrendVerdict("RESTING_HEART_RATE", { + profile: { dateOfBirth: null, gender: null, heightCm: null }, + thresholdsJson: null, + referenceValue: null, +}); + +describe("seriesDayValues", () => { + it("sums a summed type per local day and averages the rest", () => { + const points = [ + { at: "2026-09-01T06:00:00.000Z", value: 1000 }, + { at: "2026-09-01T20:00:00.000Z", value: 2000 }, + // 23:30 UTC is already the next day in Berlin. + { at: "2026-09-01T23:30:00.000Z", value: 500 }, + ]; + expect(seriesDayValues("ACTIVITY_STEPS", points, TZ)).toEqual([ + { day: "2026-09-01", value: 3000 }, + { day: "2026-09-02", value: 500 }, + ]); + expect(seriesDayValues("WEIGHT", points, TZ)[0]).toEqual({ + day: "2026-09-01", + value: 1500, + }); + }); +}); + +describe("computeSeriesChange", () => { + it("is null under two days", () => { + expect( + computeSeriesChange([{ day: "2026-09-01", value: 60 }], upBad), + ).toBeNull(); + }); + + it("compares the last day with the first and judges the change", () => { + expect( + computeSeriesChange( + [ + { day: "2026-09-01", value: 60 }, + { day: "2026-09-02", value: 70 }, + { day: "2026-09-03", value: 64.123 }, + ], + upBad, + ), + ).toEqual({ from: 60, to: 64.12, delta: 4.12, sentiment: "negative" }); + }); +}); + +describe("seriesReferenceValue", () => { + it("averages the last seven days", () => { + const days = Array.from({ length: 10 }, (_, i) => ({ + day: `2026-09-${String(i + 1).padStart(2, "0")}`, + value: i + 1, + })); + expect(seriesReferenceValue(days)).toBe(7); + expect(seriesReferenceValue([])).toBeNull(); + }); +}); diff --git a/src/lib/measurements/__tests__/series-reference-lines.test.ts b/src/lib/measurements/__tests__/series-reference-lines.test.ts new file mode 100644 index 000000000..83b55a5b8 --- /dev/null +++ b/src/lib/measurements/__tests__/series-reference-lines.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { locales as LOCALES } from "@/lib/i18n/config"; + +import { resolveSeriesReferenceLines } from "../series-reference-lines"; + +const en = getServerTranslator("en"); +const NO_PROFILE = { dateOfBirth: null, gender: null, heightCm: null }; +const MAN_41 = { + dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), + gender: "MALE", + heightCm: 180, +}; + +function lines( + kind: Parameters[0], + opts: { + profile?: typeof NO_PROFILE | typeof MAN_41; + thresholdsJson?: unknown; + glucoseUnit?: "mg/dL" | "mmol/L"; + } = {}, +) { + return resolveSeriesReferenceLines(kind, { + profile: opts.profile ?? NO_PROFILE, + thresholdsJson: opts.thresholdsJson ?? null, + glucoseUnit: opts.glucoseUnit ?? "mg/dL", + t: (key) => en.t(key), + }); +} + +describe("resolveSeriesReferenceLines", () => { + it("draws glucose 70 / 180 mg/dL, converted for an mmol/L account", () => { + expect(lines("glucose").map((l) => [l.kind, l.value])).toEqual([ + ["lower", 70], + ["upper", 180], + ]); + expect( + lines("glucose", { glucoseUnit: "mmol/L" }).map((l) => [l.kind, l.value]), + ).toEqual([ + ["lower", 3.9], + ["upper", 10], + ]); + }); + + it("draws the ESH 140 / 90 lines without a profile or a target", () => { + expect( + lines("bloodPressure").map((l) => [ + l.appliesTo, + l.kind, + l.value, + l.source, + ]), + ).toEqual([ + ["value", "upper", 140, "clinical"], + ["secondary", "upper", 90, "clinical"], + ]); + }); + + it("replaces the clinical edge with the person's band", () => { + const own = lines("bloodPressure", { + thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } }, + }); + expect(own.map((l) => [l.appliesTo, l.kind, l.value, l.source])).toEqual([ + ["value", "lower", 115, "personal"], + ["value", "upper", 125, "personal"], + ["secondary", "upper", 90, "clinical"], + ]); + // An age turns the diastolic side into the profile band too. + const aged = lines("bloodPressure", { profile: MAN_41 }); + expect(aged.every((l) => l.source === "personal")).toBe(true); + expect(aged.map((l) => l.value)).toEqual([120, 129, 70, 79]); + }); + + it("draws the SpO2 floor, or the person's band instead", () => { + expect(lines("oxygenSaturation")).toMatchObject([ + { value: 92, kind: "lower", source: "clinical" }, + ]); + expect( + lines("oxygenSaturation", { + thresholdsJson: { OXYGEN_SATURATION: { min: 90, max: 96 } }, + }).map((l) => l.value), + ).toEqual([90, 96]); + }); + + it("draws the resting band on the resting chart only", () => { + expect(lines("restingHeartRate", { profile: MAN_41 })).toHaveLength(2); + expect(lines("pulse", { profile: MAN_41 })).toEqual([]); + }); + + it("is empty when there is nothing to draw", () => { + expect(lines("heartRateVariability")).toEqual([]); + expect(lines("steps")).toEqual([]); + expect(lines("weight", { profile: MAN_41 })).toEqual([]); + }); + + it("names every label key in every bundle, and labels in the account language", () => { + const keys = new Set([ + ...lines("bloodPressure").map((l) => l.labelKey), + ...lines("bloodPressure", { profile: MAN_41 }).map((l) => l.labelKey), + ...lines("glucose").map((l) => l.labelKey), + ]); + expect(keys.size).toBe(4); + for (const locale of LOCALES) { + const t = getServerTranslator(locale); + for (const key of keys) expect(t.t(key)).not.toBe(key); + } + const de = getServerTranslator("de"); + const german = resolveSeriesReferenceLines("glucose", { + profile: NO_PROFILE, + thresholdsJson: null, + glucoseUnit: "mg/dL", + t: (key) => de.t(key), + }); + expect(german[0].label).toBe("Klinische Untergrenze"); + }); +}); diff --git a/src/lib/measurements/series-change.ts b/src/lib/measurements/series-change.ts new file mode 100644 index 000000000..b5bc64054 --- /dev/null +++ b/src/lib/measurements/series-change.ts @@ -0,0 +1,88 @@ +/** + * The first-to-last change of a chart window, by day (v1.43, ios #116 + * item 3). + * + * A chart's change is read off day values, not off single points: the first + * reading of a window can be an outlier minute of a dense series, and a day + * of steps is its total, not its last sample. Each local day of the window is + * reduced with the type's own day statistic (the total for a summed type, the + * mean of the hours' means for pulse, the mean otherwise), and the change is + * the last day against the first. Under two days there is no change to show. + * + * Pure: the series route hands over the points it already serves, in the + * unit it serves them in. + */ +import { dayValue } from "@/lib/measurements/day-mean"; +import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum"; +import { + trendSentimentOf, + type TrendSentiment, + type TrendVerdict, +} from "@/lib/targets/trend-verdict"; +import { userDayKey } from "@/lib/tz/format"; + +export interface SeriesChange { + from: number; + to: number; + delta: number; + sentiment: TrendSentiment; +} + +export interface SeriesDayValue { + day: string; + value: number; +} + +const round2 = (n: number) => Math.round(n * 100) / 100; + +/** One value per local day, oldest first. */ +export function seriesDayValues( + type: string, + points: ReadonlyArray<{ at: string; value: number }>, + timezone: string, +): SeriesDayValue[] { + const byDay = new Map>(); + for (const point of points) { + const measuredAt = new Date(point.at); + if (!Number.isFinite(point.value) || Number.isNaN(measuredAt.getTime())) { + continue; + } + const day = userDayKey(measuredAt, timezone); + const list = byDay.get(day); + if (list) list.push({ value: point.value, measuredAt }); + else byDay.set(day, [{ value: point.value, measuredAt }]); + } + const summed = isCumulativeDaySumType(type); + const out: SeriesDayValue[] = []; + for (const [day, rows] of byDay) { + const value = summed + ? rows.reduce((sum, r) => sum + r.value, 0) + : dayValue(type, rows, timezone); + if (value !== null) out.push({ day, value }); + } + return out.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); +} + +export function computeSeriesChange( + days: readonly SeriesDayValue[], + verdict: TrendVerdict | null, +): SeriesChange | null { + if (days.length < 2) return null; + const from = round2(days[0].value); + const to = round2(days[days.length - 1].value); + const delta = round2(to - from); + return { from, to, delta, sentiment: trendSentimentOf(delta, verdict) }; +} + +/** + * The value a window's verdict places against the band: the mean of its last + * seven day values (the dashboard places the seven-day average the same way), + * so one day near a band edge does not flip the colour. + */ +export function seriesReferenceValue( + days: readonly SeriesDayValue[], +): number | null { + if (days.length === 0) return null; + const recent = days.slice(-7); + return recent.reduce((sum, d) => sum + d.value, 0) / recent.length; +} diff --git a/src/lib/measurements/series-reference-lines.ts b/src/lib/measurements/series-reference-lines.ts new file mode 100644 index 000000000..0915c3b2d --- /dev/null +++ b/src/lib/measurements/series-reference-lines.ts @@ -0,0 +1,178 @@ +/** + * Horizontal reference lines for a measurement chart (v1.43, ios #116 item 4). + * + * The native client drew fixed threshold lines (blood pressure 140/60, pulse + * 100/60, glucose 70/180 mg/dL, fever 37.5, SpO2 95) that knew neither the + * series' unit nor the person's targets, and removed them. The server knows + * both, so it resolves the lines here and `GET /api/measurements/series` + * serves them in the unit of its own points. + * + * Two sources, one rule: a personal line (the person's own target from + * `/targets`, else the profile default the target cards use: the age-based + * blood pressure band, the age- and sex-based resting pulse band, the + * sex-based body fat band) replaces the clinical line for the same edge. + * The clinical lines are the ones a guideline names: + * + * - blood pressure 140 / 90 mmHg, the ESH 2023 hypertension threshold; + * - glucose 70 and 180 mg/dL, the CGM time-in-range band (ATTD consensus, + * Battelino et al. 2019), converted for an mmol/L account; + * - SpO2 lower edge 92 %, the red-flag floor in `clinical-floors.ts`. + * + * Without either, the list is empty: nothing to draw is an answer, not an + * error. + */ +import type { + ThresholdMetric, + UserProfileForRange, +} from "@/lib/analytics/effective-range"; +import { TIME_IN_RANGE_MGDL } from "@/lib/analytics/glucose-metrics"; +import { SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors"; +import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; +import { REFERENCE_RANGES } from "@/lib/reference-ranges"; +import { resolvePersonalBand } from "@/lib/targets/trend-verdict"; + +export type SeriesKind = + | "weight" + | "bloodPressure" + | "pulse" + | "bodyFat" + | "glucose" + | "sleep" + | "steps" + | "totalBodyWater" + | "boneMass" + | "oxygenSaturation" + | "restingHeartRate" + | "heartRateVariability" + | "vo2Max"; + +export interface SeriesReferenceLine { + value: number; + kind: "lower" | "upper"; + source: "personal" | "clinical"; + /** `secondary` is the diastolic line of a blood pressure chart. */ + appliesTo: "value" | "secondary"; + labelKey: string; + label: string; +} + +export interface SeriesReferenceContext { + /** The profile of the record on screen. */ + profile: UserProfileForRange; + thresholdsJson: unknown; + glucoseUnit: GlucoseUnit; + /** Server translator in the account's language. */ + t: (key: string) => string; +} + +/** The ESH 2023 hypertension threshold, systolic from the shipped bands. */ +const ESH_HYPERTENSION_SYS = + REFERENCE_RANGES.BLOOD_PRESSURE.bands.find((b) => b.label === "Hypertension") + ?.low ?? 140; +/** Its diastolic counterpart (ESH 2023 grade 1 starts at 140 / 90). */ +const ESH_HYPERTENSION_DIA = 90; + +/** Which `/targets` threshold a kind's personal lines come from. */ +const PERSONAL_METRIC: Partial> = { + weight: "WEIGHT", + bodyFat: "BODY_FAT", + sleep: "SLEEP_DURATION", + steps: "ACTIVITY_STEPS", + totalBodyWater: "TOTAL_BODY_WATER", + boneMass: "BONE_MASS", + oxygenSaturation: "OXYGEN_SATURATION", + // A resting band on the resting chart only: raw heart rate includes every + // workout, where the resting band says nothing. + restingHeartRate: "PULSE", +}; + +function personalBand( + metric: ThresholdMetric, + ctx: SeriesReferenceContext, +): { min: number; max: number } | null { + return ( + resolvePersonalBand(metric, ctx.profile, ctx.thresholdsJson)?.band ?? null + ); +} + +function line( + ctx: SeriesReferenceContext, + value: number, + kind: "lower" | "upper", + source: "personal" | "clinical", + appliesTo: "value" | "secondary", +): SeriesReferenceLine { + const labelKey = + source === "personal" + ? kind === "upper" + ? "series.reference.targetUpper" + : "series.reference.targetLower" + : kind === "upper" + ? "series.reference.clinicalUpper" + : "series.reference.clinicalLower"; + return { value, kind, source, appliesTo, labelKey, label: ctx.t(labelKey) }; +} + +/** Both edges of a personal band. */ +function bandLines( + ctx: SeriesReferenceContext, + band: { min: number; max: number }, + appliesTo: "value" | "secondary", +): SeriesReferenceLine[] { + return [ + line(ctx, band.min, "lower", "personal", appliesTo), + line(ctx, band.max, "upper", "personal", appliesTo), + ]; +} + +export function resolveSeriesReferenceLines( + kind: SeriesKind, + ctx: SeriesReferenceContext, +): SeriesReferenceLine[] { + switch (kind) { + case "bloodPressure": { + const sys = personalBand("BLOOD_PRESSURE_SYS", ctx); + const dia = personalBand("BLOOD_PRESSURE_DIA", ctx); + return [ + ...(sys + ? bandLines(ctx, sys, "value") + : [line(ctx, ESH_HYPERTENSION_SYS, "upper", "clinical", "value")]), + ...(dia + ? bandLines(ctx, dia, "secondary") + : [ + line(ctx, ESH_HYPERTENSION_DIA, "upper", "clinical", "secondary"), + ]), + ]; + } + case "glucose": + // The series mixes every meal context, so no single context's target + // is the person's line here; the time-in-range band is. + return [ + line( + ctx, + convertGlucose(TIME_IN_RANGE_MGDL.low, ctx.glucoseUnit), + "lower", + "clinical", + "value", + ), + line( + ctx, + convertGlucose(TIME_IN_RANGE_MGDL.high, ctx.glucoseUnit), + "upper", + "clinical", + "value", + ), + ]; + case "oxygenSaturation": { + const own = personalBand("OXYGEN_SATURATION", ctx); + if (own) return bandLines(ctx, own, "value"); + return [line(ctx, SPO2_RED_FLAG_PCT, "lower", "clinical", "value")]; + } + default: { + const metric = PERSONAL_METRIC[kind]; + if (!metric) return []; + const band = personalBand(metric, ctx); + return band ? bandLines(ctx, band, "value") : []; + } + } +} diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index cf1812beb..325af4af1 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -412,7 +412,7 @@ export const derivedMetricResponse = z .record(z.string(), z.unknown()) .nullable() .describe( - "Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.", + "Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.", ), coverage: derivedCoverage, confidence: derivedConfidence diff --git a/src/lib/targets/__tests__/trend-verdict.test.ts b/src/lib/targets/__tests__/trend-verdict.test.ts new file mode 100644 index 000000000..2051265c4 --- /dev/null +++ b/src/lib/targets/__tests__/trend-verdict.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; + +import { + resolvePersonalBand, + resolveSummaryVerdicts, + resolveTrendVerdict, + trendSentimentOf, + trendSentimentOfTrend, +} from "../trend-verdict"; + +const NO_PROFILE = { dateOfBirth: null, gender: null, heightCm: null }; +const MAN_41 = { + dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), + gender: "MALE", + heightCm: 180, +}; +const OWN_BP = { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } }; + +function verdict( + key: string, + referenceValue: number | null, + thresholdsJson: unknown = null, + profile: typeof NO_PROFILE | typeof MAN_41 = NO_PROFILE, +) { + return resolveTrendVerdict(key, { profile, thresholdsJson, referenceValue }); +} + +describe("resolveTrendVerdict against the person's own target", () => { + it("calls a rising blood pressure above the target a setback", () => { + const v = verdict("BLOOD_PRESSURE_SYS", 132, OWN_BP); + expect(v).toEqual({ + direction: "up-bad", + targetPosition: "above", + basis: "personal_target", + }); + expect(trendSentimentOf(4, v)).toBe("negative"); + }); + + it("calls the same rise below the target progress", () => { + const v = verdict("BLOOD_PRESSURE_SYS", 108, OWN_BP); + expect(v).toEqual({ + direction: "up-good", + targetPosition: "below", + basis: "personal_target", + }); + expect(trendSentimentOf(4, v)).toBe("positive"); + }); + + it("holds inside the target, with the edges counted as inside", () => { + expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP)).toMatchObject({ + direction: "hold", + targetPosition: "inside", + }); + const v = verdict("BLOOD_PRESSURE_SYS", 120, OWN_BP); + expect(trendSentimentOf(0, v)).toBe("positive"); + expect(trendSentimentOf(3, v)).toBe("neutral"); + }); + + it("judges the BLOOD_PRESSURE target tile by the systolic band", () => { + expect(verdict("BLOOD_PRESSURE", 132, OWN_BP).direction).toBe("up-bad"); + }); + + it("reads a sleep target in hours against a reference in minutes", () => { + const own = { SLEEP_DURATION: { min: 7, max: 8 } }; + expect(verdict("SLEEP_DURATION", 6 * 60, own)).toMatchObject({ + direction: "up-good", + targetPosition: "below", + }); + expect(verdict("SLEEP_DURATION", 7.5 * 60, own).direction).toBe("hold"); + }); + + it("ignores a malformed target and falls back", () => { + expect( + verdict("BLOOD_PRESSURE_SYS", 132, { + BLOOD_PRESSURE_SYS: { min: 130, max: 120 }, + }), + ).toEqual({ + direction: "up-bad", + targetPosition: null, + basis: "metric_default", + }); + }); +}); + +describe("resolveTrendVerdict against a profile default", () => { + it("uses the age-based blood pressure band", () => { + expect(verdict("BLOOD_PRESSURE_SYS", 125, null, MAN_41)).toEqual({ + direction: "hold", + targetPosition: "inside", + basis: "profile_default", + }); + }); + + it("uses the age- and sex-based resting pulse band for both pulse keys", () => { + for (const key of ["PULSE", "RESTING_HEART_RATE"]) { + expect(verdict(key, 95, null, MAN_41)).toMatchObject({ + direction: "up-bad", + targetPosition: "above", + basis: "profile_default", + }); + } + }); + + it("does not call a band nobody is personalised for a profile default", () => { + // No age: the pulse band is the same for everyone. + expect(verdict("RESTING_HEART_RATE", 95)).toEqual({ + direction: "up-bad", + targetPosition: null, + basis: "metric_default", + }); + // No sex: no body fat band. + expect(verdict("BODY_FAT", 30).basis).toBe("metric_default"); + }); + + it("prefers the person's own target over the profile default", () => { + expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP, MAN_41).basis).toBe( + "personal_target", + ); + }); +}); + +describe("resolveTrendVerdict metric defaults", () => { + it.each([ + ["ACTIVITY_STEPS", "up-good"], + ["HEART_RATE_VARIABILITY", "up-good"], + ["HRV_RMSSD", "up-good"], + ["VO2_MAX", "up-good"], + ["MOOD", "up-good"], + ["BODY_FAT", "up-bad"], + ["RESTING_HEART_RATE", "up-bad"], + ["RESPIRATORY_RATE", "neutral"], + ["TOTAL_BODY_WATER", "neutral"], + ["BLOOD_GLUCOSE_FASTING", "neutral"], + ])("%s defaults to %s", (key, direction) => { + expect(verdict(key, null)).toEqual({ + direction, + targetPosition: null, + basis: "metric_default", + }); + }); + + it("keeps weight's own rule: only a stored target counts", () => { + expect(verdict("WEIGHT", 70, null, MAN_41)).toEqual({ + direction: "up-bad", + targetPosition: null, + basis: "metric_default", + }); + expect(verdict("WEIGHT", 60, { WEIGHT: { min: 65, max: 72 } })).toEqual({ + direction: "up-good", + targetPosition: "below", + basis: "personal_target", + }); + // BMI follows the weight it is derived from. + expect(verdict("BMI", 60, { WEIGHT: { min: 65, max: 72 } }).direction).toBe( + "up-good", + ); + }); +}); + +describe("trend sentiment", () => { + it("applies the noise floor and treats no verdict as neutral", () => { + const upBad = verdict("RESTING_HEART_RATE", null); + expect(trendSentimentOf(0.01, upBad)).toBe("neutral"); + expect(trendSentimentOf(-2, upBad)).toBe("positive"); + expect(trendSentimentOf(2, null)).toBe("neutral"); + }); + + it("reads a trend word the way it reads a signed change", () => { + const upGood = verdict("ACTIVITY_STEPS", null); + expect(trendSentimentOfTrend("up", upGood)).toBe("positive"); + expect(trendSentimentOfTrend("down", upGood)).toBe("negative"); + expect(trendSentimentOfTrend("flat", upGood)).toBe("neutral"); + expect(trendSentimentOfTrend("unknown", upGood)).toBe("neutral"); + expect(trendSentimentOfTrend(null, upGood)).toBe("neutral"); + const hold = verdict("BLOOD_PRESSURE_SYS", 120, OWN_BP); + expect(trendSentimentOfTrend("stable", hold)).toBe("positive"); + }); +}); + +describe("resolvePersonalBand", () => { + it("is null when neither an own target nor a personalised default exists", () => { + expect(resolvePersonalBand("ACTIVITY_STEPS", NO_PROFILE, null)).toBeNull(); + expect(resolvePersonalBand("BLOOD_PRESSURE_DIA", MAN_41, null)).toEqual({ + band: { min: 70, max: 79 }, + basis: "profile_default", + }); + }); +}); + +describe("resolveSummaryVerdicts", () => { + it("places each tile's seven-day average and adds the summaryless keys", () => { + const out = resolveSummaryVerdicts( + { + BLOOD_PRESSURE_SYS: { avg7: 132, latest: 118 }, + ACTIVITY_STEPS: { avg7: null, latest: 9000 }, + }, + { profile: NO_PROFILE, thresholdsJson: OWN_BP }, + ); + expect(out.BLOOD_PRESSURE_SYS.direction).toBe("up-bad"); + expect(out.ACTIVITY_STEPS.direction).toBe("up-good"); + expect(out.MOOD.direction).toBe("up-good"); + expect(out.BLOOD_PRESSURE_IN_TARGET.direction).toBe("up-good"); + }); +}); diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 5e7d3f3b2..4ce2e7d19 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -39,6 +39,11 @@ import { buildSleepTarget } from "./sleep-builder"; import { buildTargetPageSummary } from "./summary-builder"; import { buildVitalTargets } from "./vitals-builder"; import type { TargetItem, TargetMoodEntry, TargetValueByType } from "./types"; +import { + resolveTrendVerdict, + trendSentimentOfTrend, + type TrendVerdictContext, +} from "./trend-verdict"; import type { User } from "@/generated/prisma/client"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; @@ -362,6 +367,18 @@ export async function buildTargetsResponse(user: AuthedUser) { }), ); + // v1.43 (ios #116 item 3) — every item carries which way its change + // counts, judged with the same rule the dashboard tiles use. + const verdictTargets = withTargetVerdicts(targets, { + profile: { + heightCm, + dateOfBirth: dbUser?.dateOfBirth ?? null, + gender: dbUser?.gender ?? null, + }, + thresholdsJson: dbUser?.thresholdsJson ?? null, + }); + targets.splice(0, targets.length, ...verdictTargets); + const pageSummary = buildTargetPageSummary(targets); annotate({ action: { name: "insights.targets" }, @@ -393,3 +410,35 @@ export async function buildTargetsResponse(user: AuthedUser) { }, }; } + +/** + * Lay the verdict over each target item. The reference is the item's latest + * value, else its thirty-day average, in the unit the item is served in: + * sleep is served in hours and judged in minutes, and BMI is judged by the + * weight it is derived from. + */ +export function withTargetVerdicts( + targets: readonly TargetItem[], + ctx: Omit, +): TargetItem[] { + const weight = targets.find((item) => item.type === "WEIGHT"); + const weightReference = weight ? (weight.current ?? weight.average30) : null; + return targets.map((item) => { + const own = item.current ?? item.average30; + const reference = + item.type === "BMI" + ? weightReference + : own !== null && item.type === "SLEEP_DURATION" + ? own * 60 + : own; + const direction = resolveTrendVerdict(item.type, { + ...ctx, + referenceValue: reference, + }); + return { + ...item, + direction, + trendSentiment: trendSentimentOfTrend(item.trend, direction), + }; + }); +} diff --git a/src/lib/targets/trend-verdict.ts b/src/lib/targets/trend-verdict.ts new file mode 100644 index 000000000..22ca94065 --- /dev/null +++ b/src/lib/targets/trend-verdict.ts @@ -0,0 +1,312 @@ +/** + * Which way a change of a metric counts as progress, for this person (v1.43, + * ios #116 item 3). The generalisation of `resolveWeightTrend`. + * + * Every surface that shows a change (a dashboard arrow, a seven-day delta, a + * target tile, a chart's first-to-last change) used to carry its own fixed + * polarity: a rising blood pressure turned orange whatever the person's + * target was, and the native client kept a third copy of the table. The + * verdict is resolved here, once, and published on every route that serves a + * change; clients colour from it and never decide it themselves. + * + * The rule: + * + * - a band applies (the person's own target from `/targets`, else a + * profile-derived default: the age-based blood pressure target, the + * age- and sex-based resting pulse band, the sex-based body fat band): + * below the band → a rise is progress (`up-good`) + * above the band → a fall is progress (`up-bad`) + * inside the band → holding is progress (`hold`) + * - no band, or no value to place: the metric's own default direction. + * + * Weight keeps its own rule (`weight-trend.ts`): only a target the person set + * counts, never the height-derived band, because a band nobody chose says + * nothing about which way they want to move. + * + * Pure and client-safe: no DB read, no clock beyond the age the profile + * helpers compute. + */ +import { + getEffectiveRange, + resolveThresholdOverride, + resolveWeightTargetOverride, + type ThresholdMetric, + type UserProfileForRange, +} from "@/lib/analytics/effective-range"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { + getTrendSentiment, + type TrendDirectionSentiment, + type TrendSentimentDirection, +} from "@/lib/insights/trend-sentiment"; +import { binaryReferenceSex } from "@/lib/profile/sex"; + +import { resolveWeightTrend } from "./weight-trend"; + +export type TrendTargetPosition = "below" | "inside" | "above"; +export type TrendVerdictBasis = + "personal_target" | "profile_default" | "metric_default"; + +export interface TrendVerdict { + direction: TrendDirectionSentiment; + targetPosition: TrendTargetPosition | null; + basis: TrendVerdictBasis; +} + +export type TrendSentiment = TrendSentimentDirection; + +/** + * What a verdict can be asked for: a measurement type, or one of the derived + * keys a target tile or dashboard card carries (`BLOOD_PRESSURE` is the + * systolic side of the pair, `BMI` follows weight, `MOOD` / `MOOD_SCORE` the + * mood mean, `BLOOD_PRESSURE_IN_TARGET` and `MEDICATION_COMPLIANCE` a share). + */ +export type TrendVerdictKey = string; + +/** + * The metric's own direction when no band applies. A rise is progress for the + * capacity and activity measures, a setback for the load measures, and + * carries no judgement for everything else (body water, bone mass, breathing + * rate, skin temperature, intake, …). + */ +const METRIC_DEFAULT_DIRECTION: Readonly< + Record> +> = { + ACTIVITY_STEPS: "up-good", + HEART_RATE_VARIABILITY: "up-good", + HRV_RMSSD: "up-good", + VO2_MAX: "up-good", + OXYGEN_SATURATION: "up-good", + MUSCLE_MASS: "up-good", + SLEEP_DURATION: "up-good", + SIX_MINUTE_WALK_DISTANCE: "up-good", + GRIP_STRENGTH: "up-good", + MOOD: "up-good", + MOOD_SCORE: "up-good", + BLOOD_PRESSURE_IN_TARGET: "up-good", + MEDICATION_COMPLIANCE: "up-good", + RESTING_HEART_RATE: "up-bad", + PULSE: "up-bad", + BODY_FAT: "up-bad", + BLOOD_PRESSURE: "up-bad", + BLOOD_PRESSURE_SYS: "up-bad", + BLOOD_PRESSURE_DIA: "up-bad", + BODY_TEMPERATURE: "up-bad", + BLOOD_GLUCOSE: "up-bad", +}; + +/** The `/targets` threshold a key is judged against, when it has one. */ +const THRESHOLD_FOR_KEY: Readonly> = { + BLOOD_PRESSURE: "BLOOD_PRESSURE_SYS", + BLOOD_PRESSURE_SYS: "BLOOD_PRESSURE_SYS", + BLOOD_PRESSURE_DIA: "BLOOD_PRESSURE_DIA", + // The resting pulse card and the dashboard pulse tile share one target. + PULSE: "PULSE", + RESTING_HEART_RATE: "PULSE", + BODY_FAT: "BODY_FAT", + SLEEP_DURATION: "SLEEP_DURATION", + ACTIVITY_STEPS: "ACTIVITY_STEPS", + TOTAL_BODY_WATER: "TOTAL_BODY_WATER", + BONE_MASS: "BONE_MASS", + OXYGEN_SATURATION: "OXYGEN_SATURATION", +}; + +/** + * Stored values the threshold is not written in. Sleep is stored in minutes + * and targeted in hours; every other threshold shares the stored unit. + */ +const TO_THRESHOLD_UNIT: Readonly number>> = { + SLEEP_DURATION: (minutes) => minutes / 60, +}; + +export interface TrendVerdictContext { + /** The profile of the RECORD on screen, not of the viewer. */ + profile: UserProfileForRange; + /** `User.thresholdsJson` of the same record, unparsed. */ + thresholdsJson: unknown; + /** + * The value placed against the band, in the stored (canonical) unit of the + * key: usually the seven-day average, else the latest reading. Null when + * there is nothing to place, which leaves the metric's default. + */ + referenceValue: number | null; +} + +/** + * Whether the profile carries what the default band of this metric is + * personalised from. Without it the band is the same for everyone, which is a + * metric default and not a statement about this person. + */ +function hasProfileDefault( + metric: ThresholdMetric, + profile: UserProfileForRange, +): boolean { + switch (metric) { + case "BLOOD_PRESSURE_SYS": + case "BLOOD_PRESSURE_DIA": + return profile.dateOfBirth != null; + case "PULSE": { + const age = getAgeFromDateOfBirth(profile.dateOfBirth ?? null); + return age != null && age >= 20; + } + case "BODY_FAT": + return binaryReferenceSex(profile.gender) !== null; + default: + return false; + } +} + +/** + * The band a person's values are judged against: their own target, else the + * profile default when the profile carries what it is personalised from. + * Null when neither applies. Shared with the chart reference lines, so the + * line drawn and the verdict coloured come from the same band. + */ +export function resolvePersonalBand( + metric: ThresholdMetric, + profile: UserProfileForRange, + thresholdsJson: unknown, +): { + band: { min: number; max: number }; + basis: Exclude; +} | null { + const own = resolveThresholdOverride(thresholdsJson, metric); + if (own) return { band: own, basis: "personal_target" }; + if (!hasProfileDefault(metric, profile)) return null; + const range = getEffectiveRange(metric, profile, null).range; + return range + ? { + band: { min: range.greenMin, max: range.greenMax }, + basis: "profile_default", + } + : null; +} + +function metricDefault(key: string): TrendVerdict { + return { + direction: METRIC_DEFAULT_DIRECTION[key] ?? "neutral", + targetPosition: null, + basis: "metric_default", + }; +} + +/** Place a value against a band; the edges count as inside. */ +export function placeAgainstBand( + band: { min: number; max: number }, + value: number, +): TrendTargetPosition { + if (value < band.min) return "below"; + if (value > band.max) return "above"; + return "inside"; +} + +const DIRECTION_BY_POSITION: Record< + TrendTargetPosition, + TrendDirectionSentiment +> = { + below: "up-good", + above: "up-bad", + inside: "hold", +}; + +export function resolveTrendVerdict( + key: TrendVerdictKey, + ctx: TrendVerdictContext, +): TrendVerdict { + const reference = + ctx.referenceValue !== null && Number.isFinite(ctx.referenceValue) + ? ctx.referenceValue + : null; + + // Weight, and BMI which moves exactly when weight does: only the person's + // own target counts (see the module comment). For `BMI` the reference is + // the WEIGHT it is derived from, in kg, since the target is a weight. + if (key === "WEIGHT" || key === "BMI") { + const target = resolveWeightTargetOverride(ctx.thresholdsJson); + const judgement = resolveWeightTrend(target, reference); + return { + direction: judgement.direction, + targetPosition: judgement.targetPosition, + basis: + judgement.targetPosition !== null + ? "personal_target" + : "metric_default", + }; + } + + const metric = THRESHOLD_FOR_KEY[key]; + if (!metric || reference === null) return metricDefault(key); + + const personal = resolvePersonalBand(metric, ctx.profile, ctx.thresholdsJson); + if (!personal) return metricDefault(key); + + const toUnit = TO_THRESHOLD_UNIT[key]; + const position = placeAgainstBand( + personal.band, + toUnit ? toUnit(reference) : reference, + ); + return { + direction: DIRECTION_BY_POSITION[position], + targetPosition: position, + basis: personal.basis, + }; +} + +/** + * A delivered change judged by a verdict, with the noise floor the arrows + * have always used (`getTrendSentiment`). No verdict reads neutral. + */ +export function trendSentimentOf( + change: number | null | undefined, + verdict: TrendVerdict | null, +): TrendSentiment { + return getTrendSentiment(change, verdict?.direction ?? "neutral"); +} + +/** + * The same judgement for a change that is only known by its direction (the + * `trend` of a dashboard card or a target tile). A flat or unknown trend is + * no change: inside a band that is the one case where it is news. + */ +export function trendSentimentOfTrend( + trend: "up" | "down" | "flat" | "stable" | "unknown" | null | undefined, + verdict: TrendVerdict | null, +): TrendSentiment { + if (trend === "up") return trendSentimentOf(1, verdict); + if (trend === "down") return trendSentimentOf(-1, verdict); + if (trend === "flat" || trend === "stable") + return trendSentimentOf(0, verdict); + return "neutral"; +} + +/** Keys that carry a verdict without a measurement summary of their own. */ +export const SUMMARYLESS_VERDICT_KEYS = [ + "MOOD", + "BLOOD_PRESSURE_IN_TARGET", +] as const; + +/** + * One verdict per summarised type, each placed with the value its tile shows + * as the reference (the seven-day average, else the latest reading), plus the + * summaryless keys at their metric default. The dashboard snapshot publishes + * this as `tiles.directions`. + */ +export function resolveSummaryVerdicts( + summaries: Readonly< + Record + >, + ctx: Omit, +): Record { + const out: Record = {}; + for (const [type, summary] of Object.entries(summaries)) { + if (!summary) continue; + out[type] = resolveTrendVerdict(type, { + ...ctx, + referenceValue: summary.avg7 ?? summary.latest ?? null, + }); + } + for (const key of SUMMARYLESS_VERDICT_KEYS) { + out[key] ??= resolveTrendVerdict(key, { ...ctx, referenceValue: null }); + } + return out; +} diff --git a/src/lib/targets/types.ts b/src/lib/targets/types.ts index a92e3a1db..5ae330480 100644 --- a/src/lib/targets/types.ts +++ b/src/lib/targets/types.ts @@ -10,6 +10,7 @@ import type { } from "@/lib/analytics/compliance"; import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range"; +import type { TrendSentiment, TrendVerdict } from "./trend-verdict"; export type TargetTrend = "up" | "down" | "stable" | null; export type DayBand = "in" | "near" | "out"; @@ -35,6 +36,13 @@ export interface TargetItem extends TargetConsistency { range: { min: number; max: number } | null; classification: { category: string; color: string } | null; source: string; + /** + * v1.43 (ios #116 item 3) — which way a change of this target counts for + * the person (`src/lib/targets/trend-verdict.ts`), and that verdict + * applied to `trend`. Set on every item by `buildTargetsResponse`. + */ + direction?: TrendVerdict | null; + trendSentiment?: TrendSentiment | null; details?: { medications?: Array<{ name: string; From a0f6b89e92752eebb161dfadf322619275b70ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 00:18:35 +0200 Subject: [PATCH 4/5] feat(measurements): take hourly sums of summed types into the intraday profile and a backfill trigger that moves no live stamp; fix(backup): restore qualitative lab markers --- docs/api/openapi.yaml | 456 +++++++++++------- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- prisma/schema.prisma | 24 +- .../client-threshold-literal-guard.test.ts | 356 ++++++++++++++ .../pulse-day-mean-reader-guard.test.ts | 4 + .../resolved-field-consumer-guard.test.ts | 131 +++++ src/app/api/auth/me/route.ts | 4 +- .../healthkit/__tests__/route.test.ts | 62 +++ src/app/api/measurements/batch/route.ts | 226 ++++++++- .../bulk/__tests__/dose-arrival-scope.test.ts | 35 +- src/app/api/medications/intake/bulk/route.ts | 13 +- .../__tests__/apple-health-card.test.tsx | 25 + .../integrations/apple-health-card.tsx | 24 +- .../__tests__/intraday-profile-backup.test.ts | 64 +++ src/lib/export/intraday-profile-backup.ts | 37 +- .../integrations/connection-health-load.ts | 8 +- src/lib/integrations/healthkit-type-sync.ts | 99 +++- .../__tests__/intraday-hour-buckets.test.ts | 160 ++++++ .../intraday-cumulative-profile.ts | 45 +- src/lib/measurements/intraday-hour-buckets.ts | 271 +++++++++++ src/lib/openapi/routes/cycle.ts | 22 +- src/lib/openapi/routes/measurements.ts | 9 +- src/lib/openapi/routes/medications/paths.ts | 4 +- src/lib/validations/__tests__/backup.test.ts | 63 +++ src/lib/validations/backup.ts | 55 ++- .../backup-qualitative-lab-restore.test.ts | 137 ++++++ tests/integration/backup-round-trip.test.ts | 15 +- .../hour-buckets-and-backfill.test.ts | 384 +++++++++++++++ 34 files changed, 2468 insertions(+), 286 deletions(-) create mode 100644 src/__tests__/client-threshold-literal-guard.test.ts create mode 100644 src/lib/measurements/__tests__/intraday-hour-buckets.test.ts create mode 100644 src/lib/measurements/intraday-hour-buckets.ts create mode 100644 tests/integration/backup-qualitative-lab-restore.test.ts create mode 100644 tests/integration/hour-buckets-and-backfill.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 27982c45f..3c7e6bc22 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -447,17 +447,36 @@ paths: tags: - Cycle summary: Soft-delete a cycle day-log (v1.15.0) - description: Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. - Idempotent. + description: "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. A + day that opened a cycle takes that cycle with it, which gives back any start that cycle had folded in. 204, or + 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent." parameters: - in: path name: id schema: type: string required: true + - name: Prefer + in: header + required: false + schema: + type: string + example: return=representation + description: "v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed + (`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route + answers 204 with no body, as before." responses: "200": - description: "v1.43 — with `Prefer: return=representation`: what the delete changed." + description: "v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a + day that opened no cycle or a repeated delete) and the starts the removal gave back." + headers: + Preference-Applied: + required: true + description: "The route honoured `Prefer: return=representation`." + schema: + type: string + const: return=representation + description: "The route honoured `Prefer: return=representation`." content: application/json: schema: @@ -618,16 +637,35 @@ paths: tags: - Cycle summary: Soft-delete a menstrual cycle (v1.15.0) - description: Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent. + description: "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. Any start this cycle had folded + in stands again. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent." parameters: - in: path name: id schema: type: string required: true + - name: Prefer + in: header + required: false + schema: + type: string + example: return=representation + description: "v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed + (`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route + answers 204 with no body, as before." responses: "200": - description: "v1.43 — with `Prefer: return=representation`: what the delete changed." + description: "v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a + day that opened no cycle or a repeated delete) and the starts the removal gave back." + headers: + Preference-Applied: + required: true + description: "The route honoured `Prefer: return=representation`." + schema: + type: string + const: return=representation + description: "The route honoured `Prefer: return=representation`." content: application/json: schema: @@ -4733,59 +4771,6 @@ paths: "403": *a13 "422": *a4 "429": *a5 - /api/personal-records: - get: - tags: - - Awards - summary: List detected personal bests - description: >- - The record's personal bests, newest achievement first. Read-only — rows come from the detection worker and no - endpoint creates or edits one. - - - Both query parameters are validated and a bad value is a 422. They were forgiving until this release, and in one - direction that was worse than merely quiet: dropping an unrecognised `metricType` WIDENED the read to every - metric, so a typo returned more rows than the caller asked for. The `limit` ceiling refuses rather than clamps - for the same reason. - - - Delegable on a `measurements` grant. - parameters: - - name: metricType - in: query - required: false - schema: - type: string - description: A `MeasurementType` to filter to. A value outside the enum is refused with 422 — it is not dropped, because - dropping it would widen the read rather than narrow it. - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - maximum: 500 - default: 100 - description: Page cap, default 100, ceiling 500. A non-integer, a zero, a negative value or anything above 500 is - refused with 422 rather than corrected — serving a different number of rows than were asked for is another - difference nobody is told about. - responses: - "200": - description: The record's personal bests, newest first. - content: - application/json: - schema: - $ref: "#/components/schemas/ListPersonalRecordsEnvelope" - "401": *a1 - "403": *a11 - "422": - description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = - `personal_records.invalid_query`, with the offending path under `details.issues`." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 /api/workouts: get: tags: @@ -22352,6 +22337,59 @@ paths: anyOf: - $ref: "#/components/schemas/HealthAdminResponse" - $ref: "#/components/schemas/HealthPublicResponse" + /api/personal-records: + get: + tags: + - Awards + summary: List detected personal bests + description: >- + The record's personal bests, newest achievement first. Read-only — rows come from the detection worker and no + endpoint creates or edits one. + + + Both query parameters are validated and a bad value is a 422. They were forgiving until this release, and in one + direction that was worse than merely quiet: dropping an unrecognised `metricType` WIDENED the read to every + metric, so a typo returned more rows than the caller asked for. The `limit` ceiling refuses rather than clamps + for the same reason. + + + Delegable on a `measurements` grant. + parameters: + - name: metricType + in: query + required: false + schema: + type: string + description: A `MeasurementType` to filter to. A value outside the enum is refused with 422 — it is not dropped, because + dropping it would widen the read rather than narrow it. + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + description: Page cap, default 100, ceiling 500. A non-integer, a zero, a negative value or anything above 500 is + refused with 422 rather than corrected — serving a different number of rows than were asked for is another + difference nobody is told about. + responses: + "200": + description: The record's personal bests, newest first. + content: + application/json: + schema: + $ref: "#/components/schemas/ListPersonalRecordsEnvelope" + "401": *a1 + "403": *a11 + "422": + description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = + `personal_records.invalid_query`, with the offending path under `details.issues`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 /api/gamification/achievements: get: tags: @@ -26382,19 +26420,24 @@ components: $ref: "#/components/schemas/AppleHealthBatchEntry" syncTrigger: description: 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or - a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the - account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both - readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the - batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on - each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` - trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and - backward-compatible: pre-#66 clients omit it.' + a user-initiated `manual` "Sync all" run, since v1.42). `backfill` (v1.43, ios #77; send it only when `GET + /api/auth/me` reports `features.syncTriggerBackfill: true`, an older server refuses the whole batch with + 422) names a history catch-up: it moves only `syncProgress` and the per-type `lastBackfillAt` in `GET + /api/integrations/healthkit`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt`, `lastSyncTrigger`, + `lastBackgroundSyncAt` or the sync checkpoint. Recorded on the ingest wide event and kept on the account as + `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from + `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried + (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each + `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It + does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 + clients omit it.' type: string enum: - foreground - background - push - manual + - backfill required: - entries description: Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`. @@ -26459,7 +26502,7 @@ components: minLength: 1 maxLength: 120 description: >- - Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes: + Dedup key for the `(userId, type, source, externalId)` composite index. Four shapes: • `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row is immutable. @@ -26474,6 +26517,17 @@ components: returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`. + • `stats::T` — v1.43 (ios #77), only when `GET /api/auth/me` + reports `features.cumulativeHourBuckets: true`: one local hour's SUM of steps, active energy, walking and + running distance or flights climbed, the date and the zero-padded hour being the local hour in the account's + zone (`timezone` on `/api/auth/me`). `value` is the hour's sum in the unit of the day form, `startDate` the + first instant of that local hour, `endDate` one hour later. Such a row is never a measurement and never + changes the day total, which stays the day form: it feeds the intraday profile behind the same-time baseline + (a day with at least four posted hours is built from them). A re-post overwrites (`updated`). Skipped with + `hour_bucket_misaligned` when `startDate` is not the start of that local hour, + `hour_bucket_unsupported_type` for any other identifier or a `MANUAL` / bridge entry, `hour_bucket_too_old` + when the day is more than 400 days back. + The id MUST be stable across app restarts. An object description or memory address (``, `0x12ab34cd`) is a new value on every launch, so it can never match its own earlier row; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands. @@ -26977,17 +27031,20 @@ components: items: $ref: "#/components/schemas/BulkMedicationIntakeEntry" syncTrigger: - description: 'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client - for this sync. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` - or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET + description: "Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client + for this sync. `backfill` (v1.43, only when `features.syncTriggerBackfill` is `true`) marks a history + catch-up, which moves only the `MEDICATION_DOSE` entry's `lastBackfillAt` and leaves its live stamps and the + sync checkpoint alone. When the batch carries entries with `source: \"APPLE_HEALTH\"` that land (`inserted`, + `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. - It changes no dose row. Optional; omitted records a null trigger.' + It changes no dose row. Optional; omitted records a null trigger." type: string enum: - foreground - background - push - manual + - backfill required: - entries description: iOS SyncMode bulk intake backfill. 1–500 entries per call; idempotent via `Idempotency-Key` and per-entry @@ -36037,7 +36094,8 @@ components: - type: object properties: restored: - description: v1.43 — starts given back because this edit cleared the flow on a start day. + description: v1.43 — starts given back because this edit cleared the flow on a start day; empty when it gave back none. + Absent on older servers. type: array items: $ref: "#/components/schemas/CycleRestoredStart" @@ -36193,7 +36251,8 @@ components: - $ref: "#/components/schemas/CycleDayLogDTO" - type: "null" absorbed: - description: 'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.' + description: 'v1.43 — a later start within the period length that this start folded in (it stops being a start of its + own), or null. Present on every `action: "start"` answer; null on `end`.' anyOf: - $ref: "#/components/schemas/CycleRestoredStart" - type: "null" @@ -39958,7 +40017,6 @@ components: - type: "null" unit: type: string - minLength: 1 referenceLow: anyOf: - type: number @@ -40041,7 +40099,6 @@ components: minLength: 1 unit: type: string - minLength: 1 lowerBound: anyOf: - type: number @@ -41451,6 +41508,24 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + hourlyIncrements: + default: [] + type: array + items: + type: number + basis: + default: PER_SAMPLE + type: string + enum: + - PER_SAMPLE + - HOUR_BUCKETS + bucketCount: + default: null + anyOf: + - type: integer + minimum: 0 + maximum: 9007199254740991 + - type: "null" timezone: type: string minLength: 1 @@ -41466,6 +41541,9 @@ components: - hourlyCumulative - dayTotal - sampleCount + - hourlyIncrements + - basis + - bucketCount - timezone additionalProperties: {} healthScoreRecords: @@ -46630,7 +46708,9 @@ components: reason: description: "Why a row was `skipped` or a `duplicate`. `folded_window` (since v1.42): a raw sample of a dense or mean type whose local day lies before the fold boundary and whose hour or day a live `stats:` row of the same - source already covers. Nothing to retry; treat it like any duplicate." + source already covers. Nothing to retry; treat it like any duplicate. `hour_bucket_misaligned`, + `hour_bucket_unsupported_type`, `hour_bucket_too_old` (since v1.43): an hourly + `stats::T` row the server refused, see `externalId`; permanent for the row as sent." type: string convertedValue: description: "Only on a `value_out_of_range` skip (since v1.39.1): the entry's value after conversion into the stored @@ -47240,115 +47320,6 @@ components: description: "Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type." - ListPersonalRecordsEnvelope: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/PersonalRecord" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - PersonalRecord: - type: object - properties: - id: - type: string - userId: - type: string - description: The RECORD's user id — under a delegated read this is the owner, not the caller. - metricType: - $ref: "#/components/schemas/MeasurementType" - metricSlot: - anyOf: - - type: string - - type: "null" - description: Per-sport bucket for workout-driven bests (e.g. "running_5km_time"). Null for a plain measurement-driven - best, where the metric type alone is the dimension. - direction: - type: string - enum: - - MAX - - MIN - description: Which way is better. MAX for steps / VO2 max / HRV / distance; MIN for resting heart rate, body fat, audio - exposure. - value: - type: number - unit: - type: string - achievedAt: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - sourceMeasurementId: - anyOf: - - type: string - - type: "null" - description: The measurement that achieved the record. Nulled rather than cascaded when that measurement is later - deleted, so the historical fact survives the reading behind it. - source: - $ref: "#/components/schemas/MeasurementSource" - externalId: - anyOf: - - type: string - - type: "null" - createdAt: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - benchmark: - description: v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null - otherwise. - anyOf: - - $ref: "#/components/schemas/RecordBenchmark" - - type: "null" - required: - - id - - userId - - metricType - - metricSlot - - direction - - value - - unit - - achievedAt - - sourceMeasurementId - - source - - externalId - - createdAt - additionalProperties: false - description: One detected personal best. Rows are written by the detection worker, never by a client — there is no write - endpoint on this resource. - RecordBenchmark: - type: object - properties: - low: - type: number - high: - type: number - unit: - type: string - source: - type: string - sourceLabel: - type: string - required: - - low - - high - - unit - - source - - sourceLabel - additionalProperties: false - description: An age- and sex-aware comparison band from a cited norm, in the record's unit. ListWorkoutsResponse: type: object properties: @@ -63746,9 +63717,11 @@ components: `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, - sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The - remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside - their engines under `src/lib/insights/derived/`. + sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline + and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with + `center` placed against their own target or a profile default. The remaining metrics' value objects are not + modelled here yet; their shapes are TypeScript interfaces beside their engines under + `src/lib/insights/derived/`. coverage: $ref: "#/components/schemas/DerivedCoverage" confidence: @@ -82248,6 +82221,115 @@ components: additionalProperties: false description: "What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell." + ListPersonalRecordsEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/PersonalRecord" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PersonalRecord: + type: object + properties: + id: + type: string + userId: + type: string + description: The RECORD's user id — under a delegated read this is the owner, not the caller. + metricType: + $ref: "#/components/schemas/MeasurementType" + metricSlot: + anyOf: + - type: string + - type: "null" + description: Per-sport bucket for workout-driven bests (e.g. "running_5km_time"). Null for a plain measurement-driven + best, where the metric type alone is the dimension. + direction: + type: string + enum: + - MAX + - MIN + description: Which way is better. MAX for steps / VO2 max / HRV / distance; MIN for resting heart rate, body fat, audio + exposure. + value: + type: number + unit: + type: string + achievedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + sourceMeasurementId: + anyOf: + - type: string + - type: "null" + description: The measurement that achieved the record. Nulled rather than cascaded when that measurement is later + deleted, so the historical fact survives the reading behind it. + source: + $ref: "#/components/schemas/MeasurementSource" + externalId: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + benchmark: + description: v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null + otherwise. + anyOf: + - $ref: "#/components/schemas/RecordBenchmark" + - type: "null" + required: + - id + - userId + - metricType + - metricSlot + - direction + - value + - unit + - achievedAt + - sourceMeasurementId + - source + - externalId + - createdAt + additionalProperties: false + description: One detected personal best. Rows are written by the detection worker, never by a client — there is no write + endpoint on this resource. + RecordBenchmark: + type: object + properties: + low: + type: number + high: + type: number + unit: + type: string + source: + type: string + sourceLabel: + type: string + required: + - low + - high + - unit + - source + - sourceLabel + additionalProperties: false + description: An age- and sex-aware comparison band from a cited norm, in the record's unit. AchievementsEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index f30261f62..4179ec1a9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7159,7 +7159,8 @@ "lastReceived": "Zuletzt empfangen {relative}", "lastNewValue": "Letzter neuer Wert {relative}", "lastBackground": "Zuletzt im Hintergrund {relative}", - "never": "Noch nicht empfangen" + "never": "Noch nicht empfangen", + "lastBackfill": "Verlauf nachgeladen {relative}" } }, "garminInfo": { diff --git a/messages/en.json b/messages/en.json index 146ad753d..d525113dd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7159,7 +7159,8 @@ "lastReceived": "Last received {relative}", "lastNewValue": "Last new value {relative}", "lastBackground": "Last in background {relative}", - "never": "Not received yet" + "never": "Not received yet", + "lastBackfill": "History caught up {relative}" } }, "garminInfo": { diff --git a/messages/es.json b/messages/es.json index 8f07b72b5..09e4fef90 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7159,7 +7159,8 @@ "lastReceived": "Última recepción {relative}", "lastNewValue": "Último valor nuevo {relative}", "lastBackground": "Última vez en segundo plano {relative}", - "never": "Aún no recibido" + "never": "Aún no recibido", + "lastBackfill": "Historial recuperado {relative}" } }, "garminInfo": { diff --git a/messages/fr.json b/messages/fr.json index 1eca293b5..5af0ba276 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7159,7 +7159,8 @@ "lastReceived": "Dernière réception {relative}", "lastNewValue": "Dernière nouvelle valeur {relative}", "lastBackground": "Dernière fois en arrière-plan {relative}", - "never": "Pas encore reçu" + "never": "Pas encore reçu", + "lastBackfill": "Historique rattrapé {relative}" } }, "garminInfo": { diff --git a/messages/it.json b/messages/it.json index 9fc11b0b5..2db5b18f7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7159,7 +7159,8 @@ "lastReceived": "Ultima ricezione {relative}", "lastNewValue": "Ultimo nuovo valore {relative}", "lastBackground": "Ultima volta in background {relative}", - "never": "Non ancora ricevuto" + "never": "Non ancora ricevuto", + "lastBackfill": "Storico recuperato {relative}" } }, "garminInfo": { diff --git a/messages/ko.json b/messages/ko.json index 09171a7a7..ac172d3c7 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7159,7 +7159,8 @@ "lastReceived": "마지막 수신 {relative}", "lastNewValue": "마지막 새 값 {relative}", "lastBackground": "마지막 백그라운드 수신 {relative}", - "never": "아직 수신되지 않음" + "never": "아직 수신되지 않음", + "lastBackfill": "기록 보충 {relative}" } }, "garminInfo": { diff --git a/messages/pl.json b/messages/pl.json index cb5a9a4b5..d35fe1131 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7159,7 +7159,8 @@ "lastReceived": "Ostatnio odebrano {relative}", "lastNewValue": "Ostatnia nowa wartość {relative}", "lastBackground": "Ostatnio w tle {relative}", - "never": "Jeszcze nie odebrano" + "never": "Jeszcze nie odebrano", + "lastBackfill": "Historia uzupełniona {relative}" } }, "garminInfo": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f423e3f64..1285da288 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2334,16 +2334,18 @@ model IntradayCumulativeProfile { /// two rows is not a shape; the baseline reader uses this to refuse such /// a day rather than let it drag the median. sampleCount Int @map("sample_count") - /// @pending-consumer(#v143-B9) v1.43 (#77) — per-hour increments posted as - /// `stats::T` rows, 24 slots in canonical units. Empty - /// for a profile folded from per-sample rows. + /// v1.43 (#77) — per-hour increments posted as + /// `stats::T` rows (`intraday-hour-buckets.ts`), 24 + /// slots in canonical units, -1 for an hour not posted. Empty for a day + /// the phone never posted hours for. hourlyIncrements Float[] @map("hourly_increments") - /// @pending-consumer(#v143-B9) What `hourlyCumulative` was built from: - /// `PER_SAMPLE` (the fold of raw rows) or `HOUR_BUCKETS` (the posted hour - /// rows). CHECK in migration 0408. + /// What `hourlyCumulative` was built from: `PER_SAMPLE` (the fold of raw + /// rows) or `HOUR_BUCKETS` (the posted hours, once a day holds four). + /// CHECK in migration 0408. Read by the profile reader and the drain's + /// merge, which leaves a bucket-built day alone. basis String @default("PER_SAMPLE") - /// @pending-consumer(#v143-B9) How many hour buckets backed a - /// `HOUR_BUCKETS` profile; NULL for `PER_SAMPLE`. + /// How many hours the phone posted for the day; NULL when it posted none. + /// Below four the day keeps the per-sample fold (or no curve yet). bucketCount Int? @map("bucket_count") /// The IANA zone the day was bucketed in. A user who later moves zones /// leaves behind profiles cut on the old clock; carrying the zone lets @@ -4044,7 +4046,11 @@ model HealthKitTypeSync { /// A `MeasurementType` name for rows the measurement batch writes, or /// `MEDICATION_DOSE` for Apple Health doses the intake bulk route /// receives. Text, not the enum, since v1.42.4 (migration 0401) because a - /// dose is not a measurement type. + /// dose is not a measurement type. Since v1.43 (ios #77) a + /// `backfill:` row records when a `syncTrigger: "backfill"` batch + /// last carried the type (its `lastReceivedAt`, every other column NULL), + /// so a catch-up leaves the live row of the type untouched; live readers + /// skip the prefix (`healthkit-type-sync.ts`). type String /// Last batch that carried this type. lastReceivedAt DateTime @map("last_received_at") diff --git a/src/__tests__/client-threshold-literal-guard.test.ts b/src/__tests__/client-threshold-literal-guard.test.ts new file mode 100644 index 000000000..3ac062f5e --- /dev/null +++ b/src/__tests__/client-threshold-literal-guard.test.ts @@ -0,0 +1,356 @@ +/** + * Client code holds no threshold or polarity table of its own (v1.43, ios + * #116 items 3 and 4). + * + * Which way a change counts and where a chart's reference lines sit depend on + * the person: their own target, their age, their sex, their glucose unit. The + * server resolves both (`src/lib/targets/trend-verdict.ts`, + * `src/lib/measurements/series-reference-lines.ts`) and publishes them on the + * dashboard snapshot (`tiles.directions`), the vitals baseline (`direction`), + * the summary and target reads and the series read. A client that keeps its + * own copy answers for an imaginary average person: a rising blood pressure + * painted as a setback for someone below their target, a 140 mmHg line on a + * chart whose person has a band of 115 to 125. + * + * Four shapes are refused in client code (`src/components`, `src/hooks`, and + * every `"use client"` module under `src/app` outside the API): + * + * 1. a `directionSentiment` given as a string literal: the tile must read + * the resolved verdict; + * 2. a `ReferenceLine` / `ReferenceArea` drawn at a number literal: a line + * has to come from data the server resolved; + * 3. a polarity table, i.e. a map or set of metric names to `up-good` / + * `up-bad`, or a `…Sentiment(type)` function deciding one; + * 4. an import of the server's resolvers into the browser (a type import + * is fine). + * + * The guard cannot pass by reading nothing: the walk carries a floor, every + * rule's matcher is proven against a sample of the shape it exists to catch, + * every allowlist entry must still match with its exact count, and the rules + * must see at least one real match across the tree. + * + * Its limit: it reads the source, not the running page. A polarity computed + * from a variable that happens to hold a literal is not seen; review catches + * that. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { stripComments, walkSourceFiles } from "./helpers/source-files"; + +const ROOT = process.cwd(); + +interface ClientFile { + rel: string; + source: string; +} + +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(): ClientFile[] { + const roots: Array<{ dir: string; floor: number }> = [ + { dir: "src/components", floor: 900 }, + { dir: "src/hooks", floor: 50 }, + { dir: "src/app", floor: 900 }, + ]; + const out: ClientFile[] = []; + 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, source }); + } + } + return out; +} + +function parse(name: string, source: string): ts.SourceFile { + return ts.createSourceFile( + name, + source, + ts.ScriptTarget.Latest, + true, + name.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +/** The literal text a JSX attribute initialiser carries, if it is one. */ +function literalOf(init: ts.JsxAttributeValue | undefined): string | null { + if (!init) return null; + if (ts.isStringLiteral(init)) return init.text; + if (ts.isJsxExpression(init) && init.expression) { + const e = init.expression; + if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) { + return e.text; + } + if (ts.isNumericLiteral(e)) return e.text; + if ( + ts.isPrefixUnaryExpression(e) && + e.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(e.operand) + ) { + return `-${e.operand.text}`; + } + } + return null; +} + +function jsxAttributes( + file: ts.SourceFile, + visit: ( + tag: string, + name: string, + init: ts.JsxAttributeValue | undefined, + node: ts.Node, + ) => void, +): void { + const walk = (node: ts.Node) => { + if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) { + const tag = node.tagName.getText(file); + for (const attr of node.attributes.properties) { + if (!ts.isJsxAttribute(attr)) continue; + visit(tag, attr.name.getText(file), attr.initializer, attr); + } + } + ts.forEachChild(node, walk); + }; + walk(file); +} + +function lineOf(file: ts.SourceFile, node: ts.Node): number { + return file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1; +} + +/** Rule 1 — `directionSentiment` given as a literal. */ +function literalDirections(name: string, source: string): string[] { + const file = parse(name, source); + const hits: string[] = []; + jsxAttributes(file, (tag, attr, init, node) => { + if (attr !== "directionSentiment") return; + const literal = literalOf(init); + if (literal !== null) { + hits.push( + `${name}:${lineOf(file, node)}: <${tag} directionSentiment="${literal}">`, + ); + } + }); + return hits; +} + +const LINE_TAGS = new Set(["ReferenceLine", "ReferenceArea"]); +const LINE_ATTRS = new Set(["y", "y1", "y2"]); + +/** Rule 2 — a chart reference drawn at a number literal. */ +function literalLines(name: string, source: string): string[] { + const file = parse(name, source); + const hits: string[] = []; + jsxAttributes(file, (tag, attr, init, node) => { + if (!LINE_TAGS.has(tag) || !LINE_ATTRS.has(attr)) return; + const literal = literalOf(init); + if (literal !== null && /^-?\d/.test(literal)) { + hits.push(`${name}:${lineOf(file, node)}: <${tag} ${attr}={${literal}}>`); + } + }); + return hits; +} + +/** Rule 3 — a polarity table: line-shaped, over comment-stripped code. */ +const POLARITY_PATTERNS: RegExp[] = [ + // `RESTING_HEART_RATE: "up-bad"` / `["HRV", "up-good"]` + /\b[A-Z][A-Z0-9_]{2,}["']?\s*[:,]\s*["']up-(good|bad)["']/, + // `const UP_BAD_VITALS = new Set([...])` + /\b(UP_GOOD|UP_BAD)_[A-Z0-9_]+\s*=/, + // `function vitalSentiment(type: string)` + /function\s+\w*Sentiment\s*\(\s*(type|metric|kind)\b/, +]; +const POLARITY_SAMPLES = [ + 'const POLARITY = { RESTING_HEART_RATE: "up-bad" };', + 'new Map([["HEART_RATE_VARIABILITY", "up-good"]])', + "const UP_BAD_VITALS = new Set(['RESTING_HEART_RATE']);", + "function vitalSentiment(type: string): TrendDirectionSentiment {", +]; + +function polarityTables(name: string, source: string): string[] { + const hits: string[] = []; + stripComments(source) + .split("\n") + .forEach((line, index) => { + if (POLARITY_PATTERNS.some((p) => p.test(line))) { + hits.push(`${name}:${index + 1}: ${line.trim()}`); + } + }); + return hits; +} + +/** Rule 4 — the server's resolvers, imported into the browser. */ +const SERVER_RESOLVERS = [ + "@/lib/targets/trend-verdict", + "@/lib/measurements/series-reference-lines", + "@/lib/measurements/series-change", + "@/lib/personal-records/record-benchmark", + "@/lib/insights/mood-pattern-cards", +]; + +function resolverImports(name: string, source: string): string[] { + const file = parse(name, source); + const hits: string[] = []; + for (const statement of file.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const from = (statement.moduleSpecifier as ts.StringLiteral).text; + if (!SERVER_RESOLVERS.includes(from)) continue; + if (statement.importClause?.isTypeOnly) continue; + hits.push(`${name}:${lineOf(file, statement)}: import from "${from}"`); + } + return hits; +} + +interface Rule { + name: string; + find: (name: string, source: string) => string[]; + /** Sources that must produce at least one hit, proving the matcher. */ + samples: Array<{ name: string; source: string }>; + /** File → exact hit count it may have, with the reason. */ + allow: Record; +} + +const RULES: Rule[] = [ + { + name: "a literal directionSentiment", + find: literalDirections, + samples: [ + { + name: "sample.tsx", + source: 'const a = ;', + }, + { + name: "sample.tsx", + source: 'const a = ;', + }, + ], + allow: { + "src/components/insights/derived/vitals-dashboard.tsx": { + count: 6, + reason: + "the six derived re-frame tiles (same-time steps, six-minute walk, fitness age, vascular age, HRV balance, BMI) colour a figure whose good direction is its definition (a longer walk, a younger vascular age, no judgement on BMI), not a person's target; the per-vital baseline tile reads the server's `direction`", + }, + }, + }, + { + name: "a reference line at a literal", + find: literalLines, + samples: [ + { + name: "sample.tsx", + source: 'const a = ;', + }, + { + name: "sample.tsx", + source: "const a = ;", + }, + ], + allow: { + "src/components/charts/medication-compliance-chart.tsx": { + count: 2, + reason: + "the opt-in adherence overlay: an 80 % minimum and the 100 % goal are properties of a share of doses, the same for every person, not a clinical threshold the person's profile moves", + }, + "src/components/insights/mood/mood-time-of-day-chart.tsx": { + count: 1, + reason: + "the 3.5 line is the green edge of the 1 to 5 mood scale itself (`MOOD_GREEN_MIN`), the same for every person", + }, + "src/components/insights/mood/mood-weekday-chart.tsx": { + count: 1, + reason: + "the 3.5 line is the green edge of the 1 to 5 mood scale itself (`MOOD_GREEN_MIN`), the same for every person", + }, + }, + }, + { + name: "a polarity table", + find: polarityTables, + samples: POLARITY_SAMPLES.map((source) => ({ name: "sample.ts", source })), + allow: {}, + }, + { + name: "a server resolver imported into the browser", + find: resolverImports, + samples: [ + { + name: "sample.ts", + source: + 'import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";', + }, + ], + allow: {}, + }, +]; + +const FILES = clientFiles(); + +function hitsFor(rule: Rule): Map { + const out = new Map(); + for (const { rel, source } of FILES) { + const hits = rule.find(rel, source); + if (hits.length > 0) out.set(rel, hits); + } + return out; +} + +describe("client threshold literal guard", () => { + it("walks a client tree of the size it expects", () => { + expect(FILES.length).toBeGreaterThan(700); + }); + + for (const rule of RULES) { + describe(rule.name, () => { + it("still catches the shape it exists to catch", () => { + for (const sample of rule.samples) { + expect( + rule.find(sample.name, sample.source).length, + `no hit on: ${sample.source}`, + ).toBeGreaterThan(0); + } + }); + + it("finds none outside its allowlist, and the allowlist exactly", () => { + const hits = hitsFor(rule); + const offenders = [...hits.entries()] + .filter(([rel]) => !(rel in rule.allow)) + .flatMap(([, lines]) => lines); + expect( + offenders, + "client code decides a threshold or a polarity itself. Read the " + + "server's resolved field instead (`tiles.directions`, `direction`, " + + "`referenceLines`), or allowlist the file with its reason.", + ).toEqual([]); + for (const [rel, { count }] of Object.entries(rule.allow)) { + expect( + hits.get(rel)?.length ?? 0, + `${rel} is allowlisted for exactly ${count} hits`, + ).toBe(count); + } + }); + }); + } + + it("reads a non-empty set of matches across the tree", () => { + const total = RULES.reduce( + (sum, rule) => + sum + [...hitsFor(rule).values()].reduce((n, l) => n + l.length, 0), + 0, + ); + expect(total).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index 1e418fc3a..df329bd84 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -125,6 +125,10 @@ const TS_READERS: Record = { "lib/analytics/correlations-fast-path.ts": { exempt: "mood mean; pulse enters as the resting series, never a day mean", }, + "app/api/dashboard/summary/route.ts": { + exempt: + "places a card's seven-day sparkline mean against its band for the trend verdict; each sparkline point is already one rollup day value, so the mean weighs every day once", + }, "lib/dashboard/snapshot.ts": { exempt: "summarises water-intake days and glucose contexts", }, diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts index d63da7a57..7caab3bd6 100644 --- a/src/__tests__/resolved-field-consumer-guard.test.ts +++ b/src/__tests__/resolved-field-consumer-guard.test.ts @@ -289,3 +289,134 @@ describe("resolved-field consumer guard", () => { expect(total).toBeGreaterThanOrEqual(6); }); }); + +/** + * v1.43 (ios #116 items 3, 4, 5, 6, 9; #77) — the fields the server resolves + * for the native client reach the web as well, so the two clients render the + * same answer. Each field names the web module that reads it, by a pattern + * over that module's code. A field the web has no screen for names the web + * twin it is resolved alongside (the same resolver, published where the web + * reads it) and why; the twin's reader is checked the same way. + * + * The rebuild side of the same rule (a client deciding a polarity or drawing + * a threshold of its own) is `client-threshold-literal-guard.test.ts`. + */ +interface WebReader { + file: string; + pattern: RegExp; +} + +interface ResolvedFieldReader { + field: string; + readers: WebReader[]; + /** Set when the web reads a twin rather than this route's field. */ + twin?: string; +} + +const V143_FIELDS: ResolvedFieldReader[] = [ + { + field: "GET /api/dashboard/snapshot tiles.directions", + readers: [ + { file: "src/app/page-client.tsx", pattern: /tiles\.directions/ }, + ], + }, + { + field: + "GET /api/dashboard/summary DashboardMetricCard.direction / trendSentiment", + twin: "the summary route is the native client's dashboard; the web dashboard reads the same `resolveTrendVerdict` answer from the snapshot's `tiles.directions`", + readers: [{ file: "src/app/page-client.tsx", pattern: /directionOf\(/ }], + }, + { + field: "GET /api/insights/targets TargetItem.direction / trendSentiment", + twin: "the web target panels show the band, not a change; the change they would colour is the dashboard tile's, read from `tiles.directions`", + readers: [ + { file: "src/app/page-client.tsx", pattern: /tileDirections\?\.\[key\]/ }, + ], + }, + { + field: "GET /api/insights/derived VITALS_BASELINE value.direction", + readers: [ + { + file: "src/components/insights/derived/vitals-dashboard.tsx", + pattern: /v\.direction\?\.direction/, + }, + ], + }, + { + field: "GET /api/measurements/series direction / change / referenceLines", + twin: "the web charts read the series-batch and draw the person's bands from the snapshot's `targetBands`; the series read is the native chart source", + readers: [{ file: "src/app/page-client.tsx", pattern: /targetBands/ }], + }, + { + field: "GET /api/personal-records benchmark", + readers: [ + { + file: "src/components/insights/personal-record-badge.tsx", + pattern: /fresh\.benchmark/, + }, + ], + }, + { + field: "GET /api/mood/insights patterns", + readers: [ + { + file: "src/components/insights/mood/mood-insights-breakdowns.tsx", + pattern: /data\.patterns/, + }, + { + file: "src/components/insights/mood/mood-patterns.tsx", + pattern: /pattern\.values\.subject/, + }, + ], + }, + { + field: "POST /api/cycle/period absorbed", + readers: [ + { + file: "src/components/cycle/use-cycle.ts", + pattern: /data\?\.absorbed/, + }, + ], + }, + { + field: "DELETE /api/cycle/day-logs/{id} (Prefer) and PATCH restored", + readers: [ + { + file: "src/components/cycle/use-cycle.ts", + pattern: /Prefer: "return=representation"/, + }, + { + file: "src/components/cycle/use-cycle.ts", + pattern: /data\?\.restored/, + }, + ], + }, + { + field: "GET /api/integrations/healthkit metricFreshness[].lastBackfillAt", + readers: [ + { + file: "src/components/settings/integrations/apple-health-card.tsx", + pattern: /entry\.lastBackfillAt/, + }, + ], + }, +]; + +describe("v1.43 resolved fields reach the web", () => { + for (const entry of V143_FIELDS) { + it(`${entry.field} has a web reader${entry.twin ? " through its twin" : ""}`, () => { + expect(entry.readers.length).toBeGreaterThan(0); + if (entry.twin !== undefined) + expect(entry.twin.length).toBeGreaterThan(40); + for (const reader of entry.readers) { + const code = stripComments( + readFileSync(join(ROOT, reader.file), "utf8"), + ); + expect( + reader.pattern.test(code), + `${reader.file} no longer reads ${entry.field} (${reader.pattern})`, + ).toBe(true); + } + }); + } +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 590730f6c..c354df00a 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -87,8 +87,8 @@ const SERVER_FEATURES = { trackIntake: true, // v1.43 — each turns true in the change that makes its route or request // form work; until then a client must not send it (contract §0.6). - cumulativeHourBuckets: false, - syncTriggerBackfill: false, + cumulativeHourBuckets: true, + syncTriggerBackfill: true, biomarkerMerge: true, lifeCircumstances: false, } as const; diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index deaedee17..4dfb5976e 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -533,10 +533,72 @@ describe("GET /api/integrations/healthkit — sync health", () => { lastTrigger: "foreground", lastNewSampleAt: null, lastBackgroundAt: backgroundAt.toISOString(), + lastBackfillAt: null, }); expect(byType.PULSE).toMatchObject({ lastBackgroundAt: null }); }); + // v1.43 (ios #77) — a backfill's own ledger row reads as `lastBackfillAt` + // of its type and moves none of the live facts. + it("serves lastBackfillAt from the backfill row, apart from the live arrival", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([ + "PULSE", + "ACTIVITY_STEPS", + ]); + mockProbes([ + { type: "PULSE", last_seen: ago(60 * 60 * 1000) }, + { type: "ACTIVITY_STEPS", last_seen: ago(60 * 60 * 1000) }, + ]); + const receivedAt = ago(5 * 60 * 60 * 1000); + const backfillAt = ago(60 * 1000); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([ + { + type: "PULSE", + lastReceivedAt: receivedAt, + lastTrigger: "foreground", + lastNewSampleAt: receivedAt, + lastBackgroundAt: null, + }, + { + type: "backfill:PULSE", + lastReceivedAt: backfillAt, + lastTrigger: null, + lastNewSampleAt: null, + lastBackgroundAt: null, + }, + { + type: "backfill:ACTIVITY_STEPS", + lastReceivedAt: backfillAt, + lastTrigger: null, + lastNewSampleAt: null, + lastBackgroundAt: null, + }, + ] as never); + + const body = await read(); + const byType = Object.fromEntries( + body.metricFreshness.map((entry) => [entry.type, entry]), + ); + expect(byType.PULSE).toMatchObject({ + lastReceivedAt: receivedAt.toISOString(), + lastTrigger: "foreground", + lastBackfillAt: backfillAt.toISOString(), + }); + // A type that only ever arrived through a backfill has no live arrival. + expect(byType.ACTIVITY_STEPS).toMatchObject({ + lastReceivedAt: null, + lastTrigger: null, + lastBackfillAt: backfillAt.toISOString(), + }); + expect(body.metricFreshness.map((e) => e.type)).not.toContain( + "backfill:PULSE", + ); + }); + it("leaves the dose entry out when there is no Apple Health dose", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ healthKitConfigJson: null, diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 299b0ff1b..097011380 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -73,10 +73,19 @@ import { findFoldedWindowDuplicates, } from "@/lib/measurements/folded-window"; import { - HEALTHKIT_SYNC_TRIGGERS, + BACKFILL_SYNC_TRIGGER, + HEALTHKIT_REQUEST_SYNC_TRIGGERS, recordHealthKitTypeArrivals, type HealthKitTypeArrival, } from "@/lib/integrations/healthkit-type-sync"; +import { + classifyHourBucket, + HOUR_BUCKET_SKIP_REASONS, + parseHourBucketId, + writeHourBuckets, + type HourBucketWrite, +} from "@/lib/measurements/intraday-hour-buckets"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; import { classifyExternalId, @@ -199,7 +208,9 @@ const batchEntrySchema = z.object({ // pre-#66 caller stays byte-for-byte unchanged. // v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to // the app simply being open (`foreground`). -const syncTriggerEnum = z.enum(HEALTHKIT_SYNC_TRIGGERS); +// v1.43 (ios #77) — `backfill` names a history catch-up. It moves only the +// per-type `lastBackfillAt`, never the live sync stamps (see below). +const syncTriggerEnum = z.enum(HEALTHKIT_REQUEST_SYNC_TRIGGERS); const batchPayloadSchema = z.object({ entries: z.array(batchEntrySchema).min(1), @@ -380,6 +391,11 @@ async function postBatch(request: NextRequest): Promise { const prepared: Prepared[] = []; const results: EntryResult[] = new Array(entries.length); const unstableShapes: UnstableExternalIdShape[] = []; + // v1.43 — hourly sums for the intraday profile, and the zone they are cut in + // (the account's, the one `/api/auth/me` publishes as `timezone`). + const hourWrites: HourBucketWrite[] = []; + let accountTz: string | undefined; + const batchNow = new Date(); for (let index = 0; index < entries.length; index++) { const entry = entries[index]; @@ -402,6 +418,75 @@ async function postBatch(request: NextRequest): Promise { continue; } + // v1.43 (ios #77) — an hourly sum of a summed type + // (`stats::T`) is never a measurement: it lands in the + // intraday profile behind the same-time baseline, and the day's total + // stays the day row. Validated here, written after the measurements. + const hourId = parseHourBucketId(entry.externalId); + if (hourId) { + const skip = (reason: string) => { + results[index] = { index, status: "skipped", reason }; + }; + // The profile has no source dimension and is the phone's: a bridge or a + // hand-entered row cannot post hours into it. + if (scoped || (entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") { + skip(HOUR_BUCKET_SKIP_REASONS.unsupportedType); + continue; + } + accountTz ??= await resolveStoredTimezone(user.timezone); + const startDate = new Date(entry.startDate); + const verdict = classifyHourBucket( + hourId, + startDate, + accountTz, + batchNow, + ); + if (!verdict.ok) { + skip(verdict.reason); + continue; + } + if (!isPlausibleEntryInstant(startDate)) { + skip("implausible_timestamp"); + continue; + } + const hourMapped = mapAppleHealthEntry({ + hkIdentifier: entry.hkIdentifier, + value: entry.value, + unit: entry.unit, + startDate: entry.startDate, + endDate: entry.endDate, + }); + if (!hourMapped || hourMapped.type !== verdict.type) { + // The entry's identifier must be the one its id names. + skip( + hourMapped + ? HOUR_BUCKET_SKIP_REASONS.unsupportedType + : "unmappable_identifier", + ); + continue; + } + if ( + hourMapped.value < 0 || + validateMeasurementRange(hourMapped.type, hourMapped.value) !== null + ) { + results[index] = outOfRangeResult( + index, + hourMapped.type, + hourMapped.value, + ); + continue; + } + hourWrites.push({ + index, + type: verdict.type, + hkIdentifier: hourId.hkIdentifier, + dateKey: hourId.dateKey, + hour: hourId.hour, + value: hourMapped.value, + }); + continue; + } + const mapped = mapAppleHealthEntry({ hkIdentifier: entry.hkIdentifier, value: entry.value, @@ -898,15 +983,79 @@ async function postBatch(request: NextRequest): Promise { } } + // v1.43 — the hours, after the measurements. The last entry for one slot + // wins, as for the day rows; an earlier one in the same batch is a + // superseded duplicate. + let hourInsertedCount = 0; + let hourUpdatedCount = 0; + const hourLanded: HourBucketWrite[] = []; + if (hourWrites.length > 0 && accountTz) { + const lastBySlot = new Map(); + for (const w of hourWrites) { + lastBySlot.set(`${w.type}::${w.dateKey}::${w.hour}`, w); + } + for (const w of hourWrites) { + if (lastBySlot.get(`${w.type}::${w.dateKey}::${w.hour}`) !== w) { + results[w.index] = { + index: w.index, + status: "duplicate", + reason: "superseded_in_batch", + }; + duplicateCount++; + } + } + const toWrite = [...lastBySlot.values()]; + try { + const statuses = await writeHourBuckets( + prisma, + user.id, + accountTz, + toWrite, + ); + for (const w of toWrite) { + const status = statuses.get(w.index) ?? "inserted"; + results[w.index] = { index: w.index, status }; + if (status === "inserted") hourInsertedCount++; + else hourUpdatedCount++; + hourLanded.push(w); + } + } catch (err) { + for (const w of toWrite) { + results[w.index] = { + index: w.index, + status: "failed", + reason: "write_failed", + }; + failedCount++; + } + annotate({ + action: { name: "measurement.batch.hour-bucket.failed" }, + meta: { error: err instanceof Error ? err.message : String(err) }, + }); + } + annotate({ + action: { name: "measurement.batch.hour-bucket" }, + meta: { + attempted: hourWrites.length, + inserted: hourInsertedCount, + updated: hourUpdatedCount, + processed: entries.length, + }, + }); + } + const healthKitSyncSucceeded = failedCount === 0 && - prepared.some((p) => { - if ((p.entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") return false; - const status = results[p.index]?.status; - return ( - status === "inserted" || status === "updated" || status === "duplicate" - ); - }); + (hourLanded.length > 0 || + prepared.some((p) => { + if ((p.entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") return false; + const status = results[p.index]?.status; + return ( + status === "inserted" || + status === "updated" || + status === "duplicate" + ); + })); // This batch route is the native ingestion boundary, so it is also where the // SyncMode checkpoint (`User.lastSyncedAt`, the same column @@ -938,6 +1087,11 @@ async function postBatch(request: NextRequest): Promise { // uses to size its undelivered window, so letting a scale advance it can make // the phone skip a window of its own samples — a silent data loss whose cause // would be nowhere near the code that caused it. + // + // v1.43 (ios #77) — a `backfill` batch is a catch-up, not a sync. It records + // its types under `lastBackfillAt` only and moves none of the live stamps: + // thirty days of history must not read as thirty healthy daily syncs. + const backfill = syncTrigger === BACKFILL_SYNC_TRIGGER; if (failedCount === 0 && !scoped) { const syncedAt = new Date(); const deliveredWithoutTheApp = @@ -962,6 +1116,13 @@ async function postBatch(request: NextRequest): Promise { if (status !== "duplicate") entry.newSamples += 1; arrivals.set(type, entry); } + // The hours the phone posted are an arrival of their type as well. + for (const w of hourLanded) { + const entry = arrivals.get(w.type) ?? { accepted: 0, newSamples: 0 }; + entry.accepted += 1; + entry.newSamples += 1; + arrivals.set(w.type, entry); + } if (arrivals.size > 0) { await recordHealthKitTypeArrivals(prisma, { userId: user.id, @@ -970,21 +1131,23 @@ async function postBatch(request: NextRequest): Promise { at: syncedAt, }); } - await prisma.user.update({ - where: { id: user.id }, - data: { - lastSyncedAt: syncedAt, - ...(healthKitSyncSucceeded - ? { - healthKitLastSyncedAt: syncedAt, - healthKitLastSyncTrigger: syncTrigger ?? null, - ...(deliveredWithoutTheApp - ? { healthKitLastBackgroundSyncAt: syncedAt } - : {}), - } - : {}), - }, - }); + if (!backfill) { + await prisma.user.update({ + where: { id: user.id }, + data: { + lastSyncedAt: syncedAt, + ...(healthKitSyncSucceeded + ? { + healthKitLastSyncedAt: syncedAt, + healthKitLastSyncTrigger: syncTrigger ?? null, + ...(deliveredWithoutTheApp + ? { healthKitLastBackgroundSyncAt: syncedAt } + : {}), + } + : {}), + }, + }); + } } const freshlyInsertedRows = insertedPrepared.map((p) => ({ @@ -1251,10 +1414,21 @@ async function postBatch(request: NextRequest): Promise { } } + // v1.43 — the hours are not measurements, so they touched none of the + // measurement consumers above; only the caches that read the intraday + // profile (the same-time baseline) are marked stale. + if ( + hourInsertedCount + hourUpdatedCount > 0 && + insertedCount === 0 && + updatedCount === 0 + ) { + invalidateUserMeasurements(user.id); + } + const response = apiSuccess({ processed: entries.length, - inserted: insertedCount, - updated: updatedCount, + inserted: insertedCount + hourInsertedCount, + updated: updatedCount + hourUpdatedCount, duplicates: duplicateCount, failed: failedCount, skipped, diff --git a/src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts b/src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts index 3c8e9c0aa..18232033f 100644 --- a/src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts @@ -80,7 +80,9 @@ const USER = { }; /** A replayed Apple dose: the cheapest batch that still counts as an arrival. */ -async function postReplayedDose(): Promise { +async function postReplayedDose( + syncTrigger: string = "background", +): Promise { vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ { id: "row-existing", externalId: "hk-dose-1" }, ] as never); @@ -89,7 +91,7 @@ async function postReplayedDose(): Promise { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - syncTrigger: "background", + syncTrigger, entries: [ { medicationId: "med-apple", @@ -134,4 +136,33 @@ describe("the dose arrival record and the credential that sent the batch", () => expect(res.status).toBe(200); expect(recordHealthKitTypeArrivals).not.toHaveBeenCalled(); }); + + // v1.43 (ios #77) — a history catch-up records the dose arrival under + // `lastBackfillAt` only, and does not move the sync checkpoint. + it("passes a backfill through and leaves the checkpoint where it was", async () => { + vi.mocked(requireAuth).mockResolvedValue({ + session: { id: "s" }, + user: USER, + } as never); + const res = await postReplayedDose("backfill"); + expect(res.status).toBe(200); + expect(recordHealthKitTypeArrivals).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ trigger: "backfill" }), + ); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("moves the checkpoint for a live sync", async () => { + vi.mocked(requireAuth).mockResolvedValue({ + session: { id: "s" }, + user: USER, + } as never); + await postReplayedDose("background"); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ lastSyncedAt: expect.any(Date) }), + }), + ); + }); }); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 00bfc9d5d..085322060 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -58,7 +58,8 @@ import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { markSyncCheckpoint } from "@/lib/sync/checkpoint"; import { - HEALTHKIT_SYNC_TRIGGERS, + BACKFILL_SYNC_TRIGGER, + HEALTHKIT_REQUEST_SYNC_TRIGGERS, MEDICATION_DOSE_ARRIVAL_TYPE, recordHealthKitTypeArrivals, } from "@/lib/integrations/healthkit-type-sync"; @@ -176,7 +177,9 @@ const bulkPayloadSchema = z.object({ // v1.42.4 — what woke the phone for this sync, the same diagnostic-only // field `POST /api/measurements/batch` takes. It changes no row; it only // feeds the `MEDICATION_DOSE` arrival record below. - syncTrigger: z.enum(HEALTHKIT_SYNC_TRIGGERS).optional(), + // v1.43 (ios #77) — `backfill` for a history catch-up: it moves only the + // dose entry's `lastBackfillAt`, never the live stamps or the checkpoint. + syncTrigger: z.enum(HEALTHKIT_REQUEST_SYNC_TRIGGERS).optional(), }); // v1.8.2 — `updated` joins the per-entry status vocabulary: a write that @@ -1038,8 +1041,10 @@ async function postBulk(request: NextRequest): Promise { // The SyncMode checkpoint belongs to the push that moved the data, not to // the `/api/sync/state` handshake that reports it. This endpoint is one of // the three domains that handshake summarises, so an intake drain advances - // it. - await markSyncCheckpoint(user.id); + // it. A `backfill` catch-up does not: it is history, not a sync. + if (syncTrigger !== BACKFILL_SYNC_TRIGGER) { + await markSyncCheckpoint(user.id); + } return apiSuccess({ processed: entries.length, diff --git a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index 2cee22c27..fac15f2be 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -455,6 +455,31 @@ describe(" — per-type arrivals (#1173)", () => { expect(out).not.toContain("Last in background"); }); + // v1.43 (ios #77) — a history catch-up is named apart, and a type that + // only ever arrived through one still reads as not received live. + it("names the last backfill apart from the live arrival", () => { + const live = html({ + ...base, + lastReceivedAt: hoursAgo(1), + lastTrigger: "foreground", + lastNewSampleAt: hoursAgo(1), + lastBackfillAt: hoursAgo(3), + }); + expect(live).toContain('data-state="received"'); + expect(live).toContain("History caught up 3 h ago"); + const backfillOnly = html({ + ...base, + lastReceivedAt: null, + lastTrigger: null, + lastNewSampleAt: null, + lastBackfillAt: hoursAgo(2), + }); + expect(backfillOnly).toContain('data-state="never"'); + expect(backfillOnly).toContain( + "Not received yet, History caught up 2 h ago", + ); + }); + it("names the medication dose row in the freshness list", () => { statusPayload = { lastSyncedAt: hoursAgo(1), diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index ee0431664..c2f909c11 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -148,16 +148,25 @@ export function ArrivalDetail({ now?: Date; }) { if (entry.lastReceivedAt === undefined) return null; - if (entry.lastReceivedAt === null) { - return ( - - {t("settings.appleHealth.freshness.never")} - - ); - } const reference = (now ?? new Date()).getTime(); const relative = (iso: string) => formatRelative(Math.max(0, reference - new Date(iso).getTime()), t); + // v1.43 (ios #77) — a history catch-up is named apart from the live + // arrivals, and never counts as one. + const backfill = entry.lastBackfillAt + ? t("settings.appleHealth.freshness.lastBackfill", { + relative: relative(entry.lastBackfillAt), + }) + : null; + if (entry.lastReceivedAt === null) { + return ( + + {[t("settings.appleHealth.freshness.never"), backfill] + .filter((part): part is string => part !== null) + .join(", ")} + + ); + } const trigger = entry.lastTrigger ?? null; const parts = [ t("settings.appleHealth.freshness.lastReceived", { @@ -189,6 +198,7 @@ export function ArrivalDetail({ }), ); } + if (backfill) parts.push(backfill); return ( { ).rejects.toThrow(/23 hourly slots, not 24/); expect(created, "a row every reader drops is worse than none").toEqual([]); }); + + // v1.43 (ios #77) — the posted hours travel with the day. + it("carries the posted hours, the basis and the hour count both ways", async () => { + const hours = Array.from({ length: 24 }, (_, h) => (h < 8 ? -1 : 100)); + const built = await buildIntradayProfileBackupSection( + { + intradayCumulativeProfile: { + findMany: vi.fn().mockResolvedValue([ + { + ...sourceRow(), + hourlyIncrements: hours, + basis: "HOUR_BUCKETS", + bucketCount: 16, + }, + ]), + }, + } as never, + "user-A", + ); + expect(built.intradayProfiles[0]).toMatchObject({ + hourlyIncrements: hours, + basis: "HOUR_BUCKETS", + bucketCount: 16, + }); + + const created: Array> = []; + await restoreIntradayProfileData(tx(created), "user-B", { + intradayProfiles: [built.intradayProfiles[0]], + }); + expect(created[0]).toMatchObject({ + hourlyIncrements: hours, + basis: "HOUR_BUCKETS", + bucketCount: 16, + }); + }); + + it("restores a day that so far holds only a few posted hours", async () => { + const hours = Array.from({ length: 24 }, (_, h) => (h === 9 ? 120 : -1)); + const created: Array> = []; + await restoreIntradayProfileData(tx(created), "user-B", { + intradayProfiles: [ + entry({ + hourlyCumulative: [], + dayTotal: 0, + sampleCount: 0, + hourlyIncrements: hours, + bucketCount: 1, + }), + ], + }); + expect(created[0]).toMatchObject({ hourlyCumulative: [], bucketCount: 1 }); + + await expect( + restoreIntradayProfileData(tx([]), "user-B", { + intradayProfiles: [entry({ hourlyIncrements: hours.slice(0, 3) })], + }), + ).rejects.toThrow(/3 posted-hour slots/); + }); }); diff --git a/src/lib/export/intraday-profile-backup.ts b/src/lib/export/intraday-profile-backup.ts index 3ce7caf8f..1be5b099e 100644 --- a/src/lib/export/intraday-profile-backup.ts +++ b/src/lib/export/intraday-profile-backup.ts @@ -59,6 +59,18 @@ export interface IntradayProfileBackupEntry { hourlyCumulative: number[]; dayTotal: number; sampleCount: number; + /** + * v1.43 (ios #77) — the hours the phone posted for the day, 24 slots with + * -1 for an hour not posted, or empty for a day folded from single samples + * only. A day with fewer than four posted hours keeps its posted hours here + * while its curve is still the fold's, or empty when the drain never saw + * the day. + */ + hourlyIncrements: number[]; + /** What `hourlyCumulative` was built from. */ + basis: "PER_SAMPLE" | "HOUR_BUCKETS"; + /** Posted hours behind the day; null when none were posted. */ + bucketCount: number | null; /** * The IANA zone the day was cut on. Carried because the reader compares it * against the account's current zone and drops the mismatched days; a @@ -113,6 +125,9 @@ export async function buildIntradayProfileBackupSection( hourlyCumulative: row.hourlyCumulative, dayTotal: row.dayTotal, sampleCount: row.sampleCount, + hourlyIncrements: row.hourlyIncrements, + basis: row.basis === "HOUR_BUCKETS" ? "HOUR_BUCKETS" : "PER_SAMPLE", + bucketCount: row.bucketCount, timezone: row.timezone, })), }; @@ -180,7 +195,24 @@ export async function restoreIntradayProfileData( } seen.add(key); - if (profile.hourlyCumulative.length !== PROFILE_BACKUP_HOURS) { + const increments = profile.hourlyIncrements ?? []; + if (increments.length !== 0 && increments.length !== PROFILE_BACKUP_HOURS) { + throw new Error( + `Intraday profile for ${profile.type} on ${profile.dateKey} carries ` + + `${increments.length} posted-hour slots, not ${PROFILE_BACKUP_HOURS} or none.`, + ); + } + // A day that so far holds only a few posted hours has no curve yet; the + // reader skips it until it does, and the hours are what it is restored + // for. Every other row must carry a full day. + const hoursOnly = + profile.hourlyCumulative.length === 0 && + increments.length === PROFILE_BACKUP_HOURS && + (profile.basis ?? "PER_SAMPLE") === "PER_SAMPLE"; + if ( + !hoursOnly && + profile.hourlyCumulative.length !== PROFILE_BACKUP_HOURS + ) { throw new Error( `Intraday profile for ${profile.type} on ${profile.dateKey} carries ` + `${profile.hourlyCumulative.length} hourly slots, not ${PROFILE_BACKUP_HOURS}. ` + @@ -200,6 +232,9 @@ export async function restoreIntradayProfileData( hourlyCumulative: profile.hourlyCumulative, dayTotal: profile.dayTotal, sampleCount: profile.sampleCount, + hourlyIncrements: profile.hourlyIncrements ?? [], + basis: profile.basis ?? "PER_SAMPLE", + bucketCount: profile.bucketCount ?? null, timezone: profile.timezone, ...(profile.createdAt ? { createdAt: new Date(profile.createdAt) } diff --git a/src/lib/integrations/connection-health-load.ts b/src/lib/integrations/connection-health-load.ts index a309f8253..5ab603ad5 100644 --- a/src/lib/integrations/connection-health-load.ts +++ b/src/lib/integrations/connection-health-load.ts @@ -15,6 +15,7 @@ * * Read-only. A connection that does not exist resolves to null. */ +import { BACKFILL_LEDGER_PREFIX } from "./healthkit-type-sync"; import { prisma } from "@/lib/db"; import type { MeasurementSource } from "@/generated/prisma/client"; import type { IntegrationKey } from "./status"; @@ -194,7 +195,12 @@ export async function loadConnectionFacts( : null, wanted.has("apple-health") ? prisma.healthKitTypeSync.findMany({ - where: { userId }, + // A backfill's own ledger row is a catch-up, not a live arrival, + // and must not make the connection look alive (ios #77). + where: { + userId, + NOT: { type: { startsWith: BACKFILL_LEDGER_PREFIX } }, + }, select: { type: true, lastReceivedAt: true, lastNewSampleAt: true }, orderBy: { lastReceivedAt: "desc" }, }) diff --git a/src/lib/integrations/healthkit-type-sync.ts b/src/lib/integrations/healthkit-type-sync.ts index 4c456242a..53f46e753 100644 --- a/src/lib/integrations/healthkit-type-sync.ts +++ b/src/lib/integrations/healthkit-type-sync.ts @@ -25,7 +25,10 @@ import { type MetricFreshnessEntry, } from "./sync-verdict"; -/** The `syncTrigger` values the Apple Health ingest routes accept. */ +/** + * The `syncTrigger` values that describe a live sync: the ones a batch can + * leave behind as a type's `lastTrigger`. + */ export const HEALTHKIT_SYNC_TRIGGERS = [ "foreground", "background", @@ -35,8 +38,39 @@ export const HEALTHKIT_SYNC_TRIGGERS = [ export type HealthKitSyncTrigger = (typeof HEALTHKIT_SYNC_TRIGGERS)[number]; +/** + * v1.43 (ios #77) — a history catch-up (the 30-day baseline backfill, a + * "sync everything" walk). Accepted on the two ingest routes when the server + * says so (`features.syncTriggerBackfill`). It is not a live sync, so it is + * never stored as `lastTrigger` and moves none of the live stamps; it moves + * only `lastBackfillAt`. + */ +export const BACKFILL_SYNC_TRIGGER = "backfill" as const; + +/** Every `syncTrigger` value the Apple Health ingest routes accept. */ +export const HEALTHKIT_REQUEST_SYNC_TRIGGERS = [ + ...HEALTHKIT_SYNC_TRIGGERS, + BACKFILL_SYNC_TRIGGER, +] as const; + +export type HealthKitRequestSyncTrigger = + (typeof HEALTHKIT_REQUEST_SYNC_TRIGGERS)[number]; + const TRIGGERS: ReadonlySet = new Set(HEALTHKIT_SYNC_TRIGGERS); +/** + * A backfill arrival is kept as its own ledger row, keyed `backfill:`, + * beside the live row of the same type. Its `last_received_at` is the + * backfill's time; it carries no trigger and no sample stamps. Keeping it in + * a row of its own is what lets a catch-up leave every live column of the type + * exactly as it was, and every live reader skips the prefix. + */ +export const BACKFILL_LEDGER_PREFIX = "backfill:"; + +export function isBackfillLedgerKey(type: string): boolean { + return type.startsWith(BACKFILL_LEDGER_PREFIX); +} + /** * The ledger key for Apple Health medication doses, which are not a * `MeasurementType`. Also the `type` of their `metricFreshness` entry. @@ -72,7 +106,7 @@ export async function recordHealthKitTypeArrivals( input: { userId: string; counts: ReadonlyMap; - trigger: HealthKitSyncTrigger | null; + trigger: HealthKitRequestSyncTrigger | null; at?: Date; }, ): Promise { @@ -88,6 +122,27 @@ export async function recordHealthKitTypeArrivals( // serialised in the process's local zone and these are zone-less UTC // columns (same reasoning as the access-token sunset in refresh-token.ts). const at = (input.at ?? new Date()).toISOString(); + if (input.trigger === BACKFILL_SYNC_TRIGGER) { + // v1.43 — a catch-up moves only its own row; the live row of each type + // keeps its arrival, trigger and sample stamps untouched. + const keys = types.map((type) => `${BACKFILL_LEDGER_PREFIX}${type}`); + try { + await tx.$executeRaw` + INSERT INTO "healthkit_type_syncs" + ("user_id", "type", "last_received_at") + SELECT ${input.userId}, u.t, ${at}::timestamptz AT TIME ZONE 'UTC' + FROM unnest(${keys}::text[]) AS u(t) + ON CONFLICT ("user_id", "type") DO UPDATE SET + "last_received_at" = EXCLUDED."last_received_at" + `; + } catch (error) { + logCaught("healthkit.type_sync.record_failed", error, { + types: types.length, + backfill: true, + }); + } + return; + } const background = input.trigger === "background"; try { await tx.$executeRaw` @@ -120,10 +175,13 @@ export async function recordHealthKitTypeArrivals( /** One type's arrival facts, as the Apple Health status read serves them. */ export interface HealthKitTypeArrivalRecord { - lastReceivedAt: string; + /** Null for a type that so far arrived only through a backfill. */ + lastReceivedAt: string | null; lastTrigger: HealthKitSyncTrigger | null; lastNewSampleAt: string | null; lastBackgroundAt: string | null; + /** v1.43 — when a `backfill` batch last carried the type; null when none has. */ + lastBackfillAt: string | null; } /** @@ -145,16 +203,30 @@ export async function getHealthKitTypeArrivals( }, }); const out = new Map(); + const empty = (): HealthKitTypeArrivalRecord => ({ + lastReceivedAt: null, + lastTrigger: null, + lastNewSampleAt: null, + lastBackgroundAt: null, + lastBackfillAt: null, + }); for (const row of rows) { - out.set(row.type, { - lastReceivedAt: row.lastReceivedAt.toISOString(), - lastTrigger: - row.lastTrigger && TRIGGERS.has(row.lastTrigger) - ? (row.lastTrigger as HealthKitSyncTrigger) - : null, - lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null, - lastBackgroundAt: row.lastBackgroundAt?.toISOString() ?? null, - }); + if (isBackfillLedgerKey(row.type)) { + const type = row.type.slice(BACKFILL_LEDGER_PREFIX.length); + const record = out.get(type) ?? empty(); + record.lastBackfillAt = row.lastReceivedAt.toISOString(); + out.set(type, record); + continue; + } + const record = out.get(row.type) ?? empty(); + record.lastReceivedAt = row.lastReceivedAt.toISOString(); + record.lastTrigger = + row.lastTrigger && TRIGGERS.has(row.lastTrigger) + ? (row.lastTrigger as HealthKitSyncTrigger) + : null; + record.lastNewSampleAt = row.lastNewSampleAt?.toISOString() ?? null; + record.lastBackgroundAt = row.lastBackgroundAt?.toISOString() ?? null; + out.set(row.type, record); } return out; } @@ -195,6 +267,8 @@ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry { lastNewSampleAt?: string | null; /** When a `background` sync last carried it; a later foreground one keeps it. */ lastBackgroundAt?: string | null; + /** v1.43 — when a `backfill` batch last carried it. */ + lastBackfillAt?: string | null; } /** @@ -220,6 +294,7 @@ export function withHealthKitArrivals( lastTrigger: arrival?.lastTrigger ?? null, lastNewSampleAt: arrival?.lastNewSampleAt ?? null, lastBackgroundAt: arrival?.lastBackgroundAt ?? null, + lastBackfillAt: arrival?.lastBackfillAt ?? null, }; }); } diff --git a/src/lib/measurements/__tests__/intraday-hour-buckets.test.ts b/src/lib/measurements/__tests__/intraday-hour-buckets.test.ts new file mode 100644 index 000000000..e7008f973 --- /dev/null +++ b/src/lib/measurements/__tests__/intraday-hour-buckets.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; + +import { + classifyHourBucket, + cumulativeFromIncrements, + HOUR_NOT_POSTED, + isStartOfLocalHour, + parseHourBucketId, + postedHours, +} from "../intraday-hour-buckets"; + +const BERLIN = "Europe/Berlin"; +const NOW = new Date("2026-10-15T12:00:00.000Z"); + +describe("parseHourBucketId", () => { + it("reads the hour form", () => { + expect( + parseHourBucketId( + "stats:HKQuantityTypeIdentifierStepCount:2026-10-14T09", + ), + ).toEqual({ + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + dateKey: "2026-10-14", + hour: 9, + }); + }); + + it.each([ + "stats:HKQuantityTypeIdentifierStepCount:2026-10-14", + "stats:HKQuantityTypeIdentifierHeartRate:2026-10-14T09", + "stats:HKQuantityTypeIdentifierHeartRate:2026-10-14T09:10:00.000Z", + "stats:HKQuantityTypeIdentifierStepCount:2026-10-14T24", + "stats:HKQuantityTypeIdentifierStepCount:2026-02-30T09", + "stats:HKQuantityTypeIdentifierStepCount:2026-10-14T9", + "uuid-1234", + ])("leaves %s to its own path", (id) => { + expect(parseHourBucketId(id)).toBeNull(); + }); +}); + +describe("isStartOfLocalHour", () => { + const id = { hkIdentifier: "x", dateKey: "2026-10-14", hour: 9 }; + it("accepts the first instant of the local hour only", () => { + // 09:00 in Berlin (CEST, UTC+2) is 07:00Z. + expect( + isStartOfLocalHour(new Date("2026-10-14T07:00:00.000Z"), id, BERLIN), + ).toBe(true); + expect( + isStartOfLocalHour(new Date("2026-10-14T09:00:00.000Z"), id, BERLIN), + ).toBe(false); + expect( + isStartOfLocalHour(new Date("2026-10-14T07:00:00.001Z"), id, BERLIN), + ).toBe(false); + expect( + isStartOfLocalHour(new Date("2026-10-14T07:30:00.000Z"), id, BERLIN), + ).toBe(false); + }); + + it("accepts either start of a repeated autumn hour and no start of a skipped spring hour", () => { + const repeated = { hkIdentifier: "x", dateKey: "2026-10-25", hour: 2 }; + expect( + isStartOfLocalHour( + new Date("2026-10-25T00:00:00.000Z"), + repeated, + BERLIN, + ), + ).toBe(true); + expect( + isStartOfLocalHour( + new Date("2026-10-25T01:00:00.000Z"), + repeated, + BERLIN, + ), + ).toBe(true); + const skipped = { hkIdentifier: "x", dateKey: "2026-03-29", hour: 2 }; + expect( + isStartOfLocalHour(new Date("2026-03-29T01:00:00.000Z"), skipped, BERLIN), + ).toBe(false); + }); +}); + +describe("classifyHourBucket", () => { + const steps = (dateKey: string, hour: number) => ({ + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + dateKey, + hour, + }); + + it("maps the four summed identifiers", () => { + expect( + classifyHourBucket( + steps("2026-10-14", 9), + new Date("2026-10-14T07:00:00Z"), + BERLIN, + NOW, + ), + ).toMatchObject({ ok: true, type: "ACTIVITY_STEPS" }); + }); + + it("refuses another identifier", () => { + expect( + classifyHourBucket( + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + dateKey: "2026-10-14", + hour: 9, + }, + new Date("2026-10-14T07:00:00Z"), + BERLIN, + NOW, + ), + ).toEqual({ ok: false, reason: "hour_bucket_unsupported_type" }); + }); + + it("refuses a start that is not the local hour's start", () => { + // 09:00Z is 11:00 in Berlin, not 09:00. + expect( + classifyHourBucket( + steps("2026-10-14", 9), + new Date("2026-10-14T09:00:00Z"), + BERLIN, + NOW, + ), + ).toEqual({ ok: false, reason: "hour_bucket_misaligned" }); + }); + + it("refuses a day more than 400 days back", () => { + expect( + classifyHourBucket( + steps("2025-09-01", 9), + new Date("2025-09-01T07:00:00Z"), + BERLIN, + NOW, + ), + ).toEqual({ ok: false, reason: "hour_bucket_too_old" }); + expect( + classifyHourBucket( + steps("2025-09-11", 9), + new Date("2025-09-11T07:00:00Z"), + BERLIN, + NOW, + ), + ).toMatchObject({ ok: true }); + }); +}); + +describe("cumulativeFromIncrements", () => { + it("adds only posted hours and keeps the running total flat across gaps", () => { + const increments = new Array(24).fill(HOUR_NOT_POSTED); + increments[8] = 100; + increments[9] = 0; + increments[12] = 50; + const cumulative = cumulativeFromIncrements(increments); + expect(cumulative[7]).toBe(0); + expect(cumulative[8]).toBe(100); + expect(cumulative[11]).toBe(100); + expect(cumulative[23]).toBe(150); + expect(postedHours(increments)).toBe(3); + }); +}); diff --git a/src/lib/measurements/intraday-cumulative-profile.ts b/src/lib/measurements/intraday-cumulative-profile.ts index f05e95cd0..a37cc6cbd 100644 --- a/src/lib/measurements/intraday-cumulative-profile.ts +++ b/src/lib/measurements/intraday-cumulative-profile.ts @@ -110,6 +110,14 @@ export interface CumulativeDayFold { export interface CumulativeDayProfile extends CumulativeDayFold { /** Whether this came off the table or was folded live from raw rows. */ origin: "stored" | "live"; + /** + * v1.43 (ios #77) — what the running totals were built from: the fold of + * single samples, or the hours the phone posted (`intraday-hour-buckets`). + * `sampleCount` is the number of posted hours for the latter. + */ + basis: "PER_SAMPLE" | "HOUR_BUCKETS"; + /** Posted hours behind the day; null for a fold of single samples. */ + bucketCount: number | null; } /** @@ -207,9 +215,26 @@ export async function mergeIntradayCumulativeProfile( const existing = await tx.intradayCumulativeProfile.findUnique({ where: { userId_type_dateKey: key }, - select: { hourlyCumulative: true, sampleCount: true, timezone: true }, + select: { + hourlyCumulative: true, + sampleCount: true, + timezone: true, + basis: true, + }, }); + // v1.43 (ios #77) — a day built from the hours the phone posted keeps that + // shape: the posted hours already account for every sample, so adding a + // fold of the same samples would count the day twice. The fold is dropped, + // exactly as the samples it came from are about to be. + if ( + existing !== null && + existing.basis === "HOUR_BUCKETS" && + existing.timezone === increment.timezone + ) { + return; + } + const reusable = existing !== null && existing.timezone === increment.timezone && @@ -227,6 +252,11 @@ export async function mergeIntradayCumulativeProfile( dayTotal: hourlyCumulative[PROFILE_HOURS - 1], sampleCount, timezone: increment.timezone, + // A profile cut on another clock is replaced whole, its posted hours + // included (`basis` stays the default for a day under the threshold). + ...(existing !== null && existing.timezone !== increment.timezone + ? { hourlyIncrements: [], bucketCount: null, basis: "PER_SAMPLE" } + : {}), }; await tx.intradayCumulativeProfile.upsert({ @@ -300,6 +330,8 @@ export async function readCumulativeDayProfiles(args: { hourlyCumulative: true, dayTotal: true, sampleCount: true, + basis: true, + bucketCount: true, }, }); @@ -316,6 +348,8 @@ export async function readCumulativeDayProfiles(args: { dayTotal: row.dayTotal, sampleCount: row.sampleCount, origin: "stored", + basis: row.basis === "HOUR_BUCKETS" ? "HOUR_BUCKETS" : "PER_SAMPLE", + bucketCount: row.bucketCount, }); } @@ -326,7 +360,14 @@ export async function readCumulativeDayProfiles(args: { if (dateKey < startDateKey) break; if (byDay.has(dateKey)) continue; const folded = await foldLiveDay(db, userId, type, dateKey, timezone); - if (folded) byDay.set(dateKey, { ...folded, origin: "live" }); + if (folded) { + byDay.set(dateKey, { + ...folded, + origin: "live", + basis: "PER_SAMPLE", + bucketCount: null, + }); + } } return [...byDay.values()].sort((a, b) => (a.dateKey < b.dateKey ? 1 : -1)); diff --git a/src/lib/measurements/intraday-hour-buckets.ts b/src/lib/measurements/intraday-hour-buckets.ts new file mode 100644 index 000000000..0c9940b29 --- /dev/null +++ b/src/lib/measurements/intraday-hour-buckets.ts @@ -0,0 +1,271 @@ +/** + * Hour buckets for the summed intraday types (v1.43, ios #77). + * + * The phone can hand over a day of steps, active energy, walking distance or + * flights as twenty-four hourly sums (`stats::T`, the + * local hour in the account's zone) instead of the minute-level samples the + * nightly drain folds. That matters for history: a thirty-day backfill of + * hourly sums makes the same-time baseline usable on day one, where the + * drain only ever sees samples that are still on the phone's recent window. + * + * Such a row is NEVER a measurement. The day's total stays the + * `stats::` row the phone keeps posting; an hourly row stored + * as a measurement would be summed into that day a second time. Each hour + * lands as one slot of `IntradayCumulativeProfile.hourlyIncrements` instead, + * and once a day holds `HOUR_BUCKET_MIN_FOR_PROFILE` hours its running totals + * (`hourlyCumulative`) are rebuilt from the posted hours and the profile's + * `basis` reads `HOUR_BUCKETS`. Below that a day keeps the per-sample fold, + * if it has one. + * + * An hour not posted holds `HOUR_NOT_POSTED` (-1) in its slot; every value a + * posted hour can carry is zero or more, so the sentinel cannot collide with + * a reading, and `bucketCount` is the number of slots at zero or above. + */ +import type { + MeasurementType, + Prisma, + PrismaClient, +} from "@/generated/prisma/client"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; + +import { + CUMULATIVE_PROFILE_RETENTION_DAYS, + PROFILE_HOURS, +} from "./intraday-cumulative-profile"; + +/** The HealthKit identifiers the hour form is defined for, and their types. */ +export const HOUR_BUCKET_TYPES: Readonly> = { + HKQuantityTypeIdentifierStepCount: "ACTIVITY_STEPS", + HKQuantityTypeIdentifierActiveEnergyBurned: "ACTIVE_ENERGY_BURNED", + HKQuantityTypeIdentifierDistanceWalkingRunning: "WALKING_RUNNING_DISTANCE", + HKQuantityTypeIdentifierFlightsClimbed: "FLIGHTS_CLIMBED", +}; + +/** Hours a day needs before its profile is built from buckets. */ +export const HOUR_BUCKET_MIN_FOR_PROFILE = 4; + +/** The oldest hour accepted: the profile's own retention bound. */ +export const HOUR_BUCKET_MAX_AGE_DAYS = CUMULATIVE_PROFILE_RETENTION_DAYS; + +/** The slot value of an hour the phone has not posted. */ +export const HOUR_NOT_POSTED = -1; + +export const HOUR_BUCKET_SKIP_REASONS = { + misaligned: "hour_bucket_misaligned", + unsupportedType: "hour_bucket_unsupported_type", + tooOld: "hour_bucket_too_old", +} as const; + +export type HourBucketSkipReason = + (typeof HOUR_BUCKET_SKIP_REASONS)[keyof typeof HOUR_BUCKET_SKIP_REASONS]; + +/** + * The heart-rate identifier keeps its own bucket contract (ten-minute ISO + * buckets, `malformed_hr_bucket_id`), so the hour form is not claimed for it. + */ +const HEART_RATE_HK = "HKQuantityTypeIdentifierHeartRate"; + +const HOUR_FORM = /^stats:([A-Za-z0-9]+):(\d{4}-\d{2}-\d{2})T(\d{2})$/; + +export interface HourBucketId { + hkIdentifier: string; + dateKey: string; + hour: number; +} + +/** + * Parse `stats::T`. Null for anything else, the day form + * and the heart-rate buckets included, so every other external id keeps the + * path it had. + */ +export function parseHourBucketId(externalId: string): HourBucketId | null { + const match = HOUR_FORM.exec(externalId); + if (!match) return null; + const [, hkIdentifier, dateKey, hh] = match; + if (hkIdentifier === HEART_RATE_HK) return null; + const hour = Number(hh); + if (!Number.isInteger(hour) || hour < 0 || hour >= PROFILE_HOURS) return null; + const [y, m, d] = dateKey.split("-").map(Number); + const probe = new Date(Date.UTC(y, m - 1, d)); + if ( + probe.getUTCFullYear() !== y || + probe.getUTCMonth() !== m - 1 || + probe.getUTCDate() !== d + ) { + return null; + } + return { hkIdentifier, dateKey, hour }; +} + +/** + * Whether `startDate` is the first instant of that local hour in the + * account's zone. Read through the zone rather than computed from it, so a + * repeated autumn hour accepts either of its two starts and a spring hour + * that does not exist accepts none. + */ +export function isStartOfLocalHour( + startDate: Date, + id: HourBucketId, + timezone: string, +): boolean { + if (startDate.getUTCMilliseconds() !== 0) return false; + const wall = wallClockInTz(startDate, timezone); + const key = `${String(wall.year).padStart(4, "0")}-${String(wall.month).padStart(2, "0")}-${String(wall.day).padStart(2, "0")}`; + return ( + key === id.dateKey && + wall.hour === id.hour && + wall.minute === 0 && + wall.second === 0 + ); +} + +export type HourBucketVerdict = + | { ok: true; type: MeasurementType; id: HourBucketId } + | { ok: false; reason: HourBucketSkipReason }; + +/** Decide whether one parsed hour row may be stored, and as which type. */ +export function classifyHourBucket( + id: HourBucketId, + startDate: Date, + timezone: string, + now: Date, +): HourBucketVerdict { + const type = HOUR_BUCKET_TYPES[id.hkIdentifier]; + if (!type) + return { ok: false, reason: HOUR_BUCKET_SKIP_REASONS.unsupportedType }; + const oldest = shiftDateKey( + userDayKey(now, timezone), + -HOUR_BUCKET_MAX_AGE_DAYS, + ); + if (id.dateKey < oldest) { + return { ok: false, reason: HOUR_BUCKET_SKIP_REASONS.tooOld }; + } + if (!isStartOfLocalHour(startDate, id, timezone)) { + return { ok: false, reason: HOUR_BUCKET_SKIP_REASONS.misaligned }; + } + return { ok: true, type, id }; +} + +/** Running totals from posted hours; an hour not posted adds nothing. */ +export function cumulativeFromIncrements( + increments: readonly number[], +): number[] { + const out = new Array(PROFILE_HOURS); + let running = 0; + for (let h = 0; h < PROFILE_HOURS; h += 1) { + const v = increments[h]; + if (v !== undefined && v >= 0) running += v; + out[h] = running; + } + return out; +} + +export function postedHours(increments: readonly number[]): number { + return increments.filter((v) => v >= 0).length; +} + +/** One accepted hour, as the batch hands it over. */ +export interface HourBucketWrite { + /** The entry's index in the batch, for its per-entry status. */ + index: number; + type: MeasurementType; + hkIdentifier: string; + dateKey: string; + hour: number; + /** The hour's sum in the canonical unit of `type`. */ + value: number; +} + +/** + * Store a batch's hours, one transaction per (type, day). Returns the + * per-entry status: `inserted` for the first value of a slot, `updated` when + * it replaced one. A later entry for the same slot in one batch wins, and the + * earlier ones read `updated` too, since the slot already held a value when + * they were applied in order. + * + * Serialised against the nightly drain's merge for the same day by the + * drain's own advisory lock (keyed on the day's `stats:` id), so a bucket + * write and a per-sample fold never interleave. + */ +export async function writeHourBuckets( + db: PrismaClient, + userId: string, + timezone: string, + writes: readonly HourBucketWrite[], +): Promise> { + const statuses = new Map(); + const groups = new Map(); + for (const w of writes) { + const key = `${w.type}::${w.dateKey}`; + const list = groups.get(key); + if (list) list.push(w); + else groups.set(key, [w]); + } + + for (const group of groups.values()) { + const { type, dateKey, hkIdentifier } = group[0]; + await db.$transaction(async (tx) => { + await tx.$queryRaw` + SELECT 1 AS locked + FROM pg_advisory_xact_lock(hashtextextended('drain-cumulative:' || ${userId} || ':' || ${`stats:${hkIdentifier}:${dateKey}`}, 0)) + `; + const key = { userId, type, dateKey }; + const existing = await tx.intradayCumulativeProfile.findUnique({ + where: { userId_type_dateKey: key }, + select: { + hourlyIncrements: true, + hourlyCumulative: true, + dayTotal: true, + sampleCount: true, + timezone: true, + basis: true, + }, + }); + // Hours cut on another clock mean other hours: start the day over. + const sameZone = existing !== null && existing.timezone === timezone; + const increments = + sameZone && existing.hourlyIncrements.length === PROFILE_HOURS + ? [...existing.hourlyIncrements] + : new Array(PROFILE_HOURS).fill(HOUR_NOT_POSTED); + for (const w of group) { + statuses.set(w.index, increments[w.hour] >= 0 ? "updated" : "inserted"); + increments[w.hour] = w.value; + } + const bucketCount = postedHours(increments); + const fromBuckets = bucketCount >= HOUR_BUCKET_MIN_FOR_PROFILE; + const cumulative = cumulativeFromIncrements(increments); + // Below the threshold the day keeps whatever the per-sample fold left + // (nothing, for a day the drain never saw): the reader skips a profile + // without 24 running totals. + const keepFold = sameZone && !fromBuckets; + const data = { + hourlyIncrements: increments, + bucketCount, + basis: fromBuckets ? "HOUR_BUCKETS" : "PER_SAMPLE", + timezone, + hourlyCumulative: fromBuckets + ? cumulative + : keepFold + ? existing.hourlyCumulative + : [], + dayTotal: fromBuckets + ? cumulative[PROFILE_HOURS - 1] + : keepFold + ? existing.dayTotal + : 0, + sampleCount: fromBuckets + ? bucketCount + : keepFold + ? existing.sampleCount + : 0, + } satisfies Prisma.IntradayCumulativeProfileUncheckedUpdateInput; + await tx.intradayCumulativeProfile.upsert({ + where: { userId_type_dateKey: key }, + create: { ...key, ...data }, + update: data, + }); + }); + } + return statuses; +} diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index d60b379f1..27b0d342c 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -333,18 +333,22 @@ const cycleDeleteOutcomeEnvelope = dataEnvelope( "CycleDeleteOutcomeEnvelope", ); -/** `Prefer: return=representation` on the two deletes that can remove a start. */ -const preferReturnRepresentationParameter = { +/** + * `Prefer: return=representation` on the two deletes that can remove a start. + * A factory, so each operation carries its own copy: a shared object becomes a + * YAML anchor and renumbers every other anchor in the document. + */ +const preferReturnRepresentationParameter = () => ({ name: "Prefer", in: "header" as const, required: false, schema: { type: "string" as const, example: "return=representation" }, description: "v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed (`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route answers 204 with no body, as before.", -}; +}); /** The 200 a delete answers with when the request preferred a representation. */ -const cycleDeleteOutcomeResponse = { +const cycleDeleteOutcomeResponse = () => ({ description: "v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a day that opened no cycle or a repeated delete) and the starts the removal gave back.", content: { @@ -355,7 +359,7 @@ const cycleDeleteOutcomeResponse = { description: "The route honoured `Prefer: return=representation`.", }), }), -}; +}); const cyclePeriodResponse = z.object({ cycle: menstrualCycleDto.nullable(), @@ -834,9 +838,9 @@ export const cyclePaths: NonNullable = { description: "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. A day that opened a cycle takes that cycle with it, which gives back any start that cycle had folded in. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.", requestParams: { path: z.object({ id: z.string() }) }, - parameters: [preferReturnRepresentationParameter], + parameters: [preferReturnRepresentationParameter()], responses: { - "200": cycleDeleteOutcomeResponse, + "200": cycleDeleteOutcomeResponse(), "204": { description: "Soft-deleted (no body)." }, "404": { description: "Day-log not found / not owned.", @@ -991,9 +995,9 @@ export const cyclePaths: NonNullable = { description: "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. Any start this cycle had folded in stands again. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.", requestParams: { path: z.object({ id: z.string() }) }, - parameters: [preferReturnRepresentationParameter], + parameters: [preferReturnRepresentationParameter()], responses: { - "200": cycleDeleteOutcomeResponse, + "200": cycleDeleteOutcomeResponse(), "204": { description: "Soft-deleted (no body)." }, "404": { description: "Cycle not found / not owned.", diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index d145a24bf..ee7b945b2 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -92,10 +92,11 @@ const batchEntrySchema = z .min(1) .max(120) .describe( - "Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes:\n" + + "Dedup key for the `(userId, type, source, externalId)` composite index. Four shapes:\n" + "• `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row is immutable.\n" + "• `stats::` — a per-day cumulative total (Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`.\n" + "• `stats:HKQuantityTypeIdentifierHeartRate:` — v1.19.0 (iOS #34), refined to 10-minute granularity in v1.30.7, heart-rate bucket carrying the bucket's AVERAGE bpm as one PULSE row. `` is the ISO-8601 UTC 10-minute boundary — zeroed seconds/millis, minutes ∈ {00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`.\n" + + "• `stats::T` — v1.43 (ios #77), only when `GET /api/auth/me` reports `features.cumulativeHourBuckets: true`: one local hour's SUM of steps, active energy, walking and running distance or flights climbed, the date and the zero-padded hour being the local hour in the account's zone (`timezone` on `/api/auth/me`). `value` is the hour's sum in the unit of the day form, `startDate` the first instant of that local hour, `endDate` one hour later. Such a row is never a measurement and never changes the day total, which stays the day form: it feeds the intraday profile behind the same-time baseline (a day with at least four posted hours is built from them). A re-post overwrites (`updated`). Skipped with `hour_bucket_misaligned` when `startDate` is not the start of that local hour, `hour_bucket_unsupported_type` for any other identifier or a `MANUAL` / bridge entry, `hour_bucket_too_old` when the day is more than 400 days back.\n" + "The id MUST be stable across app restarts. An object description or memory address (``, `0x12ab34cd`) is a new value on every launch, so it can never match its own earlier row; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands.", ), externalSourceVersion: z.string().min(1).max(120).optional(), @@ -132,10 +133,10 @@ const batchPayloadSchema = z // used in attribution — sending it or omitting it produces byte-identical // row outcomes. Omit on any client that does not track its wake reason. syncTrigger: z - .enum(["foreground", "background", "push", "manual"]) + .enum(["foreground", "background", "push", "manual", "backfill"]) .optional() .describe( - 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.', + 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). `backfill` (v1.43, ios #77; send it only when `GET /api/auth/me` reports `features.syncTriggerBackfill: true`, an older server refuses the whole batch with 422) names a history catch-up: it moves only `syncProgress` and the per-type `lastBackfillAt` in `GET /api/integrations/healthkit`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt`, `lastSyncTrigger`, `lastBackgroundSyncAt` or the sync checkpoint. Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.', ), }) .meta({ @@ -156,7 +157,7 @@ const batchEntryResult = z .string() .optional() .describe( - "Why a row was `skipped` or a `duplicate`. `folded_window` (since v1.42): a raw sample of a dense or mean type whose local day lies before the fold boundary and whose hour or day a live `stats:` row of the same source already covers. Nothing to retry; treat it like any duplicate.", + "Why a row was `skipped` or a `duplicate`. `folded_window` (since v1.42): a raw sample of a dense or mean type whose local day lies before the fold boundary and whose hour or day a live `stats:` row of the same source already covers. Nothing to retry; treat it like any duplicate. `hour_bucket_misaligned`, `hour_bucket_unsupported_type`, `hour_bucket_too_old` (since v1.43): an hourly `stats::T` row the server refused, see `externalId`; permanent for the row as sent.", ), convertedValue: z .number() diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index ac6ecd4e5..7ac5aa6dd 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -199,10 +199,10 @@ const bulkIntakePayload = z .object({ entries: z.array(bulkIntakeEntry).min(1).max(500), syncTrigger: z - .enum(["foreground", "background", "push", "manual"]) + .enum(["foreground", "background", "push", "manual", "backfill"]) .optional() .describe( - 'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. It changes no dose row. Optional; omitted records a null trigger.', + 'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. `backfill` (v1.43, only when `features.syncTriggerBackfill` is `true`) marks a history catch-up, which moves only the `MEDICATION_DOSE` entry\'s `lastBackfillAt` and leaves its live stamps and the sync checkpoint alone. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. It changes no dose row. Optional; omitted records a null trigger.', ), }) .meta({ diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index 42d16f7af..9a965226c 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -210,6 +210,69 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => { } }); + // A qualitative marker is minted with an empty unit and its readings are + // stored under it; the file the account's own backup wrote must parse. + describe("units of qualitative lab markers", () => { + const base = { + schemaVersion: "1", + exportedAt: "2026-05-08T07:00:00.000Z", + userId: "u1", + }; + const qualitativeMarker = { id: "bm-1", name: "Urine glucose", unit: "" }; + const qualitativeReading = { + analyte: "Urine glucose", + value: null, + valueText: "negative", + unit: "", + takenAt: "2026-04-01T09:00:00.000Z", + source: "MANUAL", + biomarkerId: "bm-1", + biomarkerName: "Urine glucose", + }; + + it("accepts an empty unit on a qualitative marker and its reading", () => { + const r = backupPayloadSchema.safeParse({ + ...base, + biomarkers: [qualitativeMarker], + labResults: [qualitativeReading], + }); + expect(r.success).toBe(true); + }); + + it("still needs the unit on a numeric reading", () => { + const r = backupPayloadSchema.safeParse({ + ...base, + labResults: [ + { + ...qualitativeReading, + value: 5, + valueText: null, + biomarkerId: null, + }, + ], + }); + expect(r.success).toBe(false); + expect(r.error?.issues.map((i) => i.path.join("."))).toEqual([ + "labResults.0.unit", + ]); + }); + + it("refuses an empty unit on a marker a numeric reading is filed under", () => { + const r = backupPayloadSchema.safeParse({ + ...base, + biomarkers: [qualitativeMarker], + labResults: [ + qualitativeReading, + { ...qualitativeReading, value: 5, valueText: null, unit: "mg/dL" }, + ], + }); + expect(r.success).toBe(false); + expect(r.error?.issues.map((i) => i.path.join("."))).toEqual([ + "biomarkers.0.unit", + ]); + }); + }); + it("round-trips an illness episode with a flare referencing its parent + nested day-logs", () => { const r = backupPayloadSchema.safeParse({ schemaVersion: "1", diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 3cdb869bb..71a813486 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -971,6 +971,12 @@ const intradayProfileBackupSchema = z hourlyCumulative: z.array(z.number()), dayTotal: z.number(), sampleCount: z.number().int(), + // v1.43 (ios #77) — the hours the phone posted, what the curve was built + // from, and how many hours there were. Defaulted so a file written before + // the hours existed restores as a fold of single samples, which it was. + hourlyIncrements: z.array(z.number()).default([]), + basis: z.enum(["PER_SAMPLE", "HOUR_BUCKETS"]).default("PER_SAMPLE"), + bucketCount: z.number().int().nonnegative().nullable().default(null), // The zone the day was cut on. The reader compares it against the // account's current zone and drops the mismatched days, so a restore that // dropped this column would make old curves look comparable when their @@ -1098,7 +1104,10 @@ const labResultBackupSchema = z analyte: z.string().min(1), value: z.number().nullable().optional(), valueText: z.string().nullable().optional(), - unit: z.string().min(1), + // A qualitative reading ("positive", "trace") has no unit and is stored + // under its marker's empty one (`POST /api/labs`); the refine below keeps + // the unit required for every numeric reading. + unit: z.string(), referenceLow: z.number().nullable().optional(), referenceHigh: z.number().nullable().optional(), // Optional so a backup written before these columns existed still @@ -1120,13 +1129,29 @@ const labResultBackupSchema = z deletedAt: isoDateTime.nullable().optional(), updatedAt: isoDateTime.optional(), }) - .passthrough(); + .passthrough() + .superRefine((reading, ctx) => { + const qualitative = + (reading.value === null || reading.value === undefined) && + typeof reading.valueText === "string" && + reading.valueText.length > 0; + if (reading.unit.length === 0 && !qualitative) { + ctx.addIssue({ + code: "custom", + path: ["unit"], + message: "A numeric lab reading needs its unit", + }); + } + }); const biomarkerBackupSchema = z .object({ id: z.string().min(1).optional(), name: z.string().min(1), - unit: z.string().min(1), + // Empty for a qualitative marker, minted from a reading with no unit. The + // payload refine below refuses an empty unit on a marker a numeric + // reading is filed under. + unit: z.string(), lowerBound: z.number().nullable().optional(), upperBound: z.number().nullable().optional(), panel: z.string().nullable().optional(), @@ -2224,6 +2249,30 @@ export const backupPayloadSchema = z }) .passthrough() .superRefine((payload, ctx) => { + // A marker without a unit is a qualitative one. A numeric reading filed + // under it would restore as a number with no unit, so the file is refused + // naming the marker rather than restored into that. + const numericByMarker = new Set(); + for (const reading of payload.labResults) { + if (reading.value === null || reading.value === undefined) continue; + if (reading.biomarkerId) numericByMarker.add(`id:${reading.biomarkerId}`); + if (reading.biomarkerName) { + numericByMarker.add(`name:${reading.biomarkerName}`); + } + } + payload.biomarkers.forEach((marker, index) => { + if (marker.unit.length > 0) return; + if ( + (marker.id && numericByMarker.has(`id:${marker.id}`)) || + numericByMarker.has(`name:${marker.name}`) + ) { + ctx.addIssue({ + code: "custom", + path: ["biomarkers", index, "unit"], + message: `Biomarker "${marker.name}" has numeric readings and needs its unit`, + }); + } + }); if (payload.schemaVersion !== BACKUP_SCHEMA_VERSION) return; payload.measurements.forEach((measurement, index) => { if (!measurement.id) { diff --git a/tests/integration/backup-qualitative-lab-restore.test.ts b/tests/integration/backup-qualitative-lab-restore.test.ts new file mode 100644 index 000000000..c0f0dc071 --- /dev/null +++ b/tests/integration/backup-qualitative-lab-restore.test.ts @@ -0,0 +1,137 @@ +/** + * A backup holding a qualitative lab marker restores. + * + * A qualitative reading ("positive", "trace") has no unit, so its marker is + * minted with an empty unit and the reading stored under it + * (`POST /api/labs`, `isQualitative`). The backup schema demanded a non-empty + * unit on both rows, so the file the account's own backup wrote could not be + * read back: the parse refused it and the restore never started. Asserted on + * rows through the real writer, the real wire format, the real parser and the + * real restore, with the marker removed first so the file is the only copy. + * + * The empty unit is accepted for a qualitative reading and a marker none of + * whose readings is numeric; a numeric reading, or a marker a numeric reading + * is filed under, still needs its unit. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST as RESTORE } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserData: vi.fn() }; +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("a backup with a qualitative lab marker", () => { + it("restores the marker and its reading", async () => { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "qualitative-lab-owner", + email: "qualitative-lab-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + // The real write path mints the marker with an empty unit. + const { POST: CREATE_LAB } = await import("@/app/api/labs/route"); + const created = await CREATE_LAB( + new NextRequest("http://localhost/api/labs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + analyte: "Urine glucose", + valueText: "negative", + takenAt: "2026-07-01T08:00:00.000Z", + }), + }), + ); + expect(created.status).toBe(201); + const marker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: owner.id }, + }); + expect(marker.unit).toBe(""); + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + // The parse is where the file used to be refused. + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await prisma.dataBackup.create({ + data: { + userId: owner.id, + type: "QUALITATIVE_LAB_ROUND_TRIP", + data: encrypt(JSON.stringify(parsed)), + }, + }); + + await prisma.labResult.deleteMany({ where: { userId: owner.id } }); + await prisma.biomarker.deleteMany({ where: { userId: owner.id } }); + + const response = await RESTORE( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + const restoredMarker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: owner.id, name: "Urine glucose" }, + }); + expect(restoredMarker.unit).toBe(""); + const restoredReading = await prisma.labResult.findFirstOrThrow({ + where: { userId: owner.id }, + }); + expect(restoredReading).toMatchObject({ + biomarkerId: restoredMarker.id, + value: null, + valueText: "negative", + unit: "", + }); + }); +}); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 1f2497960..a47ed5359 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -3644,14 +3644,7 @@ const COLUMN_PRESENCE_ONLY: Readonly> = { * starts coming back, so the entry has to go in the change that carries it. * The list is empty again before the release is tagged. */ -const RELEASE_PENDING_COLUMNS: Readonly> = { - "IntradayCumulativeProfile.hourlyIncrements": - "hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest", - "IntradayCumulativeProfile.basis": - "hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest", - "IntradayCumulativeProfile.bucketCount": - "hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest", -}; +const RELEASE_PENDING_COLUMNS: Readonly> = {}; /** * Columns every reader parses through one function before using, compared @@ -3680,6 +3673,12 @@ const FILL_OVERRIDES: Readonly> = { "CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32), // CHECK in migration 0408: the profile is built from samples or from hour buckets. "IntradayCumulativeProfile.basis": "HOUR_BUCKETS", + // The posted hours are a whole day of slots (-1 for an hour not posted); + // the restore refuses any other length, as it does for the curve. + "IntradayCumulativeProfile.hourlyIncrements": Array.from( + { length: 24 }, + (_, hour) => (hour < 6 ? -1 : 350), + ), }; /** diff --git a/tests/integration/hour-buckets-and-backfill.test.ts b/tests/integration/hour-buckets-and-backfill.test.ts new file mode 100644 index 000000000..299b76dfa --- /dev/null +++ b/tests/integration/hour-buckets-and-backfill.test.ts @@ -0,0 +1,384 @@ +/** + * Hour buckets for summed types and the `backfill` trigger (ios #77), against + * real Postgres. + * + * An hourly `stats::T` row must never become a + * measurement: the day total stays the day row, and an hour stored as a + * measurement would be counted into that day a second time. The hours land in + * the intraday profile instead, which is what makes the same-time baseline + * usable on day one after a thirty-day backfill. And a backfill is a + * catch-up, so it must not move the live sync stamps. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-hour-buckets-backfill-32-bytes-minimum-1234"; + +const USER_ID = "user-hour-buckets"; +const STEPS = "HKQuantityTypeIdentifierStepCount"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "hour-buckets", + email: "hour-buckets@example.test", + timezone: "UTC", + }, + }); + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +async function postBatch(body: Record) { + const { POST } = await import("@/app/api/measurements/batch/route"); + const res = await POST( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + expect(res.status).toBe(200); + return (await res.json()).data as { + inserted: number; + updated: number; + entries: Array<{ index: number; status: string; reason?: string }>; + }; +} + +function dayKeyBack(days: number): string { + return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); +} + +function hourEntry(dateKey: string, hour: number, value: number) { + const hh = String(hour).padStart(2, "0"); + const start = `${dateKey}T${hh}:00:00.000Z`; + return { + hkIdentifier: STEPS, + value, + unit: "count", + startDate: start, + endDate: new Date(Date.parse(start) + 3_600_000).toISOString(), + externalId: `stats:${STEPS}:${dateKey}T${hh}`, + }; +} + +function dayEntry(dateKey: string, value: number) { + return { + hkIdentifier: STEPS, + value, + unit: "count", + startDate: `${dateKey}T00:00:00.000Z`, + endDate: `${dateKey}T12:00:00.000Z`, + externalId: `stats:${STEPS}:${dateKey}`, + }; +} + +describe("hour buckets for summed types", () => { + it("keeps the day total as the only measurement and builds the profile from the hours", async () => { + const day = dayKeyBack(2); + const hours = Array.from({ length: 24 }, (_, h) => + hourEntry(day, h, h * 10), + ); + const total = hours.reduce((s, e) => s + e.value, 0); + + const first = await postBatch({ + entries: [dayEntry(day, total), ...hours], + }); + expect(first.entries.slice(1).every((e) => e.status === "inserted")).toBe( + true, + ); + + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER_ID, type: "ACTIVITY_STEPS" }, + select: { value: true, externalId: true }, + }); + // The guard: no hour of a summed type ever reaches `measurements`. + expect(rows).toEqual([ + { value: total, externalId: `stats:${STEPS}:${day}` }, + ]); + + const profile = + await getPrismaClient().intradayCumulativeProfile.findUniqueOrThrow({ + where: { + userId_type_dateKey: { + userId: USER_ID, + type: "ACTIVITY_STEPS", + dateKey: day, + }, + }, + }); + expect(profile.basis).toBe("HOUR_BUCKETS"); + expect(profile.bucketCount).toBe(24); + expect(profile.hourlyIncrements).toEqual(hours.map((e) => e.value)); + expect(profile.hourlyCumulative[23]).toBe(total); + expect(profile.dayTotal).toBe(total); + expect(profile.timezone).toBe("UTC"); + + // A re-post of one hour overwrites it. + const again = await postBatch({ entries: [hourEntry(day, 5, 999)] }); + expect(again.entries[0]).toMatchObject({ status: "updated" }); + expect(again.updated).toBe(1); + const after = + await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({ + where: { userId: USER_ID, dateKey: day }, + }); + expect(after.hourlyIncrements[5]).toBe(999); + expect(after.dayTotal).toBe(total - 50 + 999); + // And the day row did not move. + const dayRow = await getPrismaClient().measurement.findFirstOrThrow({ + where: { userId: USER_ID, type: "ACTIVITY_STEPS" }, + }); + expect(dayRow.value).toBe(total); + }); + + it("keeps a day under four hours on its per-sample shape", async () => { + const day = dayKeyBack(3); + await postBatch({ + entries: [hourEntry(day, 8, 100), hourEntry(day, 9, 200)], + }); + const profile = + await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({ + where: { userId: USER_ID, dateKey: day }, + }); + expect(profile.basis).toBe("PER_SAMPLE"); + expect(profile.bucketCount).toBe(2); + expect(profile.hourlyCumulative).toEqual([]); + + // The drain's fold then fills the curve; the posted hours stay put. + const { mergeIntradayCumulativeProfile } = + await import("@/lib/measurements/intraday-cumulative-profile"); + await getPrismaClient().$transaction((tx) => + mergeIntradayCumulativeProfile(tx, { + userId: USER_ID, + type: "ACTIVITY_STEPS", + increment: { + dateKey: day, + timezone: "UTC", + hourlyCumulative: Array.from({ length: 24 }, (_, h) => + h >= 8 ? 300 : 0, + ), + dayTotal: 300, + sampleCount: 30, + }, + }), + ); + const folded = + await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({ + where: { userId: USER_ID, dateKey: day }, + }); + expect(folded.dayTotal).toBe(300); + expect(folded.hourlyIncrements[9]).toBe(200); + }); + + it("does not let the per-sample fold count a bucket-built day twice", async () => { + const day = dayKeyBack(4); + await postBatch({ + entries: Array.from({ length: 6 }, (_, h) => hourEntry(day, h + 8, 100)), + }); + const { mergeIntradayCumulativeProfile } = + await import("@/lib/measurements/intraday-cumulative-profile"); + await getPrismaClient().$transaction((tx) => + mergeIntradayCumulativeProfile(tx, { + userId: USER_ID, + type: "ACTIVITY_STEPS", + increment: { + dateKey: day, + timezone: "UTC", + hourlyCumulative: new Array(24).fill(600), + dayTotal: 600, + sampleCount: 60, + }, + }), + ); + const profile = + await getPrismaClient().intradayCumulativeProfile.findFirstOrThrow({ + where: { userId: USER_ID, dateKey: day }, + }); + expect(profile.basis).toBe("HOUR_BUCKETS"); + expect(profile.dayTotal).toBe(600); + expect(profile.sampleCount).toBe(6); + }); + + it("names every refusal", async () => { + const day = dayKeyBack(2); + const misaligned = { + ...hourEntry(day, 9, 10), + startDate: `${day}T09:30:00.000Z`, + }; + const unsupported = { + ...hourEntry(day, 9, 10), + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + unit: "kg", + externalId: `stats:HKQuantityTypeIdentifierBodyMass:${day}T09`, + }; + const old = hourEntry("2024-01-02", 9, 10); + const result = await postBatch({ entries: [misaligned, unsupported, old] }); + expect(result.entries.map((e) => e.reason)).toEqual([ + "hour_bucket_misaligned", + "hour_bucket_unsupported_type", + "hour_bucket_too_old", + ]); + expect( + await getPrismaClient().intradayCumulativeProfile.count({ + where: { userId: USER_ID }, + }), + ).toBe(0); + }); + + it("makes the same-time baseline usable on day one after a thirty-day backfill", async () => { + const entries = []; + for (let back = 31; back >= 1; back -= 1) { + const day = dayKeyBack(back); + for (let h = 0; h < 24; h += 1) entries.push(hourEntry(day, h, 50 + h)); + } + for (let i = 0; i < entries.length; i += 400) { + await postBatch({ + syncTrigger: "backfill", + entries: entries.slice(i, i + 400), + }); + } + const { computeSameTimeBaseline } = + await import("@/lib/insights/derived/same-time-baseline"); + // "Today" is the last backfilled day, at 20:30. + const now = new Date(`${dayKeyBack(1)}T20:30:00.000Z`); + const derived = await computeSameTimeBaseline(USER_ID, null, { + type: "ACTIVITY_STEPS", + now, + }); + expect(derived.status).toBe("ok"); + }); +}); + +describe('syncTrigger "backfill"', () => { + it("records lastBackfillAt and moves none of the live stamps", async () => { + const live = await postBatch({ + syncTrigger: "foreground", + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value: 61, + unit: "count/min", + startDate: "2026-10-01T06:00:00.000Z", + endDate: "2026-10-01T06:00:00.000Z", + externalId: "uuid-live-1", + }, + ], + }); + expect(live.inserted).toBe(1); + const prisma = getPrismaClient(); + const userBefore = await prisma.user.findUniqueOrThrow({ + where: { id: USER_ID }, + select: { + lastSyncedAt: true, + healthKitLastSyncedAt: true, + healthKitLastSyncTrigger: true, + healthKitLastBackgroundSyncAt: true, + }, + }); + const ledgerBefore = await prisma.healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "PULSE" } }, + }); + + const backfill = await postBatch({ + syncTrigger: "backfill", + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value: 63, + unit: "count/min", + startDate: "2026-09-01T06:00:00.000Z", + endDate: "2026-09-01T06:00:00.000Z", + externalId: "uuid-backfill-1", + }, + ], + }); + expect(backfill.inserted).toBe(1); + + expect( + await prisma.user.findUniqueOrThrow({ + where: { id: USER_ID }, + select: { + lastSyncedAt: true, + healthKitLastSyncedAt: true, + healthKitLastSyncTrigger: true, + healthKitLastBackgroundSyncAt: true, + }, + }), + ).toEqual(userBefore); + expect( + await prisma.healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "PULSE" } }, + }), + ).toEqual(ledgerBefore); + const backfillRow = await prisma.healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "backfill:PULSE" } }, + }); + expect(backfillRow.lastTrigger).toBeNull(); + + // The status read serves it, and the live facts are the foreground ones. + const { GET } = await import("@/app/api/integrations/healthkit/route"); + const res = await (GET as () => Promise)(); + const body = (await res.json()).data as { + metricFreshness: Array>; + syncProgress: { recordsAccepted: number } | null; + }; + const pulse = body.metricFreshness.find((e) => e.type === "PULSE"); + expect(pulse).toMatchObject({ + lastReceivedAt: ledgerBefore.lastReceivedAt.toISOString(), + lastTrigger: "foreground", + lastBackfillAt: backfillRow.lastReceivedAt.toISOString(), + }); + // syncProgress counts what arrived, the backfill included. + expect(body.syncProgress?.recordsAccepted).toBe(2); + expect( + body.metricFreshness.some((e) => String(e.type).startsWith("backfill:")), + ).toBe(false); + }); + + it("is refused by name for anything outside the enum", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + const res = await POST( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + syncTrigger: "history", + entries: [dayEntry("2026-10-01", 1)], + }), + }), + ); + expect(res.status).toBe(422); + }); +}); From b7706d2eccaad8c16c2efa0d4cd05c54f21c4128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 00:22:23 +0200 Subject: [PATCH 5/5] test(mood): read the patterns through the real aggregate, note days included --- tests/integration/mood-patterns.test.ts | 79 +++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/integration/mood-patterns.test.ts diff --git a/tests/integration/mood-patterns.test.ts b/tests/integration/mood-patterns.test.ts new file mode 100644 index 000000000..a22d1d155 --- /dev/null +++ b/tests/integration/mood-patterns.test.ts @@ -0,0 +1,79 @@ +/** + * Mood patterns from the real aggregate read (ios #116 item 6). + * + * The note days come from their own projection (`noteEncrypted` or the legacy + * `note` set, distinct by mood day), so the `notes` pattern is only as right + * as that query. This seeds a year with a weekend effect and notes on low + * days, runs `fetchMoodAggregates` against Postgres, and reads the patterns + * the wire serves for the year and for thirty days. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const USER_ID = "user-mood-patterns"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "mood-patterns", + email: "mood-patterns@example.test", + timezone: "UTC", + }, + }); +}); + +describe("mood patterns", () => { + it("finds the weekend and the note days in a seeded year", async () => { + const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); + const now = new Date(); + const rows = []; + for (let back = 1; back <= 200; back += 1) { + const at = new Date(now.getTime() - back * 86_400_000); + const date = at.toISOString().slice(0, 10); + const weekday = (at.getUTCDay() + 6) % 7; + const low = back % 3 === 0; + const jitter = ((back * 37) % 10) / 20; + const score = Math.min( + 5, + Math.max( + 1, + Math.round((weekday >= 5 ? 4.4 : 3) - (low ? 1 : 0) + jitter), + ), + ); + rows.push({ + userId: USER_ID, + date, + mood: "GUT" as const, + score, + source: "WEB" as const, + moodLoggedAt: at, + tz: "UTC", + ...(low ? { noteEncrypted: encryptToBytes("a hard day") } : {}), + }); + } + await getPrismaClient().moodEntry.createMany({ data: rows as never }); + + const { fetchMoodAggregates, moodInsightsWire } = + await import("@/lib/insights/mood-aggregates"); + const aggregate = await fetchMoodAggregates(USER_ID, now); + const year = moodInsightsWire(aggregate, 365, (k) => k).patterns; + expect(year.map((p) => p.kind)).toContain("weekend"); + expect(year.find((p) => p.kind === "notes")).toMatchObject({ + direction: "lower", + }); + const notes = year.find((p) => p.kind === "notes")!; + expect(notes.sampleSize.subject).toBeGreaterThanOrEqual(60); + const month = moodInsightsWire(aggregate, 30, (k) => k).patterns; + for (const p of month) { + expect( + p.sampleSize.subject + p.sampleSize.comparison, + ).toBeLessThanOrEqual(30); + } + }); +});