From 832aad9dd8ffb7888e468bc2825b9ccc3cf2feaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 08:54:43 +0200 Subject: [PATCH 01/20] Add the Vorsorge skip/snooze columns, the completion ledger and the custom-metric tombstone Migration 0334, all additive. MeasurementReminder gains snoozedUntil, lastSkippedAt and skipCount; the new MeasurementReminderEvent table is the append-only completion ledger (kind SATISFIED|SKIPPED, occurredAt, write-time onTime, source). CustomMetricEntry gains the deletedAt tombstone for undo parity with every other entry surface. Consumers land in the sibling commits of this branch. Refs #223 --- .../0334_vorsorge_skip_ledger/migration.sql | 54 ++++++++++++++ prisma/schema.prisma | 74 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 prisma/migrations/0334_vorsorge_skip_ledger/migration.sql diff --git a/prisma/migrations/0334_vorsorge_skip_ledger/migration.sql b/prisma/migrations/0334_vorsorge_skip_ledger/migration.sql new file mode 100644 index 000000000..f9e157775 --- /dev/null +++ b/prisma/migrations/0334_vorsorge_skip_ledger/migration.sql @@ -0,0 +1,54 @@ +-- Refs #223 + iOS #68 — Vorsorge snooze/skip + the completion ledger. +-- All additive; every existing reminder keeps its exact behaviour. + +-- Snooze cursor: non-NULL and in the future = "pushed back until this +-- instant". The snooze write sets next_due_at to the SAME instant, so every +-- due-state consumer moves with it; expiry is the clock passing the value. +ALTER TABLE "measurement_reminders" ADD COLUMN "snoozed_until" TIMESTAMP(3); + +-- Last honest skip. A skip re-anchors next_due_at at the skip instant but +-- never moves last_satisfied_at — done and skipped stay distinguishable. +ALTER TABLE "measurement_reminders" ADD COLUMN "last_skipped_at" TIMESTAMP(3); + +-- Lifetime skip counter ("skipped N times"). +ALTER TABLE "measurement_reminders" ADD COLUMN "skip_count" INTEGER NOT NULL DEFAULT 0; + +-- The completion ledger (iOS #68). The engine is single-cursor, so before +-- this table each satisfy overwrote the last and history was unrecoverable. +-- Append-only: one row per satisfy / skip, written by the one satisfy +-- primitive and the skip route. History starts with this release — the +-- cursor holds nothing to backfill from. +CREATE TYPE "measurement_reminder_event_kind" AS ENUM ('SATISFIED', 'SKIPPED'); + +CREATE TABLE "measurement_reminder_events" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "reminder_id" TEXT NOT NULL, + "kind" "measurement_reminder_event_kind" NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL, + "on_time" BOOLEAN NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "measurement_reminder_events_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "measurement_reminder_events_reminder_id_occurred_at_idx" + ON "measurement_reminder_events"("reminder_id", "occurred_at"); + +CREATE INDEX "measurement_reminder_events_user_id_idx" + ON "measurement_reminder_events"("user_id"); + +ALTER TABLE "measurement_reminder_events" + ADD CONSTRAINT "measurement_reminder_events_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "measurement_reminder_events" + ADD CONSTRAINT "measurement_reminder_events_reminder_id_fkey" + FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Custom-metric entries gain the soft-delete tombstone every peer entry +-- surface already has (undo parity — the one hard delete left). +ALTER TABLE "custom_metric_entries" ADD COLUMN "deleted_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 25c7d873d..b2c46a171 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -879,6 +879,9 @@ model User { // Server computes the canonical `nextDueAt`; the cron fires through the // existing notification dispatcher. Soft-deleted. measurementReminders MeasurementReminder[] + // v1.37.20 — completion ledger behind the reminders (iOS #68): one + // append-only row per satisfy / skip. Cascade-deleted with the account. + measurementReminderEvents MeasurementReminderEvent[] // v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …) // that pairs with the Vorsorge "annual blood panel" reminder so a result // has somewhere to land. Soft-deleted. @@ -5187,6 +5190,20 @@ model MeasurementReminder { /// excluded everywhere), whereas a booster is the engine's native case — /// recurring, satisfiable, cadence-bearing. vaccinationAntigen String? @map("vaccination_antigen") + /// v1.37.20 — snooze cursor. Non-NULL and in the future = "pushed back until + /// this instant". The snooze write sets `nextDueAt` to the SAME instant, so + /// every due-state consumer (digest, dashboard, cron, AI block) moves with + /// it without knowing snooze exists. Self-expiring: once the instant passes, + /// the clock comparison stops being true and no cleanup is needed. Any + /// recompute of `nextDueAt` (satisfy, skip, cadence PATCH) nulls it. + snoozedUntil DateTime? @map("snoozed_until") + /// v1.37.20 — last honest skip. A skip re-anchors `nextDueAt` at the skip + /// instant but NEVER moves `lastSatisfiedAt` — done and skipped stay + /// distinguishable forever. "Skipped cycle" derives as + /// `lastSkippedAt > lastSatisfiedAt` (or non-NULL with no satisfy yet). + lastSkippedAt DateTime? @map("last_skipped_at") + /// v1.37.20 — lifetime skip counter ("skipped N times"). + skipCount Int @default(0) @map("skip_count") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. @@ -5201,6 +5218,10 @@ model MeasurementReminder { /// this reminder. SetNull on the far side — deleting the reminder leaves /// the vaccination record intact. vaccinations VaccinationRecord[] + /// v1.37.20 — the completion ledger (iOS #68): every satisfy and every + /// skip appends one row. Cascade: the ledger has no meaning without its + /// reminder. + events MeasurementReminderEvent[] @@index([userId, deletedAt]) @@index([userId, nextDueAt]) @@ -5234,6 +5255,55 @@ enum ReminderOrigin { @@map("reminder_origin") } +/// v1.37.20 — what a completion-ledger row records: an honest fulfilment +/// (`SATISFIED` — a matching reading landed or the user marked it done) or an +/// honest skip (`SKIPPED` — the user deliberately let this cycle go, which is +/// NOT a completion and never touches `lastSatisfiedAt`). +enum MeasurementReminderEventKind { + SATISFIED + SKIPPED + + @@map("measurement_reminder_event_kind") +} + +/// v1.37.20 — the Vorsorge completion ledger (iOS #68). The reminder engine is +/// single-cursor (`nextDueAt` + `lastSatisfiedAt`), so before this table each +/// satisfy overwrote the previous one and history was unrecoverable. Every +/// path through the ONE satisfy primitive (`satisfyReminder`) and the skip +/// route appends one row here; nothing ever updates or deletes a row short of +/// deleting the reminder itself. History starts at the release that ships +/// this — the single cursor holds nothing to backfill from, which the iOS #68 +/// thread records as agreed. +model MeasurementReminderEvent { + id String @id @default(cuid()) + userId String @map("user_id") + reminderId String @map("reminder_id") + kind MeasurementReminderEventKind + /// The event instant: the satisfy instant (which may be the backdated + /// reading's own timestamp) or the skip instant (always the server clock). + occurredAt DateTime @map("occurred_at") + /// Derived AT WRITE TIME against the reminder's `nextDueAt` as it stood + /// before the event re-anchored it: `true` when the event landed at or + /// before the due instant (or the cadence had no computable due). Clients + /// (web + iOS) consume this resolved value and never re-derive it — the + /// pre-event `nextDueAt` is gone after the write, so write time is the only + /// honest moment to answer the question. + onTime Boolean @map("on_time") + /// Which path produced the event — a closed set of engine call sites + /// (`manual`, `auto_measurement`, `auto_lab`, `telegram`, `vaccination`, + /// `encounter`, `skip`), asserted in code, not free text. + source String + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + reminder MeasurementReminder @relation(fields: [reminderId], references: [id], onDelete: Cascade) + + /// The history endpoint reads newest-first per reminder. + @@index([reminderId, occurredAt]) + @@index([userId]) + @@map("measurement_reminder_events") +} + // ─── Lab results ───────────────────────────────────────────── /// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual @@ -5432,6 +5502,10 @@ model CustomMetricEntry { measuredAt DateTime @map("measured_at") note String? createdAt DateTime @default(now()) @map("created_at") + /// v1.37.20 — soft-delete tombstone, peer parity with Measurement / + /// MoodEntry (every other entry surface deletes recoverably with an undo + /// toast; custom-metric entries were the one hard delete). NULL = live. + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade) From 57ec22073cadc3e48d3cd27d75e39c3537b14fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:23:34 +0200 Subject: [PATCH 02/20] Publish the mood-entries family, the linked-context read and the import endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten operations the spec owed: the seven mood-entries routes (list, create, read, edit, delete, restore, bulk-delete) whose /bulk twin was already published, GET /api/mood/linked-context (a cross-section read through the mood URL that grant-scope reviewers need visible), and POST /api/import (the spec documented polling an import job with no way to create one). Every schema mirrors the handler's own Zod source; the envelope, refusal and error semantics are read off the routes, not invented. Request-body ids are bound instances now — a bare .meta() call clones in Zod 4 and never reaches components.schemas. --- src/lib/openapi/routes/import.ts | 172 +++++++++++- src/lib/openapi/routes/mood.ts | 461 ++++++++++++++++++++++++++++++- 2 files changed, 617 insertions(+), 16 deletions(-) diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index 040e6c103..7b3e13415 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -1,17 +1,23 @@ /** - * OpenAPI route table — CSV measurement import (v1.17.1). + * OpenAPI route table — data import (CSV since v1.17.1, JSON since the + * publication sweep that closed the create/poll gap). * * The CSV importer is the cold-start escape hatch for self-hosters migrating - * from a spreadsheet or another tracker. The JSON (`/api/import`) and Apple- - * Health (`/api/import/apple-health-export`) routes predate the contract - * surface and are intentionally not documented here; this module documents - * the one new endpoint so the wire contract stays drift-free (CI gate). iOS - * does not call it — its canonical ingest is `POST /api/measurements/batch`. + * from a spreadsheet or another tracker. The JSON importer (`/api/import`) + * restores the structure `GET /api/export/json` emits. The Apple-Health + * route (`/api/import/apple-health-export`) predates the contract surface + * and remains undocumented here. iOS calls neither — its canonical ingest is + * `POST /api/measurements/batch`. */ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; -import { dataEnvelope, stdResponses } from "./shared"; +import { + glucoseContextEnum, + measurementTypeEnum, +} from "@/lib/validations/measurement"; +import { moodLevelEnum } from "@/lib/validations/mood"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; const csvImportRowResult = z .object({ @@ -47,6 +53,126 @@ const csvImportResponse = z "Per-row import outcome. Mirrors the batch-route per-entry envelope. In `dryRun` mode no write runs and every valid row reports its projected `inserted` status.", }); +// ── JSON import — mirrors the route-local `importSchema` +// (`src/app/api/import/route.ts`), which is deliberately not exported: the +// runtime side carries transforms + plausibility refinements the document +// restates in prose. + +const jsonImportMeasurement = z + .object({ + type: measurementTypeEnum, + value: z + .number() + .describe( + "Checked against the type's server-side plausibility range; an out-of-range value fails the whole request with 422.", + ), + unit: z.string(), + measuredAt: z.iso + .datetime({ offset: true }) + .describe( + "Bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900.", + ), + glucoseContext: glucoseContextEnum + .optional() + .describe( + "Optional even on a BLOOD_GLUCOSE row — a sensor export classifies nothing per sample.", + ), + source: z + .string() + .optional() + .describe( + "Accepted for export-format compatibility and ignored: every imported row stores `source = IMPORT`.", + ), + notes: z.string().optional().describe("Encrypted at rest."), + externalId: z + .string() + .min(1) + .max(120) + .optional() + .describe( + "Source-stable id making re-import idempotent (upsert on `(userId, type, source=IMPORT, externalId)`; a re-import updates the row in place). Absent → first-write-wins create with a NULL key, which DOES duplicate on re-import. An unstable id (an object description or memory address that rotates per launch) skips THIS row, never the file.", + ), + }) + .meta({ + id: "JsonImportMeasurement", + description: "One measurement row of a JSON import file.", + }); + +const jsonImportMoodEntry = z + .object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .describe("Local-day label, YYYY-MM-DD."), + mood: moodLevelEnum, + score: z + .number() + .int() + .min(1) + .max(5) + .describe( + "Stored as sent — the export format's own field. Pleasantness (`a1`) is re-derived from `mood` on every imported row, never taken from this.", + ), + tags: z + .string() + .optional() + .describe("JSON-encoded array of tag strings, stored verbatim."), + loggedAt: z.iso + .datetime({ offset: true }) + .optional() + .describe( + "Bounded like `measuredAt`. Absent → the server derives a stable per-row instant within the `date` day, so a re-import of the same file dedups instead of duplicating.", + ), + externalId: z + .string() + .min(1) + .max(120) + .optional() + .describe( + "Source-stable id (e.g. a Daylio row id) making re-import idempotent (upsert on `(userId, source=IMPORT, externalId)`). Same stability rule as the measurement `externalId`: an unstable id skips the row.", + ), + }) + .meta({ + id: "JsonImportMoodEntry", + description: "One mood-entry row of a JSON import file.", + }); + +const jsonImportRequest = z + .object({ + measurements: z.array(jsonImportMeasurement).max(10000).optional(), + moodEntries: z.array(jsonImportMoodEntry).max(10000).optional(), + }) + .meta({ + id: "JsonImportRequest", + description: + "JSON import payload, matching the structure `GET /api/export/json` emits. Both sections optional; up to 10 000 rows each.", + }); + +const jsonImportStats = z + .object({ + measurements: z + .number() + .int() + .nonnegative() + .describe("Measurement rows written (inserted or updated in place)."), + moodEntries: z + .number() + .int() + .nonnegative() + .describe("Mood-entry rows written (inserted or updated in place)."), + skipped: z + .number() + .int() + .nonnegative() + .describe( + "Rows not written: duplicates of existing rows, and rows carrying an unstable `externalId`.", + ), + }) + .meta({ + id: "JsonImportStats", + description: "Per-section outcome counts of a JSON import.", + }); + export const importPaths: NonNullable = { "/api/import/csv": { post: { @@ -100,4 +226,36 @@ export const importPaths: NonNullable = { }, }, }, + "/api/import": { + post: { + tags: ["Import"], + summary: "Import measurements and mood entries from JSON", + description: + "Restores a JSON export (the structure `GET /api/export/json` emits): up to 10 000 measurements plus 10 000 mood entries per call, body ≤ 16 MB. Rows carrying an `externalId` upsert idempotently (re-import updates in place); rows without one are first-write-wins and dedup against the existing unique keys, so an exact duplicate is `skipped` rather than doubled. A row with an unstable `externalId` is skipped while the rest of the file still lands. Every imported measurement stores `source = IMPORT`; every imported mood entry re-derives pleasantness from its label. Writes are per-row, not transactional: on a mid-file write failure the response is 503 (`meta.errorCode` = `import.write_failed`, `meta.retryable` = true) with `meta.stats` carrying what landed — rows already written stay written, and a retry is only safe against duplication for rows that carry an `externalId`. Validation is fail-fast (the 422 carries the first issue's message, not the multi-issue envelope). Rate-limited 5/hour per user (shared `import:` bucket with the CSV importer).", + requestBody: { + required: true, + content: { "application/json": { schema: jsonImportRequest } }, + }, + responses: { + "200": { + description: "Import completed; per-section outcome counts.", + content: { + "application/json": { + schema: dataEnvelope(jsonImportStats, "JsonImportStatsEnvelope"), + }, + }, + }, + ...stdResponses, + "413": { + description: "Body exceeds the 16 MB limit.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "503": { + description: + "A write failed partway through the file. `meta.errorCode` = `import.write_failed`, `meta.retryable` = true, `meta.stats` carries the counts that landed before the failure. Rows already written stay written; retry the same file — rows with `externalId` re-import idempotently, rows without one may duplicate.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 7589199e4..ffebfc628 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -1,16 +1,23 @@ /** - * OpenAPI route table — mood-tag taxonomy management (catalog read, custom - * tags, custom groups, per-user layout, catalogue hide). + * OpenAPI route table — the mood surface: entry CRUD (list, create, single + * read/edit/delete, restore, bulk delete), the bulk backfill, the linked-day + * read, and mood-tag taxonomy management (catalog read, custom tags, custom + * groups, per-user layout, catalogue hide). * * Part of the OpenAPI route table; aggregated in `./index.ts`. - * Request schemas come from `src/lib/mood/{custom-tags,tag-layout}.ts` - * where they are shared with the runtime request parsing, so the wire - * contract stays single-source. Response DTOs are declared here mirroring - * the route handlers under `src/app/api/mood/tags/`. + * Request schemas come from `src/lib/validations/mood.ts` and + * `src/lib/mood/{custom-tags,tag-layout}.ts` where they are shared with the + * runtime request parsing, so the wire contract stays single-source. Response + * DTOs are declared here mirroring the route handlers under + * `src/app/api/mood-entries/` and `src/app/api/mood/`. * * v1.13.0 shipped the custom-tag CRUD + hide surface outside the YAML; - * v1.17.0 adds groups + layout and registers the WHOLE surface so the iOS - * client gets a locked contract. + * v1.17.0 adds groups + layout and registers the WHOLE taxonomy surface so + * the iOS client gets a locked contract. The entry CRUD family itself and + * the linked-day read joined the published document later — they predated + * the contract surface and had been served unpublished while their bulk + * twin was in the YAML, which left grant-scope reviewers unable to see the + * cross-section read at all. */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; @@ -23,7 +30,13 @@ import { hideCatalogueTagSchema, } from "@/lib/mood/custom-tags"; import { moodTagLayoutSchema } from "@/lib/mood/tag-layout"; -import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood"; +import { + createMoodEntrySchema, + listMoodEntriesSchema, + moodLevelEnum, + moodSourceEnum, + updateMoodEntrySchema, +} from "@/lib/validations/mood"; import { CONTACT_CIRCLE_KEYS, CONTACT_EXTENT_KEYS, @@ -76,6 +89,27 @@ hideCatalogueTagSchema.meta({ "Hide / show a CATALOGUE tag for the calling user. Custom tags are hidden via their own `isActive` flag on the custom PATCH instead (this route 400s a `custom:` key).", }); +// Assigned rather than annotated in place: Zod 4's `.meta()` returns a NEW +// instance carrying the id, so the route table below must reference these +// consts for the names to reach `components.schemas`. +const createMoodEntryRequest = createMoodEntrySchema.meta({ + id: "CreateMoodEntryRequest", + description: + "Single mood-entry create. `mood` is the five-point label; the server derives `score` (1-5) and pleasantness `a1` from it, so a one-tap check-in writes a complete entry. `a1`-`a5` are the level-A self-state values (0-10): absent says nothing, a number is the answer, an explicit null takes an answer back (`a1` falls back to the derivation instead of clearing). With `externalId` the create UPSERTS on `(userId, source, externalId)` — the idempotent re-post iOS drives over Bearer; without it a same-`(date, moodLoggedAt)` re-post 409s. `moodLoggedAt` is bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900.", +}); + +const updateMoodEntryRequest = updateMoodEntrySchema.meta({ + id: "UpdateMoodEntryRequest", + description: + "Per-field mood-entry edit. Omitted fields keep the stored value. `tagKeys`, `ratedFactors` and `context` replace their whole set when present (`null` clears). `a2`-`a5`: explicit null clears; `a1` null re-derives from the label. Editing `mood` without `a1` re-derives pleasantness the same way it re-derives `score`. Editing `moodLoggedAt` re-anchors the row's `date` (and `tz`) to the account's display timezone.", +}); + +const listMoodEntriesQuery = listMoodEntriesSchema.meta({ + id: "ListMoodEntriesQuery", + description: + "Filters + paging for the mood-entry list. `from` / `to` bound the local-day `date` label (YYYY-MM-DD, inclusive). `limit` capped at 500.", +}); + moodTagLayoutSchema.meta({ id: "MoodTagLayout", description: @@ -388,6 +422,213 @@ const bulkMoodResponse = z }) .meta({ id: "BulkMoodResponse" }); +// ── Entry CRUD — mirrors the route handlers under +// `src/app/api/mood-entries/` and the shared list read +// (`src/lib/mood/list-read.ts`). One DTO for the list rows, the single GET, +// the create and the edit response, because the handlers deliberately answer +// one shape: the edit form reads back exactly what the write path takes. + +const moodEntryRatedFactor = z + .object({ + key: z.string(), + rating: z.number().int(), + }) + .meta({ + id: "MoodEntryRatedFactor", + description: "One rated factor on an entry, with its per-entry score.", + }); + +const moodEntryDto = z + .object({ + id: z.string(), + userId: z.string(), + date: z + .string() + .describe( + "YYYY-MM-DD, anchored to the row's `tz` (legacy rows with `tz: null` read as Europe/Berlin).", + ), + mood: moodLevelEnum, + score: z + .number() + .int() + .describe("The legacy 1-5 axis, server-derived from `mood`."), + a1: z + .number() + .int() + .nullable() + .describe( + "Pleasantness, 0-10. Server-derived from `mood` on every write when the request carried none, so it is populated even for rows a client posted without it. Consume the value; do not re-derive it.", + ), + a2: z + .number() + .int() + .nullable() + .describe( + "Stress, 0-10, INVERSE-oriented: a higher value is more stress. NULL when nobody answered it.", + ), + a3: z.number().int().nullable().describe("Energy, 0-10, or null."), + a4: z.number().int().nullable().describe("Connectedness, 0-10, or null."), + a5: z.number().int().nullable().describe("Stability, 0-10, or null."), + tags: z + .array(z.string()) + .describe("Free-text tag list, decoded from storage; `[]` when none."), + note: z + .string() + .nullable() + .describe( + "Free-text note, decrypted for the response. AES-256-GCM at rest; the ciphertext never rides the wire.", + ), + source: moodSourceEnum, + externalId: z + .string() + .nullable() + .describe("The sync client's source-stable dedup id, or null."), + moodLoggedAt: z.iso.datetime({ offset: true }), + tz: z + .string() + .nullable() + .describe( + "IANA zone the `date` label is anchored to. Null on legacy rows (read as Europe/Berlin).", + ), + syncedAt: z.iso.datetime({ offset: true }), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + syncVersion: z + .number() + .int() + .nonnegative() + .describe("LWW reconciliation counter; mood is last-writer-wins by it."), + deletedAt: z + .null() + .describe( + "Always null on this surface: tombstoned rows are hidden from the list and 404 on a direct GET. Tombstones surface on the `/api/sync/changes` feed instead.", + ), + tagKeys: z + .array(z.string()) + .describe("Binary structured-tag keys linked to the entry."), + ratedFactors: z.array(moodEntryRatedFactor), + context: moodContextWire + .nullable() + .describe( + "The day's context, or null when the entry carries none. Null and an object of nulls are different answers.", + ), + }) + .meta({ + id: "MoodEntryDTO", + description: + "One mood entry as every entry surface answers it — list rows, single GET, create and edit responses share this shape, so a client hydrating from a write response renders without a refetch.", + }); + +const moodEntryEnvelope = dataEnvelope(moodEntryDto, "MoodEntryEnvelope"); + +const moodEntryListResponse = z.object({ + entries: z.array(moodEntryDto), + meta: z.object({ + total: z.number().int().nonnegative(), + limit: z.number().int(), + offset: z.number().int(), + }), +}); + +// Mirrors the route-local `restoreSchema` / `bulkDeleteSchema` +// (`src/app/api/mood-entries/{restore,bulk-delete}/route.ts`) — the two +// declare the identical shape on purpose, so one published schema serves both. +const moodEntryIdsPayload = z + .object({ + ids: z.array(z.string().min(1)).min(1).max(200), + }) + .meta({ + id: "MoodEntryIdsRequest", + description: + "1-200 mood-entry ids, scoped to the resolved record. A forged / foreign / non-matching id is a silent no-op, never a 404 existence leak: the mutation `where` pins the record owner's id.", + }); + +// The `(userId, date, moodLoggedAt)` unique — only the legacy no-`externalId` +// create path and a `moodLoggedAt` edit can trip it; a create carrying +// `externalId` upserts instead of 409-ing. +const duplicateTimestampResponse = { + "409": { + description: + "A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place.", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +// ── The linked day — mirrors `src/lib/mood/linked-context.ts` ──────── + +const linkedFigure = z + .union([ + z.object({ present: z.literal(false) }), + z.object({ + present: z.literal(true), + value: z.number(), + unit: z.string(), + }), + ]) + .meta({ + id: "MoodLinkedFigure", + description: + "A figure that either exists or honestly does not. `{ present: false }` means the data is not recorded — never zero, never an error.", + }); + +/** + * A block that can also be absent because its owning module is switched off. + * A disabled module blanks its block rather than answering zero or a filtered + * version of itself. + */ +function linkedBlock(shape: T) { + return z.union([ + z.object({ + available: z.literal(false), + reason: z.literal("module-disabled"), + }), + z.object({ available: z.literal(true), ...shape }), + ]); +} + +const moodLinkedContextResponse = z + .object({ + day: z + .string() + .describe("The local day the figures are about, YYYY-MM-DD."), + sleep: linkedBlock({ + asleep: linkedFigure.describe( + "Time asleep (minutes) for the night the day woke up on.", + ), + inBed: linkedFigure.describe( + "Time in bed (minutes), when any writer recorded a bed window.", + ), + }), + activity: linkedBlock({ + steps: linkedFigure, + activeEnergy: linkedFigure, + }), + vitals: linkedBlock({ + restingHeartRate: linkedFigure, + heartRateVariability: linkedFigure, + }), + body: linkedBlock({ + logged: z + .boolean() + .describe("Whether an illness day-log exists for this day at all."), + functionalImpact: linkedFigure.describe( + "How much the day was limited, 0-3, as the illness module records it.", + ), + symptoms: z + .array(z.string()) + .describe("Symptoms linked to the day-log, by catalogue key."), + episodeId: z + .string() + .nullable() + .describe("The episode to open in the illness module, when any."), + }), + }) + .meta({ + id: "MoodLinkedContext", + description: + "What the modules that own sleep, activity, vitals and body symptoms already know about one local day. Nothing here is stored on the mood row — every figure is resolved from its owning module at read time, cross-source de-duplicated to one canonical source per metric. A switched-off module blanks its whole block (`available: false`).", + }); + // ── The day's two readings ─────────────────────────────────────────── // // Published as the RESOLVED value. The server computes the expected value, the @@ -814,4 +1055,206 @@ export const moodPaths: NonNullable = { }, }, }, + // ── Entry CRUD (published later than its bulk twin; see file header) ─ + "/api/mood-entries": { + get: { + tags: ["Mood"], + summary: "List mood entries", + description: + "The record's mood entries, filtered and paged. Soft-deleted (tombstoned) rows are hidden; the `/api/sync/changes` feed is where tombstones surface. Every row carries the same `MoodEntryDTO` shape the write paths answer, structured-tag keys and rated factors included, so the edit form pre-populates without per-row requests. A malformed query 422s with every issue listed (`meta.errorCode` = `mood.list.invalid`).", + requestParams: { query: listMoodEntriesQuery }, + responses: { + "200": { + description: "One page of entries plus paging meta.", + content: { + "application/json": { + schema: dataEnvelope( + moodEntryListResponse, + "MoodEntryListEnvelope", + ), + }, + }, + }, + ...recordRefusal(), + ...stdResponses, + }, + }, + post: { + tags: ["Mood"], + summary: "Create a mood entry", + description: + "Writes the entry, its structured-tag links and its day context in one transaction — a link or context failure rolls the entry back rather than letting a retry mint a duplicate. Idempotent via the `Idempotency-Key` header. With `externalId` the write UPSERTS on `(userId, source, externalId)` (a re-post updates the row in place and restates pleasantness from the label, while A2-A5 and a stored context are only touched when the request carries them); without it, a same-`(date, moodLoggedAt)` re-post 409s. A delegated manager cannot supply `externalId` (422, `meta.errorCode` = `mood.create.external_id_not_delegable`) — it is a device's contract with its own rows. A rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a malformed body 422s (`mood.create.invalid`). An unstable `externalId` (an object description or memory address that rotates per launch) is refused at validation.", + requestBody: { + required: true, + content: { "application/json": { schema: createMoodEntryRequest } }, + }, + responses: { + "201": { + description: + "Entry created (or upserted in place via `externalId`), with its persisted tag keys, rated factors and context.", + content: { "application/json": { schema: moodEntryEnvelope } }, + }, + ...duplicateTimestampResponse, + ...recordRefusal(), + ...stdResponses, + }, + }, + }, + "/api/mood-entries/{id}": { + get: { + tags: ["Mood"], + summary: "Read one mood entry", + description: + "The full entry with its day context, so the edit surface loads both in one request. A soft-deleted row 404s, matching the list read's invariant.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "The entry.", + content: { "application/json": { schema: moodEntryEnvelope } }, + }, + ...notFoundResponse, + ...recordRefusal(), + ...stdResponses, + }, + }, + put: { + tags: ["Mood"], + summary: "Edit a mood entry", + description: + "Per-field edit: omitted fields keep their stored value; `tagKeys`, `ratedFactors` and `context` replace their whole set when present (`null` clears). Editing `mood` re-derives `score` and — when the label actually moved — pleasantness `a1`; editing `moodLoggedAt` re-anchors the row's `date` and `tz` to the account's display timezone. Bumps `syncVersion` so paired clients reconcile last-writer-wins. A soft-deleted row 404s (`meta.errorCode` = `mood.not_found`); an edit landing on another row's `(date, moodLoggedAt)` 409s (`mood.duplicate_timestamp`); a rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a malformed body 422s (`mood.update.invalid`).", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: updateMoodEntryRequest } }, + }, + responses: { + "200": { + description: + "The entry after the edit, with its persisted tag keys, rated factors and context.", + content: { "application/json": { schema: moodEntryEnvelope } }, + }, + ...notFoundResponse, + ...duplicateTimestampResponse, + ...recordRefusal(), + ...stdResponses, + }, + }, + delete: { + tags: ["Mood"], + summary: "Delete a mood entry (soft)", + description: + "Tombstones the row (`deletedAt` set, `syncVersion` bumped) rather than hard-deleting, so the `/api/sync/changes` feed carries the tombstone to paired clients that were offline. Invisible to every normal read from here on; `POST /api/mood-entries/restore` puts it back. Re-deleting an already-tombstoned row is a harmless idempotent no-op.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Entry tombstoned.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.literal(true) }), + "MoodEntryDeleteEnvelope", + ), + }, + }, + }, + ...notFoundResponse, + ...recordRefusal(), + ...stdResponses, + }, + }, + }, + "/api/mood-entries/restore": { + post: { + tags: ["Mood"], + summary: "Restore soft-deleted mood entries", + description: + "Un-tombstones every owned, currently-deleted id in one statement — the delete-Undo affordance. Clears `deletedAt` and bumps `syncVersion` so the row re-surfaces in normal reads and rides the `/api/sync/changes` feed as an upsert. A forged / foreign / not-deleted id is a silent no-op, never a 404 existence leak. Idempotent via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user, so a manager burns their own allowance). A malformed body 422s (`meta.errorCode` = `mood.restore.invalid`).", + requestBody: { + required: true, + content: { "application/json": { schema: moodEntryIdsPayload } }, + }, + responses: { + "200": { + description: "`restored` counts the rows actually un-tombstoned.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ restored: z.number().int().nonnegative() }), + "MoodEntriesRestoreEnvelope", + ), + }, + }, + }, + ...recordRefusal(), + ...stdResponses, + }, + }, + }, + "/api/mood-entries/bulk-delete": { + post: { + tags: ["Mood"], + summary: "Soft-delete mood entries in bulk", + description: + "Tombstones every owned, not-already-deleted id in one statement — the management list's multi-select delete, matching the single-DELETE tombstone contract. A forged / foreign id is a silent no-op, never a 404 existence leak. Idempotent via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user). A malformed body 422s (`meta.errorCode` = `mood.bulk-delete.invalid`).", + requestBody: { + required: true, + content: { "application/json": { schema: moodEntryIdsPayload } }, + }, + responses: { + "200": { + description: "`deleted` counts the rows actually tombstoned.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.number().int().nonnegative() }), + "MoodEntriesBulkDeleteEnvelope", + ), + }, + }, + }, + ...recordRefusal(), + ...stdResponses, + }, + }, + }, + "/api/mood/linked-context": { + get: { + tags: ["Mood"], + summary: "The linked figures of one local day", + description: + "What the modules that own sleep, activity, vitals and body symptoms already know about one day, resolved at read time and never stored on the mood row — a corrected sleep session shows up here with nothing to re-sync. Keyed by local day rather than entry id, deliberately: the capture sheet needs the figures BEFORE an entry exists. `tz` anchors the day's boundaries — the capture sheet sends the browser's zone, the edit dialog the entry's stored `tz`; absent falls back to the account's display timezone. Whole-record read: the payload crosses into the measurements and illness sections from a mood surface, so a section-scoped grant is refused outright rather than served a filtered day that would read as empty. Gated: 403 `module.disabled` when the mood module is off. A malformed query 422s (`meta.errorCode` = `mood.linkedContext.invalid`).", + requestParams: { + query: z.object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .describe("The local day to resolve, YYYY-MM-DD."), + tz: z + .string() + .min(1) + .max(64) + .optional() + .describe( + "IANA zone the day is anchored to. Send the entry's stored `tz` when editing; omit to use the account's display timezone.", + ), + }), + }, + responses: { + "200": { + description: + "The day's linked blocks; absence is explicit per figure and per module.", + content: { + "application/json": { + schema: dataEnvelope( + moodLinkedContextResponse, + "MoodLinkedContextEnvelope", + ), + }, + }, + }, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...stdResponses, + }, + }, + }, }; From cd12b3886e31c9e08b9c6015332a36a091a5fd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:23:39 +0200 Subject: [PATCH 03/20] Say what the stage tooltip's total actually is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #611. The nap separation itself shipped in v1.34.4; what remained of the report was the tooltip footer labelling a single night's total with the orphaned 'Average per night' key (the headline card that owned it was dropped in v1.22.0, so hovering one column claimed an average that was that night's sum). The footer now names the main session's sleep, and on a day with an inferred nap adds the whole-day total beside it — the Main sleep / Naps / Total triple the report asked for. Percentages keep dividing by the night alone. --- .../sleep-stage-stacked-bar.test.tsx | 41 +++++++++++ .../insights/sleep-stage-stacked-bar.tsx | 71 ++++++++++++++----- 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index afe9fd8a1..3ff6b6baa 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -6,6 +6,7 @@ import { SleepStageStackedBar, STAGE_ORDER, buildCompositionRows, + compositionTotals, windowHasNap, type SleepStageBreakdown, type SleepStageNight, @@ -270,6 +271,46 @@ describe("nap band", () => { expect(windowHasNap(rows)).toBe(false); }); + /** + * The tooltip footer used to label a single night's total "Average per + * night" — the orphaned title of a headline card dropped in v1.22.0. The + * footer now says what the figure is: the main sleep session, with a + * second "total" line on days a nap rides on top (issue #611's + * Main sleep / Naps / Total sleep shape). Recharts renders no tooltip + * under SSR, so the split is pinned through the pure totals helper the + * tooltip reads. + */ + it("totals the main session and the whole day separately when a nap rides on the column", () => { + const perNight: SleepStageNight[] = [ + { + dayKey: "2026-06-04", + stages: { CORE: 250, DEEP: 50, AWAKE: 20 }, + napMinutes: 80, + napCount: 1, + }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + const totals = compositionTotals(rows[0]); + // The main figure is the night column alone — the nap never leaks in, + // so the per-stage percentages keep meaning "of this night". + expect(totals.nightMinutes).toBe(320); + expect(totals.napMinutes).toBe(80); + // The day line is the whole drawn column: night plus nap. + expect(totals.dayMinutes).toBe(400); + }); + + it("collapses the totals to the night alone on a day without a nap", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { CORE: 250, DEEP: 50 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + const totals = compositionTotals(rows[0]); + expect(totals.nightMinutes).toBe(300); + expect(totals.napMinutes).toBe(0); + // No second line to render — the day IS the night. + expect(totals.dayMinutes).toBe(300); + }); + it("drops the band when the nap falls outside the visible window", () => { // The nap is on the oldest day; a 2-day window scrolls past it, so the // legend must not keep a nap entry the user cannot see. diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index 15e50bb0b..b5b6ae800 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -231,6 +231,33 @@ export function windowHasNap(rows: readonly CompositionRow[]): boolean { ); } +/** + * Totals for one hovered column, split the way the tooltip reports them. + * + * `nightMinutes` is the sum of the stacked stage bands — the main session + * only, the same figure the column draws and the per-stage percentages + * divide by. `napMinutes` is the day's inferred naps. `dayMinutes` is the + * whole drawn column, night plus naps; it equals `nightMinutes` on a day + * without one, and the tooltip renders its line only when it differs. + * + * Pulled out of the tooltip body because Recharts renders no tooltip under + * SSR — this is the only place the footer split can be pinned by a test. + */ +export function compositionTotals(row: CompositionRow | undefined): { + nightMinutes: number; + napMinutes: number; + dayMinutes: number; +} { + let nightMinutes = 0; + for (const stage of STAGE_ORDER) { + const minutes = row?.[stage]; + if (typeof minutes === "number") nightMinutes += minutes; + } + const nap = row?.[NAP_KEY]; + const napMinutes = typeof nap === "number" ? nap : 0; + return { nightMinutes, napMinutes, dayMinutes: nightMinutes + napMinutes }; +} + export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { const { t, locale } = useTranslations(); @@ -379,17 +406,13 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { Record | undefined; const napCount = typeof row?.napCount === "number" ? row.napCount : 0; - // The footer total is the NIGHT. The nap is listed above - // it as its own line and stays out of the sum, so the - // per-stage percentages keep meaning "of this night". - const totalNight = payload.reduce( - (sum, entry) => - entry.dataKey === NAP_KEY - ? sum - : sum + - (typeof entry.value === "number" ? entry.value : 0), - 0, - ); + // The footer's first line is the NIGHT. The nap is listed + // above it as its own line and stays out of that sum, so + // the per-stage percentages keep meaning "of this night". + // On a day with a nap a second line carries the whole + // column — main sleep plus naps — the Main sleep / Naps / + // Total split issue #611 asked for. + const totals = compositionTotals(row); return (
@@ -409,8 +432,10 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { : stageLabels[NAP_KEY] : (stageLabels[stage] ?? stage); const pct = - totalNight > 0 - ? Math.round((minutes / totalNight) * 100) + totals.nightMinutes > 0 + ? Math.round( + (minutes / totals.nightMinutes) * 100, + ) : 0; return (
); })} - {totalNight > 0 && ( -
- {t("insights.sleep.headlineTitle")} - {formatDurationMinutes(totalNight, t)} + {totals.nightMinutes > 0 && ( +
+
+ {t("insights.sleep.mainSleep")} + + {formatDurationMinutes(totals.nightMinutes, t)} + +
+ {totals.napMinutes > 0 && ( +
+ {t("insights.sleep.totalSleep")} + + {formatDurationMinutes(totals.dayMinutes, t)} + +
+ )}
)}
From 5619783648863bb428eed36b7cbb7d041ff23029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:23:58 +0200 Subject: [PATCH 04/20] Let a checkup be skipped or pushed back, and remember every outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #223. A due Vorsorge item could only be done, deleted or ignored; Telegram's postpone moved the date with no trace. Both decisions are first-class now, and both are honest: - POST /{id}/skip (no body): the interval restarts from the skip instant through the recurrence engine, lastSkippedAt is stamped, skipCount increments, any snooze clears. lastSatisfiedAt is NEVER touched — done and skipped stay distinguishable forever, enforced in the primitive and pinned by tests. - POST /{id}/snooze ({until: YYYY-MM-DD}): the server resolves the day to the notifyHour in the profile timezone and moves snoozedUntil and nextDueAt to the same instant, so every due-state consumer follows without knowing snooze exists. At least tomorrow, at most five years. - GET /{id}/history: the new append-only completion ledger (MeasurementReminderEvent), one row per satisfy from every engine path and per skip, onTime derived at write time against the due instant that was current when the event landed. Two anchor fixes close the backdated-event gaps: the auto-resolve floor is now the later of last satisfy and last skip (an old reading synced late no longer resolves the cycle the user just skipped), and a backdated real result re-anchors the next due at the later of its own instant and the skip decision. Screenings stay skippable and snoozable — the satisfy-side 409 guards claimed fulfilment; a skip claims the opposite. Appointment reminders 404 on all three routes, in the lookup, like every by-id sibling. /checkups gains the postpone sheet (quick chips, date field, the skip zone with its own confirm) in both view branches, with muted meta lines and no card colour change. The MCP preventive-care tool carries the resolved fields; the OpenAPI contract publishes all three paths and the widened DTO. --- docs/api/openapi.yaml | 1980 ++++++++++++++++- messages/de.json | 22 +- messages/en.json | 22 +- messages/es.json | 22 +- messages/fr.json | 22 +- messages/it.json | 22 +- messages/pl.json | 22 +- .../encounter-reminder-exclusion.test.ts | 28 +- src/__tests__/sharing-surface-guard.test.ts | 30 +- .../__tests__/skip-snooze-history.test.ts | 314 +++ .../[id]/complete/route.ts | 8 +- .../[id]/history/route.ts | 72 + .../api/measurement-reminders/[id]/route.ts | 3 + .../[id]/satisfy/route.ts | 2 +- .../measurement-reminders/[id]/skip/route.ts | 77 + .../[id]/snooze/route.ts | 134 ++ .../delegated-write-affordances.test.tsx | 3 + .../vorsorge-section.tsx | 208 +- src/hooks/use-measurement-reminders.ts | 33 +- src/lib/encounters/service.ts | 6 + src/lib/jobs/measurement-reminder.ts | 4 + src/lib/mcp/__tests__/tools.test.ts | 9 + src/lib/mcp/tools.ts | 11 + .../__tests__/resolve.test.ts | 58 + .../__tests__/satisfy.test.ts | 244 +- src/lib/measurement-reminders/dto.ts | 13 + src/lib/measurement-reminders/resolve.ts | 29 +- src/lib/measurement-reminders/satisfy.ts | 206 +- .../openapi/routes/measurement-reminders.ts | 89 + src/lib/query-keys/measurement-reminders.ts | 3 + src/lib/record-activity/activity-verb.ts | 11 + src/lib/telegram-webhook-handlers.ts | 5 +- src/lib/vaccinations/satisfy-matching.ts | 7 + src/lib/validations/measurement-reminders.ts | 95 +- tests/fixtures/v137/sharing-matrix.ts | 16 + 35 files changed, 3715 insertions(+), 115 deletions(-) create mode 100644 src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts create mode 100644 src/app/api/measurement-reminders/[id]/history/route.ts create mode 100644 src/app/api/measurement-reminders/[id]/skip/route.ts create mode 100644 src/app/api/measurement-reminders/[id]/snooze/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e86c3cfb4..64f302d78 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8906,6 +8906,312 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a8 "429": *a4 + /api/mood-entries: + get: + tags: + - Mood + summary: List mood entries + description: The record's mood entries, filtered and paged. Soft-deleted (tombstoned) rows are hidden; the + `/api/sync/changes` feed is where tombstones surface. Every row carries the same `MoodEntryDTO` shape the write + paths answer, structured-tag keys and rated factors included, so the edit form pre-populates without per-row + requests. A malformed query 422s with every issue listed (`meta.errorCode` = `mood.list.invalid`). + parameters: + - in: query + name: mood + schema: + type: string + enum: + - SUPER_GUT + - GUT + - OKAY + - SCHLECHT + - LAUSIG + - in: query + name: source + schema: + type: string + enum: + - MANUAL + - MOODLOG + - WEB + - TELEGRAM + - DAYLIO + - in: query + name: from + schema: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - in: query + name: to + schema: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - in: query + name: limit + schema: + default: 100 + type: integer + minimum: 1 + maximum: 500 + - in: query + name: offset + schema: + default: 0 + type: integer + minimum: 0 + maximum: 9007199254740991 + - in: query + name: sortBy + schema: + default: moodLoggedAt + type: string + enum: + - date + - mood + - score + - moodLoggedAt + - source + - in: query + name: sortDir + schema: + default: desc + type: string + enum: + - asc + - desc + responses: + "200": + description: One page of entries plus paging meta. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryListEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + post: + tags: + - Mood + summary: Create a mood entry + description: Writes the entry, its structured-tag links and its day context in one transaction — a link or context + failure rolls the entry back rather than letting a retry mint a duplicate. Idempotent via the `Idempotency-Key` + header. With `externalId` the write UPSERTS on `(userId, source, externalId)` (a re-post updates the row in + place and restates pleasantness from the label, while A2-A5 and a stored context are only touched when the + request carries them); without it, a same-`(date, moodLoggedAt)` re-post 409s. A delegated manager cannot supply + `externalId` (422, `meta.errorCode` = `mood.create.external_id_not_delegable`) — it is a device's contract with + its own rows. A rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a malformed body + 422s (`mood.create.invalid`). An unstable `externalId` (an object description or memory address that rotates per + launch) is refused at validation. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMoodEntryRequest" + responses: + "201": + description: Entry created (or upserted in place via `externalId`), with its persisted tag keys, rated factors and + context. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryEnvelope" + "401": *a1 + "403": *a6 + "409": &a12 + description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = + `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write + upserts in place. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/mood-entries/{id}: + get: + tags: + - Mood + summary: Read one mood entry + description: The full entry with its day context, so the edit surface loads both in one request. A soft-deleted row + 404s, matching the list read's invariant. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The entry. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryEnvelope" + "401": *a1 + "403": *a6 + "404": *a11 + "422": *a3 + "429": *a4 + put: + tags: + - Mood + summary: Edit a mood entry + description: "Per-field edit: omitted fields keep their stored value; `tagKeys`, `ratedFactors` and `context` replace + their whole set when present (`null` clears). Editing `mood` re-derives `score` and — when the label actually + moved — pleasantness `a1`; editing `moodLoggedAt` re-anchors the row's `date` and `tz` to the account's display + timezone. Bumps `syncVersion` so paired clients reconcile last-writer-wins. A soft-deleted row 404s + (`meta.errorCode` = `mood.not_found`); an edit landing on another row's `(date, moodLoggedAt)` 409s + (`mood.duplicate_timestamp`); a rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a + malformed body 422s (`mood.update.invalid`)." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMoodEntryRequest" + responses: + "200": + description: The entry after the edit, with its persisted tag keys, rated factors and context. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryEnvelope" + "401": *a1 + "403": *a6 + "404": *a11 + "409": *a12 + "422": *a3 + "429": *a4 + delete: + tags: + - Mood + summary: Delete a mood entry (soft) + description: Tombstones the row (`deletedAt` set, `syncVersion` bumped) rather than hard-deleting, so the + `/api/sync/changes` feed carries the tombstone to paired clients that were offline. Invisible to every normal + read from here on; `POST /api/mood-entries/restore` puts it back. Re-deleting an already-tombstoned row is a + harmless idempotent no-op. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Entry tombstoned. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryDeleteEnvelope" + "401": *a1 + "403": *a6 + "404": *a11 + "422": *a3 + "429": *a4 + /api/mood-entries/restore: + post: + tags: + - Mood + summary: Restore soft-deleted mood entries + description: Un-tombstones every owned, currently-deleted id in one statement — the delete-Undo affordance. Clears + `deletedAt` and bumps `syncVersion` so the row re-surfaces in normal reads and rides the `/api/sync/changes` + feed as an upsert. A forged / foreign / not-deleted id is a silent no-op, never a 404 existence leak. Idempotent + via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user, so a manager burns their own + allowance). A malformed body 422s (`meta.errorCode` = `mood.restore.invalid`). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryIdsRequest" + responses: + "200": + description: "`restored` counts the rows actually un-tombstoned." + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + /api/mood-entries/bulk-delete: + post: + tags: + - Mood + summary: Soft-delete mood entries in bulk + description: Tombstones every owned, not-already-deleted id in one statement — the management list's multi-select + delete, matching the single-DELETE tombstone contract. A forged / foreign id is a silent no-op, never a 404 + existence leak. Idempotent via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user). A + malformed body 422s (`meta.errorCode` = `mood.bulk-delete.invalid`). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryIdsRequest" + responses: + "200": + description: "`deleted` counts the rows actually tombstoned." + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + /api/mood/linked-context: + get: + tags: + - Mood + summary: The linked figures of one local day + description: "What the modules that own sleep, activity, vitals and body symptoms already know about one day, resolved + at read time and never stored on the mood row — a corrected sleep session shows up here with nothing to re-sync. + Keyed by local day rather than entry id, deliberately: the capture sheet needs the figures BEFORE an entry + exists. `tz` anchors the day's boundaries — the capture sheet sends the browser's zone, the edit dialog the + entry's stored `tz`; absent falls back to the account's display timezone. Whole-record read: the payload crosses + into the measurements and illness sections from a mood surface, so a section-scoped grant is refused outright + rather than served a filtered day that would read as empty. Gated: 403 `module.disabled` when the mood module is + off. A malformed query 422s (`meta.errorCode` = `mood.linkedContext.invalid`)." + parameters: + - in: query + name: date + schema: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + description: The local day to resolve, YYYY-MM-DD. + required: true + description: The local day to resolve, YYYY-MM-DD. + - in: query + name: tz + schema: + description: IANA zone the day is anchored to. Send the entry's stored `tz` when editing; omit to use the account's + display timezone. + type: string + minLength: 1 + maxLength: 64 + description: IANA zone the day is anchored to. Send the entry's stored `tz` when editing; omit to use the account's + display timezone. + responses: + "200": + description: The day's linked blocks; absence is explicit per figure and per module. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodLinkedContextEnvelope" + "401": *a1 + "403": *a7 + "422": *a3 + "429": *a4 /api/settings/reminder-thresholds: get: tags: @@ -9056,6 +9362,51 @@ paths: $ref: "#/components/schemas/CsvImportTooLargeEnvelope" "422": *a3 "429": *a4 + /api/import: + post: + tags: + - Import + summary: Import measurements and mood entries from JSON + description: "Restores a JSON export (the structure `GET /api/export/json` emits): up to 10 000 measurements plus 10 000 + mood entries per call, body ≤ 16 MB. Rows carrying an `externalId` upsert idempotently (re-import updates in + place); rows without one are first-write-wins and dedup against the existing unique keys, so an exact duplicate + is `skipped` rather than doubled. A row with an unstable `externalId` is skipped while the rest of the file + still lands. Every imported measurement stores `source = IMPORT`; every imported mood entry re-derives + pleasantness from its label. Writes are per-row, not transactional: on a mid-file write failure the response is + 503 (`meta.errorCode` = `import.write_failed`, `meta.retryable` = true) with `meta.stats` carrying what landed — + rows already written stay written, and a retry is only safe against duplication for rows that carry an + `externalId`. Validation is fail-fast (the 422 carries the first issue's message, not the multi-issue envelope). + Rate-limited 5/hour per user (shared `import:` bucket with the CSV importer)." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/JsonImportRequest" + responses: + "200": + description: Import completed; per-section outcome counts. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonImportStatsEnvelope" + "401": *a1 + "413": + description: Body exceeds the 16 MB limit. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + "503": + description: A write failed partway through the file. `meta.errorCode` = `import.write_failed`, `meta.retryable` = true, + `meta.stats` carries the counts that landed before the failure. Rows already written stay written; retry the + same file — rows with `externalId` re-import idempotently, rows without one may duplicate. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/measurement-reminders: get: tags: @@ -9118,7 +9469,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 "403": *a6 - "404": &a12 + "404": &a13 description: Measurement reminder not found / not owned. content: application/json: @@ -9153,7 +9504,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 "403": *a6 - "404": *a12 + "404": *a13 "422": *a3 "429": *a4 delete: @@ -9176,7 +9527,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 "403": *a6 - "404": *a12 + "404": *a13 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/satisfy: @@ -9201,7 +9552,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 "403": *a6 - "404": *a12 + "404": *a13 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/complete: @@ -9229,7 +9580,112 @@ paths: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 "403": *a6 - "404": *a12 + "404": *a13 + "422": *a3 + "429": *a4 + /api/measurement-reminders/{id}/skip: + post: + tags: + - MeasurementReminders + summary: Skip the current due cycle (v1.37.20) + description: "Honestly skips the current due cycle — no body; the skip instant is the server clock. The interval + restarts from the skip (rolling: skip + N days at the notify hour; rrule: next occurrence strictly after the + skip), lastSkippedAt is stamped, skipCount incremented, any snooze cleared, and a SKIPPED row lands in the + completion ledger. lastSatisfiedAt is never touched: a skip is not a completion. Screening reminders are + skippable (the satisfy-side 409 guards claimed fulfilment; a skip claims the opposite). Owner-scoped; an + appointment (encounter) reminder 404s." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Skip applied (skipped=true) or forward-only no-op (skipped=false); reminder carries the canonical post-skip + DTO. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" + "401": *a1 + "403": *a6 + "404": *a13 + "422": *a3 + "429": *a4 + /api/measurement-reminders/{id}/snooze: + post: + tags: + - MeasurementReminders + summary: Snooze to a calendar day (v1.37.20) + description: "Pushes the current due date back to a named calendar day (YYYY-MM-DD), resolved server-side to the + reminder's notifyHour in the profile timezone. snoozedUntil and nextDueAt are set to the same instant, so every + due-state consumer moves together and the cursor self-expires. The regular cadence is untouched — + lastSatisfiedAt, lastSkippedAt and the anchor stay put. Must be at least tomorrow and at most five years out + (422 otherwise). Repeated snoozes: last one wins. Owner-scoped; an appointment reminder 404s." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderSnooze" + responses: + "200": + description: Snooze applied; reminder carries the resolved DTO. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" + "401": *a1 + "403": *a6 + "404": *a13 + "422": *a3 + "429": *a4 + /api/measurement-reminders/{id}/history: + get: + tags: + - MeasurementReminders + summary: Completion ledger, newest first (v1.37.20) + description: "Paginated completion ledger for one reminder (iOS #68): one row per satisfy (from any path) and per skip, + newest first. onTime is derived server-side at write time against the due instant that was current when the + event landed. History begins at the release that introduced the ledger — the single-cursor engine holds nothing + to backfill from. Owner-scoped; an appointment reminder 404s." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: limit + schema: + default: 50 + type: integer + minimum: 1 + maximum: 100 + - in: query + name: offset + schema: + default: 0 + type: integer + minimum: 0 + maximum: 9007199254740991 + responses: + "200": + description: The reminder's completion ledger page. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" + "401": *a1 + "403": *a6 + "404": *a13 "422": *a3 "429": *a4 /api/labs: @@ -9411,7 +9867,7 @@ paths: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 "403": *a6 - "404": &a13 + "404": &a14 description: Lab result not found (or owned by another user). content: application/json: @@ -9506,7 +9962,7 @@ paths: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 "403": *a6 - "404": *a13 + "404": *a14 "422": *a3 "429": *a4 delete: @@ -9529,7 +9985,7 @@ paths: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 "403": *a6 - "404": *a13 + "404": *a14 "422": *a3 "429": *a4 /api/labs/restore: @@ -9759,7 +10215,7 @@ paths: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 "403": *a6 - "409": &a15 + "409": &a16 description: A biomarker with this name already exists for the caller. content: application/json: @@ -9788,7 +10244,7 @@ paths: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 "403": *a6 - "404": &a14 + "404": &a15 description: Biomarker not found (or owned by another user). content: application/json: @@ -9856,8 +10312,8 @@ paths: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 "403": *a6 - "404": *a14 - "409": *a15 + "404": *a15 + "409": *a16 "422": *a3 "429": *a4 delete: @@ -9880,7 +10336,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a14 + "404": *a15 "422": *a3 "429": *a4 /api/encounters: @@ -10087,7 +10543,7 @@ paths: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a6 - "404": &a16 + "404": &a17 description: Visit not found (or owned by another user). content: application/json: @@ -10188,7 +10644,7 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a17 "422": *a3 "429": *a4 delete: @@ -10213,7 +10669,7 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a17 "422": *a3 "429": *a4 /api/encounters/{id}/restore: @@ -10239,7 +10695,7 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a17 "422": *a3 "429": *a4 /api/encounters/{id}/links: @@ -10288,7 +10744,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a3 "429": *a4 delete: @@ -10336,7 +10792,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a3 "429": *a4 /api/practitioners: @@ -10445,7 +10901,7 @@ paths: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a6 - "404": &a17 + "404": &a18 description: Practitioner not found (or owned by another user). content: application/json: @@ -10510,7 +10966,7 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a6 - "404": *a17 + "404": *a18 "422": *a3 "429": *a4 delete: @@ -10534,7 +10990,7 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a6 - "404": *a17 + "404": *a18 "422": *a3 "429": *a4 /api/practitioners/{id}/restore: @@ -10560,7 +11016,7 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a6 - "404": *a17 + "404": *a18 "422": *a3 "429": *a4 /api/vaccinations: @@ -10726,7 +11182,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a6 - "404": &a18 + "404": &a19 description: Vaccination not found (or owned by another user). content: application/json: @@ -10833,7 +11289,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 delete: @@ -10858,7 +11314,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/vaccinations/{id}/restore: @@ -10883,7 +11339,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/vaccinations/{id}/booster: @@ -10941,7 +11397,7 @@ paths: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/vaccinations/suggest: @@ -11016,7 +11472,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 delete: @@ -11062,7 +11518,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes: @@ -11190,7 +11646,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": &a19 + "404": &a20 description: Illness episode not found (or owned by another user). content: application/json: @@ -11269,7 +11725,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 delete: @@ -11293,7 +11749,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/illness/episodes/{id}/restore: @@ -11320,7 +11776,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/illness/episodes/{id}/resolve: @@ -11357,7 +11813,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/illness/episodes/{id}/day-logs: @@ -11408,7 +11864,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 post: @@ -11488,7 +11944,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/illness/episodes/{id}/correlation: @@ -11515,7 +11971,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/illness/insights: @@ -11788,7 +12244,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a6 - "404": &a20 + "404": &a21 description: Allergy not found (or owned by another user). content: application/json: @@ -11871,7 +12327,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 delete: @@ -11895,7 +12351,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 /api/family-history: @@ -12003,7 +12459,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": &a21 + "404": &a22 description: Family history entry not found (or owned by another user). content: application/json: @@ -12072,7 +12528,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 delete: @@ -12096,7 +12552,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 /api/documents/inbound: @@ -13249,7 +13705,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a23 + "409": &a24 description: A custom metric with this name already exists for the caller. content: application/json: @@ -13278,7 +13734,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a6 - "404": &a22 + "404": &a23 description: Custom metric not found (or owned by another user). content: application/json: @@ -13342,8 +13798,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a22 - "409": *a23 + "404": *a23 + "409": *a24 "422": *a3 "429": *a4 delete: @@ -13366,7 +13822,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a22 + "404": *a23 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries: @@ -13410,7 +13866,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a6 - "404": *a22 + "404": *a23 "422": *a3 "429": *a4 post: @@ -13454,7 +13910,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a22 + "404": *a23 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries/{entryId}: @@ -13504,7 +13960,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a24 + "404": &a25 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -13536,7 +13992,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a24 + "404": *a25 "422": *a3 "429": *a4 /api/onboarding/disclaimer: @@ -13706,7 +14162,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a25 + "403": &a26 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -13761,7 +14217,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "422": *a3 "429": *a4 /api/nutrients/water: @@ -13788,7 +14244,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "422": *a3 "429": *a4 /api/integrations/healthkit: @@ -14126,7 +14582,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a26 + "403": &a27 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -14151,7 +14607,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "422": *a3 "429": *a4 post: @@ -14246,7 +14702,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "404": description: No such invitation for this account. content: @@ -14290,7 +14746,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "404": description: No such grant on this account's record. content: @@ -14331,7 +14787,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "404": description: No such grant held by this account. content: @@ -14630,7 +15086,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -17146,6 +17602,686 @@ components: token. The two manage cards write different fields of the same blob; echo the token to have a stale write refused with 409 instead of resurrecting the other card's overwritten field. Omit it for the legacy unconditional write. + CreateMoodEntryRequest: + type: object + properties: + mood: + type: string + enum: + - SUPER_GUT + - GUT + - OKAY + - SCHLECHT + - LAUSIG + tags: + maxItems: 20 + type: array + items: + type: string + maxLength: 50 + tagKeys: + maxItems: 30 + type: array + items: + type: string + maxLength: 60 + ratedFactors: + maxItems: 30 + type: array + items: + type: object + properties: + key: + type: string + maxLength: 60 + rating: + type: integer + minimum: 1 + maximum: 5 + required: + - key + - rating + a1: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a2: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a3: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a4: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a5: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + note: + type: string + maxLength: 500 + context: + anyOf: + - type: object + properties: + workStatus: + anyOf: + - type: string + enum: + - off + - partial + - regular + - overtime + - type: "null" + workMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + overtimeMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + workLoad: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + workSatisfaction: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactCircles: + anyOf: + - maxItems: 8 + type: array + items: + type: string + enum: + - partner + - child + - family + - friends + - colleagues + - acquaintances + - professional + - other + - type: "null" + contactForm: + anyOf: + - type: string + enum: + - inPerson + - phone + - video + - written + - type: "null" + contactExtent: + anyOf: + - type: string + enum: + - brief + - medium + - extended + - type: "null" + contactQuality: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactSupport: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureCategories: + anyOf: + - maxItems: 11 + type: array + items: + type: string + enum: + - film + - reading + - gaming + - music + - creative + - outing + - garden + - cooking + - timeWithChild + - relaxation + - other + - type: "null" + leisureMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + leisureJoy: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureRecovery: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + eventType: + anyOf: + - type: string + enum: + - goodNews + - badNews + - success + - disappointment + - conflict + - appointment + - familyEvent + - change + - unexpected + - type: "null" + eventValence: + anyOf: + - type: integer + minimum: -5 + maximum: 5 + - type: "null" + eventAt: + anyOf: + - 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)))$ + - type: "null" + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + - type: "null" + moodLoggedAt: + 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)))$ + source: + default: MANUAL + type: string + enum: + - MANUAL + - MOODLOG + - WEB + - TELEGRAM + - DAYLIO + externalId: + type: string + minLength: 1 + maxLength: 120 + required: + - mood + - moodLoggedAt + description: "Single mood-entry create. `mood` is the five-point label; the server derives `score` (1-5) and + pleasantness `a1` from it, so a one-tap check-in writes a complete entry. `a1`-`a5` are the level-A self-state + values (0-10): absent says nothing, a number is the answer, an explicit null takes an answer back (`a1` falls + back to the derivation instead of clearing). With `externalId` the create UPSERTS on `(userId, source, + externalId)` — the idempotent re-post iOS drives over Bearer; without it a same-`(date, moodLoggedAt)` re-post + 409s. `moodLoggedAt` is bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900." + UpdateMoodEntryRequest: + type: object + properties: + mood: + type: string + enum: + - SUPER_GUT + - GUT + - OKAY + - SCHLECHT + - LAUSIG + tags: + anyOf: + - maxItems: 20 + type: array + items: + type: string + maxLength: 50 + - type: "null" + tagKeys: + anyOf: + - maxItems: 30 + type: array + items: + type: string + maxLength: 60 + - type: "null" + ratedFactors: + anyOf: + - maxItems: 30 + type: array + items: + type: object + properties: + key: + type: string + maxLength: 60 + rating: + type: integer + minimum: 1 + maximum: 5 + required: + - key + - rating + - type: "null" + a1: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a2: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a3: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a4: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a5: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + context: + anyOf: + - type: object + properties: + workStatus: + anyOf: + - type: string + enum: + - off + - partial + - regular + - overtime + - type: "null" + workMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + overtimeMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + workLoad: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + workSatisfaction: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactCircles: + anyOf: + - maxItems: 8 + type: array + items: + type: string + enum: + - partner + - child + - family + - friends + - colleagues + - acquaintances + - professional + - other + - type: "null" + contactForm: + anyOf: + - type: string + enum: + - inPerson + - phone + - video + - written + - type: "null" + contactExtent: + anyOf: + - type: string + enum: + - brief + - medium + - extended + - type: "null" + contactQuality: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactSupport: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureCategories: + anyOf: + - maxItems: 11 + type: array + items: + type: string + enum: + - film + - reading + - gaming + - music + - creative + - outing + - garden + - cooking + - timeWithChild + - relaxation + - other + - type: "null" + leisureMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + leisureJoy: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureRecovery: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + eventType: + anyOf: + - type: string + enum: + - goodNews + - badNews + - success + - disappointment + - conflict + - appointment + - familyEvent + - change + - unexpected + - type: "null" + eventValence: + anyOf: + - type: integer + minimum: -5 + maximum: 5 + - type: "null" + eventAt: + anyOf: + - 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)))$ + - type: "null" + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + - type: "null" + moodLoggedAt: + 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)))$ + description: "Per-field mood-entry edit. Omitted fields keep the stored value. `tagKeys`, `ratedFactors` and `context` + replace their whole set when present (`null` clears). `a2`-`a5`: explicit null clears; `a1` null re-derives from + the label. Editing `mood` without `a1` re-derives pleasantness the same way it re-derives `score`. Editing + `moodLoggedAt` re-anchors the row's `date` (and `tz`) to the account's display timezone." + MoodEntryIdsRequest: + type: object + properties: + ids: + minItems: 1 + maxItems: 200 + type: array + items: + type: string + minLength: 1 + required: + - ids + description: "1-200 mood-entry ids, scoped to the resolved record. A forged / foreign / non-matching id is a silent + no-op, never a 404 existence leak: the mutation `where` pins the record owner's id." + JsonImportRequest: + type: object + properties: + measurements: + maxItems: 10000 + type: array + items: + $ref: "#/components/schemas/JsonImportMeasurement" + moodEntries: + maxItems: 10000 + type: array + items: + $ref: "#/components/schemas/JsonImportMoodEntry" + description: JSON import payload, matching the structure `GET /api/export/json` emits. Both sections optional; up to 10 + 000 rows each. + JsonImportMeasurement: + type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + value: + type: number + description: Checked against the type's server-side plausibility range; an out-of-range value fails the whole request + with 422. + unit: + type: string + measuredAt: + 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)))$ + description: "Bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900." + glucoseContext: + description: Optional even on a BLOOD_GLUCOSE row — a sensor export classifies nothing per sample. + type: string + enum: + - FASTING + - POSTPRANDIAL + - RANDOM + - BEDTIME + source: + description: "Accepted for export-format compatibility and ignored: every imported row stores `source = IMPORT`." + type: string + notes: + description: Encrypted at rest. + type: string + externalId: + description: Source-stable id making re-import idempotent (upsert on `(userId, type, source=IMPORT, externalId)`; a + re-import updates the row in place). Absent → first-write-wins create with a NULL key, which DOES duplicate + on re-import. An unstable id (an object description or memory address that rotates per launch) skips THIS + row, never the file. + type: string + minLength: 1 + maxLength: 120 + required: + - type + - value + - unit + - measuredAt + description: One measurement row of a JSON import file. + JsonImportMoodEntry: + type: object + properties: + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + description: Local-day label, YYYY-MM-DD. + mood: + type: string + enum: + - SUPER_GUT + - GUT + - OKAY + - SCHLECHT + - LAUSIG + score: + type: integer + minimum: 1 + maximum: 5 + description: Stored as sent — the export format's own field. Pleasantness (`a1`) is re-derived from `mood` on every + imported row, never taken from this. + tags: + description: JSON-encoded array of tag strings, stored verbatim. + type: string + loggedAt: + description: Bounded like `measuredAt`. Absent → the server derives a stable per-row instant within the `date` day, so a + re-import of the same file dedups instead of duplicating. + 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)))$ + externalId: + description: "Source-stable id (e.g. a Daylio row id) making re-import idempotent (upsert on `(userId, source=IMPORT, + externalId)`). Same stability rule as the measurement `externalId`: an unstable id skips the row." + type: string + minLength: 1 + maxLength: 120 + required: + - date + - mood + - score + description: One mood-entry row of a JSON import file. MeasurementReminderCreate: type: object properties: @@ -17262,6 +18398,19 @@ components: type: boolean description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side after the patch applies. + MeasurementReminderSnooze: + type: object + properties: + until: + type: string + format: date + 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])))$ + required: + - until + description: "Push a reminder's current due date back to a calendar day (YYYY-MM-DD). The server resolves the day to the + reminder's notifyHour in the profile timezone. Must be at least tomorrow and at most five years out. The regular + cadence is untouched: lastSatisfiedAt and the anchor stay where they are, and the reminder resumes its normal + interval after the snoozed cycle." RestoreLabResultsRequest: type: object properties: @@ -34670,6 +35819,499 @@ components: - data - error additionalProperties: false + MoodEntryListEnvelope: + type: object + properties: + data: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/MoodEntryDTO" + meta: + type: object + properties: + total: + type: integer + minimum: 0 + maximum: 9007199254740991 + limit: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + offset: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - total + - limit + - offset + additionalProperties: false + required: + - entries + - meta + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodEntryDTO: + type: object + properties: + id: + type: string + userId: + type: string + date: + type: string + description: "YYYY-MM-DD, anchored to the row's `tz` (legacy rows with `tz: null` read as Europe/Berlin)." + mood: + type: string + enum: + - SUPER_GUT + - GUT + - OKAY + - SCHLECHT + - LAUSIG + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The legacy 1-5 axis, server-derived from `mood`. + a1: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Pleasantness, 0-10. Server-derived from `mood` on every write when the request carried none, so it is + populated even for rows a client posted without it. Consume the value; do not re-derive it. + a2: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: "Stress, 0-10, INVERSE-oriented: a higher value is more stress. NULL when nobody answered it." + a3: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Energy, 0-10, or null. + a4: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Connectedness, 0-10, or null. + a5: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Stability, 0-10, or null. + tags: + type: array + items: + type: string + description: Free-text tag list, decoded from storage; `[]` when none. + note: + anyOf: + - type: string + - type: "null" + description: Free-text note, decrypted for the response. AES-256-GCM at rest; the ciphertext never rides the wire. + source: + type: string + enum: + - MANUAL + - MOODLOG + - WEB + - TELEGRAM + - DAYLIO + externalId: + anyOf: + - type: string + - type: "null" + description: The sync client's source-stable dedup id, or null. + moodLoggedAt: + 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)))$ + tz: + anyOf: + - type: string + - type: "null" + description: IANA zone the `date` label is anchored to. Null on legacy rows (read as Europe/Berlin). + syncedAt: + 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)))$ + 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)))$ + updatedAt: + 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)))$ + syncVersion: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: LWW reconciliation counter; mood is last-writer-wins by it. + deletedAt: + type: "null" + description: "Always null on this surface: tombstoned rows are hidden from the list and 404 on a direct GET. Tombstones + surface on the `/api/sync/changes` feed instead." + tagKeys: + type: array + items: + type: string + description: Binary structured-tag keys linked to the entry. + ratedFactors: + type: array + items: + $ref: "#/components/schemas/MoodEntryRatedFactor" + context: + anyOf: + - $ref: "#/components/schemas/MoodContextOutput" + - type: "null" + description: The day's context, or null when the entry carries none. Null and an object of nulls are different answers. + required: + - id + - userId + - date + - mood + - score + - a1 + - a2 + - a3 + - a4 + - a5 + - tags + - note + - source + - externalId + - moodLoggedAt + - tz + - syncedAt + - createdAt + - updatedAt + - syncVersion + - deletedAt + - tagKeys + - ratedFactors + - context + additionalProperties: false + description: One mood entry as every entry surface answers it — list rows, single GET, create and edit responses share + this shape, so a client hydrating from a write response renders without a refetch. + MoodEntryRatedFactor: + type: object + properties: + key: + type: string + rating: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - key + - rating + additionalProperties: false + description: One rated factor on an entry, with its per-entry score. + MoodEntryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MoodEntryDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodEntryDeleteEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodEntriesRestoreEnvelope: + type: object + properties: + data: + type: object + properties: + restored: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - restored + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodEntriesBulkDeleteEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodLinkedContextEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MoodLinkedContext" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodLinkedContext: + type: object + properties: + day: + type: string + description: The local day the figures are about, YYYY-MM-DD. + sleep: + anyOf: + - type: object + properties: + available: + type: boolean + const: false + reason: + type: string + const: module-disabled + required: + - available + - reason + additionalProperties: false + - type: object + properties: + available: + type: boolean + const: true + asleep: + description: Time asleep (minutes) for the night the day woke up on. + $ref: "#/components/schemas/MoodLinkedFigure" + inBed: + description: Time in bed (minutes), when any writer recorded a bed window. + $ref: "#/components/schemas/MoodLinkedFigure" + required: + - available + - asleep + - inBed + additionalProperties: false + activity: + anyOf: + - type: object + properties: + available: + type: boolean + const: false + reason: + type: string + const: module-disabled + required: + - available + - reason + additionalProperties: false + - type: object + properties: + available: + type: boolean + const: true + steps: + $ref: "#/components/schemas/MoodLinkedFigure" + activeEnergy: + $ref: "#/components/schemas/MoodLinkedFigure" + required: + - available + - steps + - activeEnergy + additionalProperties: false + vitals: + anyOf: + - type: object + properties: + available: + type: boolean + const: false + reason: + type: string + const: module-disabled + required: + - available + - reason + additionalProperties: false + - type: object + properties: + available: + type: boolean + const: true + restingHeartRate: + $ref: "#/components/schemas/MoodLinkedFigure" + heartRateVariability: + $ref: "#/components/schemas/MoodLinkedFigure" + required: + - available + - restingHeartRate + - heartRateVariability + additionalProperties: false + body: + anyOf: + - type: object + properties: + available: + type: boolean + const: false + reason: + type: string + const: module-disabled + required: + - available + - reason + additionalProperties: false + - type: object + properties: + available: + type: boolean + const: true + logged: + type: boolean + description: Whether an illness day-log exists for this day at all. + functionalImpact: + description: How much the day was limited, 0-3, as the illness module records it. + $ref: "#/components/schemas/MoodLinkedFigure" + symptoms: + type: array + items: + type: string + description: Symptoms linked to the day-log, by catalogue key. + episodeId: + anyOf: + - type: string + - type: "null" + description: The episode to open in the illness module, when any. + required: + - available + - logged + - functionalImpact + - symptoms + - episodeId + additionalProperties: false + required: + - day + - sleep + - activity + - vitals + - body + additionalProperties: false + description: "What the modules that own sleep, activity, vitals and body symptoms already know about one local day. + Nothing here is stored on the mood row — every figure is resolved from its owning module at read time, + cross-source de-duplicated to one canonical source per metric. A switched-off module blanks its whole block + (`available: false`)." + MoodLinkedFigure: + anyOf: + - type: object + properties: + present: + type: boolean + const: false + required: + - present + additionalProperties: false + - type: object + properties: + present: + type: boolean + const: true + value: + type: number + unit: + type: string + required: + - present + - value + - unit + additionalProperties: false + description: "A figure that either exists or honestly does not. `{ present: false }` means the data is not recorded — + never zero, never an error." GetReminderThresholdsResponse: type: object properties: @@ -34920,6 +36562,47 @@ components: - data - error additionalProperties: false + JsonImportStatsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/JsonImportStats" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + JsonImportStats: + type: object + properties: + measurements: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Measurement rows written (inserted or updated in place). + moodEntries: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Mood-entry rows written (inserted or updated in place). + skipped: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: "Rows not written: duplicates of existing rows, and rows carrying an unstable `externalId`." + required: + - measurements + - moodEntries + - skipped + additionalProperties: false + description: Per-section outcome counts of a JSON import. MeasurementReminderListEnvelope: type: object properties: @@ -34997,6 +36680,22 @@ components: 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)))$ - type: "null" + snoozedUntil: + anyOf: + - 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)))$ + - type: "null" + lastSkippedAt: + anyOf: + - 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)))$ + - type: "null" + skipCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 enabled: type: boolean createdAt: @@ -35020,14 +36719,19 @@ components: - location - nextDueAt - lastSatisfiedAt + - snoozedUntil + - lastSkippedAt + - skipCount - enabled - createdAt - updatedAt additionalProperties: false - description: A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries + description: "A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = - open-ended). + open-ended). snoozedUntil/lastSkippedAt/skipCount are resolved values: snoozed means snoozedUntil > now, a + skipped cycle means lastSkippedAt > lastSatisfiedAt; clients compare against the clock and never recompute + cadence. lastSatisfiedAt stays the only completed signal — skip and snooze never move it." MeasurementReminderCreatedEnvelope: type: object properties: @@ -35148,8 +36852,152 @@ components: - reminder additionalProperties: false description: Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; - completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle - (idempotent no-op). reminder is the canonical post-completion DTO. + completed=false when an earlier satisfy or a matching reading already fulfilled the current cycle (idempotent + no-op). reminder is the canonical post-completion DTO. + MeasurementReminderSkipEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementReminderSkip" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderSkip: + type: object + properties: + skipped: + type: boolean + reminder: + $ref: "#/components/schemas/MeasurementReminderDTO" + required: + - skipped + - reminder + additionalProperties: false + description: Result of skipping the current due cycle. skipped=true when this call recorded the skip and restarted the + interval from the skip instant; skipped=false when a concurrent skip or satisfy already advanced the row + (idempotent no-op). A skip never touches lastSatisfiedAt — it is recorded honestly as a skip, not a completion. + MeasurementReminderSnoozeEnvelope: + type: object + properties: + data: + type: object + properties: + reminder: + $ref: "#/components/schemas/MeasurementReminderDTO" + required: + - reminder + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderHistoryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementReminderHistory" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderHistory: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/MeasurementReminderEvent" + meta: + type: object + properties: + total: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + limit: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + offset: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - total + - limit + - offset + additionalProperties: false + required: + - events + - meta + additionalProperties: false + description: Paginated completion ledger for one reminder, newest first. + MeasurementReminderEvent: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - SATISFIED + - SKIPPED + occurredAt: + 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)))$ + onTime: + type: boolean + source: + type: string + enum: + - manual + - auto_measurement + - auto_lab + - telegram + - vaccination + - encounter + - skip + 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)))$ + required: + - id + - kind + - occurredAt + - onTime + - source + - createdAt + additionalProperties: false + description: "One completion-ledger row: an honest fulfilment (SATISFIED) or an honest skip (SKIPPED). onTime is derived + server-side at write time against the due instant that was current when the event landed — clients never + re-derive it. History begins at the release that introduced the ledger." ListLabResultsEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index e9b6b1877..604e98166 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3168,7 +3168,8 @@ "sleep": { "title": "Schlaf", "description": "Phasen-Aufschlüsselung und nächtliche Dauer – gezogen aus Apple Health.", - "headlineTitle": "Durchschnitt pro Nacht", + "mainSleep": "Hauptschlaf", + "totalSleep": "Schlaf gesamt", "compositionTitle": "Phasen-Verteilung", "compositionSubtitle": "Letzte {nights} Nächte", "compositionAriaLabel": "Schlafphasen-Verteilung über {nights} Nächte", @@ -8085,6 +8086,23 @@ "delete": "Löschen", "deleteConfirmTitle": "Erinnerung löschen?", "deleteConfirmDescription": "Diese Vorsorge-Erinnerung wird dauerhaft gelöscht.", + "postpone": { + "menuItem": "Verschieben…", + "title": "Fälligkeit verschieben", + "description": "Verschiebt nur die aktuelle Fälligkeit — der reguläre Rhythmus bleibt unverändert.", + "chip": "+{days} Tage", + "dateLabel": "Neues Datum", + "confirm": "Verschieben" + }, + "skip": { + "heading": "Diese Fälligkeit überspringen", + "explainer": "Wird ehrlich als übersprungen festgehalten — nicht als erledigt. Das Intervall startet ab heute neu.", + "action": "Überspringen", + "confirmTitle": "Fälligkeit überspringen?" + }, + "snoozedUntil": "Verschoben bis {date}", + "skippedOn": "Übersprungen am {date}", + "skipCount": "{count}× übersprungen", "disabledBadge": "Aus", "nextDue": { "none": "Nicht geplant", @@ -10208,6 +10226,8 @@ "measurementReminderDelete": "{name} hat eine Messerinnerung gelöscht", "measurementReminderComplete": "{name} hat eine Messerinnerung erledigt", "measurementReminderSatisfy": "{name} hat eine Messerinnerung abgehakt", + "measurementReminderSkip": "{name} hat eine Messerinnerung übersprungen", + "measurementReminderSnooze": "{name} hat eine Messerinnerung verschoben", "labResultUpdate": "{name} hat einen Laborwert geändert", "labResultDelete": "{name} hat einen Laborwert gelöscht", "labResultRestore": "{name} hat einen Laborwert wiederhergestellt", diff --git a/messages/en.json b/messages/en.json index ce24821f3..846315bde 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3168,7 +3168,8 @@ "sleep": { "title": "Sleep", "description": "Stage breakdown and nightly duration, drawn from Apple Health.", - "headlineTitle": "Average per night", + "mainSleep": "Main sleep", + "totalSleep": "Total sleep", "compositionTitle": "Stage composition", "compositionSubtitle": "Last {nights} nights", "compositionAriaLabel": "Sleep stage composition over {nights} nights", @@ -8085,6 +8086,23 @@ "delete": "Delete", "deleteConfirmTitle": "Delete reminder?", "deleteConfirmDescription": "This checkup reminder will be permanently deleted.", + "postpone": { + "menuItem": "Postpone…", + "title": "Postpone due date", + "description": "Only moves the current due date — the regular rhythm stays unchanged.", + "chip": "+{days} days", + "dateLabel": "New date", + "confirm": "Postpone" + }, + "skip": { + "heading": "Skip this due date", + "explainer": "Recorded honestly as skipped — not as done. The interval restarts from today.", + "action": "Skip", + "confirmTitle": "Skip this due date?" + }, + "snoozedUntil": "Postponed until {date}", + "skippedOn": "Skipped on {date}", + "skipCount": "skipped {count}×", "disabledBadge": "Off", "nextDue": { "none": "Not scheduled", @@ -10208,6 +10226,8 @@ "measurementReminderDelete": "{name} removed a measurement reminder", "measurementReminderComplete": "{name} completed a measurement reminder", "measurementReminderSatisfy": "{name} cleared a measurement reminder", + "measurementReminderSkip": "{name} skipped a measurement reminder", + "measurementReminderSnooze": "{name} postponed a measurement reminder", "labResultUpdate": "{name} changed a lab result", "labResultDelete": "{name} removed a lab result", "labResultRestore": "{name} restored a lab result", diff --git a/messages/es.json b/messages/es.json index c84064132..ba5f61255 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3168,7 +3168,8 @@ "sleep": { "title": "Sueño", "description": "Desglose por fases y duración nocturna, tomados de Apple Health.", - "headlineTitle": "Media por noche", + "mainSleep": "Sueño principal", + "totalSleep": "Sueño total", "compositionTitle": "Distribución de fases", "compositionSubtitle": "Últimas {nights} noches", "compositionAriaLabel": "Distribución de fases del sueño en {nights} noches", @@ -8085,6 +8086,23 @@ "delete": "Eliminar", "deleteConfirmTitle": "¿Eliminar recordatorio?", "deleteConfirmDescription": "Este recordatorio de revisión se eliminará de forma permanente.", + "postpone": { + "menuItem": "Aplazar…", + "title": "Aplazar vencimiento", + "description": "Solo mueve el vencimiento actual — el ritmo regular no cambia.", + "chip": "+{days} días", + "dateLabel": "Nueva fecha", + "confirm": "Aplazar" + }, + "skip": { + "heading": "Omitir este vencimiento", + "explainer": "Se registra honestamente como omitido — no como hecho. El intervalo se reinicia desde hoy.", + "action": "Omitir", + "confirmTitle": "¿Omitir este vencimiento?" + }, + "snoozedUntil": "Aplazado hasta {date}", + "skippedOn": "Omitido el {date}", + "skipCount": "omitido {count} veces", "disabledBadge": "Apagado", "nextDue": { "none": "Sin programar", @@ -10208,6 +10226,8 @@ "measurementReminderDelete": "{name} eliminó un recordatorio de medición", "measurementReminderComplete": "{name} completó un recordatorio de medición", "measurementReminderSatisfy": "{name} cerró un recordatorio de medición", + "measurementReminderSkip": "{name} omitió un recordatorio de medición", + "measurementReminderSnooze": "{name} aplazó un recordatorio de medición", "labResultUpdate": "{name} cambió un resultado de laboratorio", "labResultDelete": "{name} eliminó un resultado de laboratorio", "labResultRestore": "{name} restauró un resultado de laboratorio", diff --git a/messages/fr.json b/messages/fr.json index 8243162db..2035de369 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3168,7 +3168,8 @@ "sleep": { "title": "Sommeil", "description": "Répartition des phases et durée nocturne, issues d’Apple Health.", - "headlineTitle": "Moyenne par nuit", + "mainSleep": "Sommeil principal", + "totalSleep": "Sommeil total", "compositionTitle": "Répartition des phases", "compositionSubtitle": "{nights} dernières nuits", "compositionAriaLabel": "Répartition des phases de sommeil sur {nights} nuits", @@ -8085,6 +8086,23 @@ "delete": "Supprimer", "deleteConfirmTitle": "Supprimer le rappel ?", "deleteConfirmDescription": "Ce rappel d’examen sera définitivement supprimé.", + "postpone": { + "menuItem": "Reporter…", + "title": "Reporter l'échéance", + "description": "Ne déplace que l'échéance actuelle — le rythme régulier reste inchangé.", + "chip": "+{days} jours", + "dateLabel": "Nouvelle date", + "confirm": "Reporter" + }, + "skip": { + "heading": "Passer cette échéance", + "explainer": "Enregistré honnêtement comme passé — pas comme fait. L'intervalle redémarre à partir d'aujourd'hui.", + "action": "Passer", + "confirmTitle": "Passer cette échéance ?" + }, + "snoozedUntil": "Reporté au {date}", + "skippedOn": "Passé le {date}", + "skipCount": "passé {count} fois", "disabledBadge": "Désactivé", "nextDue": { "none": "Non planifié", @@ -10208,6 +10226,8 @@ "measurementReminderDelete": "{name} a supprimé un rappel de mesure", "measurementReminderComplete": "{name} a terminé un rappel de mesure", "measurementReminderSatisfy": "{name} a clôturé un rappel de mesure", + "measurementReminderSkip": "{name} a passé un rappel de mesure", + "measurementReminderSnooze": "{name} a reporté un rappel de mesure", "labResultUpdate": "{name} a modifié un résultat de laboratoire", "labResultDelete": "{name} a supprimé un résultat de laboratoire", "labResultRestore": "{name} a restauré un résultat de laboratoire", diff --git a/messages/it.json b/messages/it.json index b4c8313c9..a3ffb3c95 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3168,7 +3168,8 @@ "sleep": { "title": "Sonno", "description": "Suddivisione per fasi e durata notturna, da Apple Health.", - "headlineTitle": "Media per notte", + "mainSleep": "Sonno principale", + "totalSleep": "Sonno totale", "compositionTitle": "Distribuzione delle fasi", "compositionSubtitle": "Ultime {nights} notti", "compositionAriaLabel": "Distribuzione delle fasi del sonno su {nights} notti", @@ -8085,6 +8086,23 @@ "delete": "Elimina", "deleteConfirmTitle": "Eliminare il promemoria?", "deleteConfirmDescription": "Questo promemoria di controllo verrà eliminato definitivamente.", + "postpone": { + "menuItem": "Rimanda…", + "title": "Rimanda la scadenza", + "description": "Sposta solo la scadenza attuale — il ritmo regolare resta invariato.", + "chip": "+{days} giorni", + "dateLabel": "Nuova data", + "confirm": "Rimanda" + }, + "skip": { + "heading": "Salta questa scadenza", + "explainer": "Registrato onestamente come saltato — non come fatto. L'intervallo riparte da oggi.", + "action": "Salta", + "confirmTitle": "Saltare questa scadenza?" + }, + "snoozedUntil": "Rimandato al {date}", + "skippedOn": "Saltato il {date}", + "skipCount": "saltato {count} volte", "disabledBadge": "Disattivato", "nextDue": { "none": "Non pianificato", @@ -10208,6 +10226,8 @@ "measurementReminderDelete": "{name} ha eliminato un promemoria di misurazione", "measurementReminderComplete": "{name} ha completato un promemoria di misurazione", "measurementReminderSatisfy": "{name} ha chiuso un promemoria di misurazione", + "measurementReminderSkip": "{name} ha saltato un promemoria di misurazione", + "measurementReminderSnooze": "{name} ha rimandato un promemoria di misurazione", "labResultUpdate": "{name} ha modificato un risultato di laboratorio", "labResultDelete": "{name} ha eliminato un risultato di laboratorio", "labResultRestore": "{name} ha ripristinato un risultato di laboratorio", diff --git a/messages/pl.json b/messages/pl.json index c250f3c8d..5d494ecd6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3168,7 +3168,8 @@ "sleep": { "title": "Sen", "description": "Podział na fazy i nocna długość snu, dane z Apple Health.", - "headlineTitle": "Średnia na noc", + "mainSleep": "Sen główny", + "totalSleep": "Sen łączny", "compositionTitle": "Rozkład faz", "compositionSubtitle": "Ostatnie {nights} nocy", "compositionAriaLabel": "Rozkład faz snu w {nights} nocach", @@ -8085,6 +8086,23 @@ "delete": "Usuń", "deleteConfirmTitle": "Usunąć przypomnienie?", "deleteConfirmDescription": "To przypomnienie o badaniu zostanie trwale usunięte.", + "postpone": { + "menuItem": "Przełóż…", + "title": "Przełóż termin", + "description": "Przesuwa tylko bieżący termin — regularny rytm pozostaje bez zmian.", + "chip": "+{days} dni", + "dateLabel": "Nowa data", + "confirm": "Przełóż" + }, + "skip": { + "heading": "Pomiń ten termin", + "explainer": "Zapisane uczciwie jako pominięte — nie jako wykonane. Odstęp zaczyna się od nowa od dzisiaj.", + "action": "Pomiń", + "confirmTitle": "Pominąć ten termin?" + }, + "snoozedUntil": "Przełożono do {date}", + "skippedOn": "Pominięto {date}", + "skipCount": "pominięto {count} razy", "disabledBadge": "Wył.", "nextDue": { "none": "Niezaplanowane", @@ -10208,6 +10226,8 @@ "measurementReminderDelete": "{name} usunął przypomnienie o pomiarze", "measurementReminderComplete": "{name} zrealizował przypomnienie o pomiarze", "measurementReminderSatisfy": "{name} zamknął przypomnienie o pomiarze", + "measurementReminderSkip": "{name} pominął(-ęła) przypomnienie o pomiarze", + "measurementReminderSnooze": "{name} przełożył(-yła) przypomnienie o pomiarze", "labResultUpdate": "{name} zmienił wynik badania", "labResultDelete": "{name} usunął wynik badania", "labResultRestore": "{name} przywrócił wynik badania", diff --git a/src/__tests__/encounter-reminder-exclusion.test.ts b/src/__tests__/encounter-reminder-exclusion.test.ts index a136120ec..99817264f 100644 --- a/src/__tests__/encounter-reminder-exclusion.test.ts +++ b/src/__tests__/encounter-reminder-exclusion.test.ts @@ -97,6 +97,9 @@ function reminderRow( location: null, nextDueAt: at, lastSatisfiedAt: null, + snoozedUntil: null, + lastSkippedAt: null, + skipCount: 0, enabled: true, vaccinationAntigen: null, createdAt: at, @@ -152,6 +155,27 @@ const BY_ID_SITES: ReadonlyArray<{ sites: 1, surface: "the manual satisfy action", }, + { + // v1.37.20 (#223) — skipping an appointment would move a date the visit + // record still believes it owns; snoozing one likewise. Both refuse in + // the lookup, before any write. This entry went in RED (the routes did + // not exist yet) and turned green when they landed with the exclusion. + file: "src/app/api/measurement-reminders/[id]/skip/route.ts", + sites: 1, + surface: "the honest skip action", + }, + { + file: "src/app/api/measurement-reminders/[id]/snooze/route.ts", + sites: 1, + surface: "the snooze action", + }, + { + // v1.37.20 (iOS #68) — the ledger read answers 404 for an appointment + // row like every by-id sibling. + file: "src/app/api/measurement-reminders/[id]/history/route.ts", + sites: 1, + surface: "the completion-ledger history read", + }, { file: "src/lib/telegram-webhook-handlers.ts", sites: 2, @@ -161,9 +185,9 @@ const BY_ID_SITES: ReadonlyArray<{ describe("an appointment reminder is not addressable by id", () => { it("names a by-id set that is not empty (no vacuous pass)", () => { - expect(BY_ID_SITES.length).toBe(4); + expect(BY_ID_SITES.length).toBe(7); expect(BY_ID_SITES.reduce((total, entry) => total + entry.sites, 0)).toBe( - 7, + 10, ); }); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index b9d3f9897..d37eee72c 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1140,6 +1140,18 @@ const DELEGABLE_ROUTES: Record = { domain: "measurements", why: "The same primitive from the other caller. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", }, + "app/api/measurement-reminders/[id]/skip/route.ts": { + domain: "measurements", + why: "Honestly skipping the current due cycle (v1.37.20, #223) — the counterpart of complete/satisfy, restarting the interval without claiming fulfilment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/measurement-reminders/[id]/snooze/route.ts": { + domain: "measurements", + why: "Pushing one due date back to a calendar day (v1.37.20, #223), the cadence untouched. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/measurement-reminders/[id]/history/route.ts": { + domain: "measurements", + why: "The completion ledger behind one reminder (v1.37.20, iOS #68), read-only and newest-first. The lookup binds the resolved user id and refuses an appointment row like every by-id sibling.", + }, "app/api/labs/restore/route.ts": { domain: "labs", why: "Restoring tombstoned results, scoped so a foreign or live id is a no-op. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", @@ -1477,6 +1489,16 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "The same primitive from the other caller.", }, + "app/api/measurement-reminders/[id]/skip/route.ts": { + domain: "measurements", + conditions: [], + why: "Honestly letting one due cycle go (v1.37.20, #223). Additive management of a schedule the level admits — the interval restarts, lastSatisfiedAt is never touched.", + }, + "app/api/measurement-reminders/[id]/snooze/route.ts": { + domain: "measurements", + conditions: [], + why: "Pushing one due date back (v1.37.20, #223). Additive, self-expiring, the anchor untouched.", + }, "app/api/labs/[id]/route.ts": { domain: "labs", conditions: ["C4"], @@ -1911,8 +1933,12 @@ const ACTOR_ROUTES: Record = { * v1.38.0 -- the immunization log adds six: two reads and one restore on the * record list, one create on the write literal, and the edit/delete pair plus * the restore on the manage literal. 215 -> 221. + * + * v1.37.20 (#223 / iOS #68) -- Vorsorge skip/snooze adds five: skip, snooze + * and the completion-ledger history read on the record list, plus skip and + * snooze on the manage literal. 224 -> 229. */ -const FROZEN_ENTRY_COUNT = 224; +const FROZEN_ENTRY_COUNT = 229; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2673,7 +2699,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(75); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts b/src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts new file mode 100644 index 000000000..8483ec1f6 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts @@ -0,0 +1,314 @@ +/** + * v1.37.20 (#223 / iOS #68) — the skip, snooze and history routes. + * + * What this suite pins, per the feature's design: + * + * - 404 for a foreign or ENCOUNTER-origin reminder on all three routes + * (the refusal lives in the lookup, before any write). + * - A SCREENING reminder is skippable and snoozable — deliberately NO 409. + * The satisfy-side 409 guards against CLAIMED fulfilment without an + * assessment; skip and snooze claim the opposite, so refusing them here + * would only push people back to deleting the reminder. This test is the + * pinned boundary between the two rules. + * - Skip: forward-only no-op behind an existing cursor; the write never + * carries `lastSatisfiedAt` (THE invariant); the SKIPPED ledger row. + * - Snooze: the named calendar day resolves to the notifyHour in the + * profile timezone; both cursors move to the SAME instant; a past date + * and a beyond-cap date answer 422; the cadence fields stay untouched. + * - History: paginated newest-first with the meta triple. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + requireRecordAuth: vi.fn(async () => ({ + user: { id: "u1", locale: "en" }, + actor: { id: "u1", locale: "en" }, + grantId: null, + })), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +const findFirstMock = vi.fn(); +const updateMock = vi.fn(); +const updateManyMock = vi.fn(); +const findUniqueOrThrowMock = vi.fn(); +const findUserMock = vi.fn(); +const eventCreateMock = vi.fn(); +const eventFindManyMock = vi.fn(); +const eventCountMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurementReminder: { + findFirst: (...a: unknown[]) => findFirstMock(...a), + update: (...a: unknown[]) => updateMock(...a), + updateMany: (...a: unknown[]) => updateManyMock(...a), + findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a), + }, + measurementReminderEvent: { + create: (...a: unknown[]) => eventCreateMock(...a), + findMany: (...a: unknown[]) => eventFindManyMock(...a), + count: (...a: unknown[]) => eventCountMock(...a), + }, + user: { findUnique: (...a: unknown[]) => findUserMock(...a) }, + }, +})); + +import { POST as SKIP } from "../skip/route"; +import { POST as SNOOZE } from "../snooze/route"; +import { GET as HISTORY } from "../history/route"; + +const NOW = new Date("2026-06-15T08:00:00.000Z"); + +const BASE_ROW = { + id: "r1", + userId: "u1", + label: "Blutdruck messen", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 30, + rrule: null as string | null, + anchorDate: null as Date | null, + endsOn: null, + origin: "VORSORGE", + notifyHour: 9, + location: null, + nextDueAt: new Date("2026-06-15T07:00:00Z"), + lastSatisfiedAt: null as Date | null, + snoozedUntil: null as Date | null, + lastSkippedAt: null as Date | null, + skipCount: 0, + enabled: true, + vaccinationAntigen: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + deletedAt: null, +}; + +function params(id = "r1") { + return { params: Promise.resolve({ id }) }; +} + +function skipRequest(): NextRequest { + return new NextRequest("http://localhost/api/measurement-reminders/r1/skip", { + method: "POST", + }); +} + +function snoozeRequest(body: unknown): NextRequest { + return new NextRequest( + "http://localhost/api/measurement-reminders/r1/snooze", + { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); +} + +function historyRequest(query = ""): NextRequest { + return new NextRequest( + `http://localhost/api/measurement-reminders/r1/history${query}`, + { method: "GET" }, + ); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + findFirstMock.mockResolvedValue({ ...BASE_ROW }); + findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" }); + updateManyMock.mockResolvedValue({ count: 1 }); + updateMock.mockResolvedValue({ ...BASE_ROW }); + findUniqueOrThrowMock.mockResolvedValue({ ...BASE_ROW }); + eventCreateMock.mockResolvedValue({ id: "evt-1" }); + eventFindManyMock.mockResolvedValue([]); + eventCountMock.mockResolvedValue(0); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("POST /api/measurement-reminders/[id]/skip", () => { + it("404s a foreign reminder without touching it", async () => { + findFirstMock.mockResolvedValue({ ...BASE_ROW, userId: "someone-else" }); + const res = await SKIP(skipRequest(), params()); + expect(res.status).toBe(404); + expect(updateManyMock).not.toHaveBeenCalled(); + }); + + it("404s an ENCOUNTER reminder via the lookup filter", async () => { + // The route's findFirst carries `origin: { not: "ENCOUNTER" }`, so the + // DB answers null for an appointment row. Mirror that here. + findFirstMock.mockResolvedValue(null); + const res = await SKIP(skipRequest(), params()); + expect(res.status).toBe(404); + const where = findFirstMock.mock.calls[0][0].where; + expect(where.origin).toEqual({ not: "ENCOUNTER" }); + expect(updateManyMock).not.toHaveBeenCalled(); + }); + + it("skips a SCREENING reminder — the satisfy-side 409 does not apply", async () => { + findFirstMock.mockResolvedValue({ + ...BASE_ROW, + measurementType: "PHQ9_SCORE", + }); + const res = await SKIP(skipRequest(), params()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.skipped).toBe(true); + }); + + it("stamps the skip without ever touching lastSatisfiedAt", async () => { + const res = await SKIP(skipRequest(), params()); + expect(res.status).toBe(200); + const { data } = updateManyMock.mock.calls[0][0]; + expect(data.lastSkippedAt).toEqual(NOW); + expect(data.skipCount).toEqual({ increment: 1 }); + expect(data.snoozedUntil).toBeNull(); + expect("lastSatisfiedAt" in data).toBe(false); + // Rolling 30d cadence restarts from the skip instant. + expect(data.nextDueAt.getTime()).toBeGreaterThan( + NOW.getTime() + 29 * 24 * 60 * 60 * 1000, + ); + }); + + it("appends the SKIPPED ledger row", async () => { + await SKIP(skipRequest(), params()); + expect(eventCreateMock).toHaveBeenCalledTimes(1); + const { data } = eventCreateMock.mock.calls[0][0]; + expect(data).toMatchObject({ + userId: "u1", + reminderId: "r1", + kind: "SKIPPED", + source: "skip", + }); + }); + + it("answers skipped=false on a forward-only no-op (double skip)", async () => { + findFirstMock.mockResolvedValue({ + ...BASE_ROW, + // A cursor already ahead of the clock — the primitive refuses in + // memory, before any write. + lastSkippedAt: new Date("2026-06-16T09:00:00Z"), + }); + const res = await SKIP(skipRequest(), params()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.skipped).toBe(false); + expect(updateManyMock).not.toHaveBeenCalled(); + expect(eventCreateMock).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/measurement-reminders/[id]/snooze", () => { + it("404s an ENCOUNTER reminder via the lookup filter", async () => { + findFirstMock.mockResolvedValue(null); + const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params()); + expect(res.status).toBe(404); + const where = findFirstMock.mock.calls[0][0].where; + expect(where.origin).toEqual({ not: "ENCOUNTER" }); + }); + + it("snoozes a SCREENING reminder without a 409", async () => { + findFirstMock.mockResolvedValue({ + ...BASE_ROW, + measurementType: "GAD7_SCORE", + }); + const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params()); + expect(res.status).toBe(200); + }); + + it("resolves the named day to the notifyHour in the profile timezone and moves both cursors together", async () => { + const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params()); + expect(res.status).toBe(200); + const { data } = updateMock.mock.calls[0][0]; + // 2026-09-01 09:00 Europe/Berlin (CEST, UTC+2) = 07:00Z. This is the + // DST-aware wall-clock resolution, not a request-time offset. + const expected = new Date("2026-09-01T07:00:00.000Z"); + expect(data.snoozedUntil).toEqual(expected); + expect(data.nextDueAt).toEqual(expected); + // The cadence and its cursors stay untouched. + expect("lastSatisfiedAt" in data).toBe(false); + expect("lastSkippedAt" in data).toBe(false); + expect("intervalDays" in data).toBe(false); + expect("anchorDate" in data).toBe(false); + }); + + it("422s a past or same-day date", async () => { + const res = await SNOOZE(snoozeRequest({ until: "2026-06-15" }), params()); + expect(res.status).toBe(422); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("422s a date beyond the five-year cap", async () => { + const res = await SNOOZE(snoozeRequest({ until: "2031-07-01" }), params()); + expect(res.status).toBe(422); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("422s a malformed body through the multi-issue envelope", async () => { + const res = await SNOOZE(snoozeRequest({ until: "not-a-date" }), params()); + expect(res.status).toBe(422); + }); +}); + +describe("GET /api/measurement-reminders/[id]/history", () => { + it("404s an ENCOUNTER reminder via the lookup filter", async () => { + findFirstMock.mockResolvedValue(null); + const res = await HISTORY(historyRequest(), params()); + expect(res.status).toBe(404); + const where = findFirstMock.mock.calls[0][0].where; + expect(where.origin).toEqual({ not: "ENCOUNTER" }); + }); + + it("pages the ledger newest first with the meta triple", async () => { + eventFindManyMock.mockResolvedValue([ + { + id: "e2", + kind: "SKIPPED", + occurredAt: new Date("2026-06-10T09:00:00Z"), + onTime: false, + source: "skip", + createdAt: new Date("2026-06-10T09:00:00Z"), + }, + { + id: "e1", + kind: "SATISFIED", + occurredAt: new Date("2026-05-10T09:00:00Z"), + onTime: true, + source: "manual", + createdAt: new Date("2026-05-10T09:00:00Z"), + }, + ]); + eventCountMock.mockResolvedValue(2); + + const res = await HISTORY(historyRequest("?limit=10&offset=0"), params()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.events).toHaveLength(2); + expect(body.data.events[0]).toMatchObject({ + id: "e2", + kind: "SKIPPED", + onTime: false, + source: "skip", + }); + expect(body.data.meta).toEqual({ total: 2, limit: 10, offset: 0 }); + const query = eventFindManyMock.mock.calls[0][0]; + expect(query.orderBy).toEqual({ occurredAt: "desc" }); + expect(query.where).toEqual({ reminderId: "r1", userId: "u1" }); + }); + + it("422s an out-of-range limit", async () => { + const res = await HISTORY(historyRequest("?limit=1000"), params()); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts index 23fcc71b6..a993579cd 100644 --- a/src/app/api/measurement-reminders/[id]/complete/route.ts +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -77,7 +77,13 @@ export const POST = apiHandler( // Forward-only: a completion strictly after any prior satisfy advances // the row; a completion at/behind the last satisfy is a no-op. Either // way no notification is emitted — the primitive only stamps + reschedules. - const result = await satisfyReminder(prisma, existing, timezone, now); + const result = await satisfyReminder( + prisma, + existing, + timezone, + now, + "manual", + ); const updated = await prisma.measurementReminder.findUniqueOrThrow({ where: { id }, diff --git a/src/app/api/measurement-reminders/[id]/history/route.ts b/src/app/api/measurement-reminders/[id]/history/route.ts new file mode 100644 index 000000000..19872c4fc --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/history/route.ts @@ -0,0 +1,72 @@ +/** + * v1.37.20 (iOS #68) — the completion ledger for one Vorsorge reminder, + * paginated, newest first. + * + * Every satisfy (from any path — manual, auto-resolve, Telegram, + * vaccination booster, visit close) and every skip appends one row via the + * shared primitives; this route only reads. History begins at the release + * that introduced the ledger: the single-cursor engine holds nothing to + * backfill from, which the iOS #68 thread records as agreed. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { listReminderHistorySchema } from "@/lib/validations/measurement-reminders"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + // READ — the same level as the reminder list this history hangs off. + const { user } = await requireRecordAuth("read", "measurements"); + const { id } = await params; + + const searchParams = Object.fromEntries(request.nextUrl.searchParams); + const parsed = listReminderHistorySchema.safeParse(searchParams); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + const { limit, offset } = parsed.data; + + // An appointment reminder is not addressable by this family — 404, the + // same refusal every by-id sibling makes. + const existing = await prisma.measurementReminder.findFirst({ + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, + select: { id: true, userId: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Measurement reminder not found", 404); + } + + const where = { reminderId: id, userId: user.id }; + const [rows, total] = await Promise.all([ + prisma.measurementReminderEvent.findMany({ + where, + orderBy: { occurredAt: "desc" }, + take: limit, + skip: offset, + }), + prisma.measurementReminderEvent.count({ where }), + ]); + + annotate({ + action: { name: "measurement-reminders.history" }, + meta: { reminderId: id, total }, + }); + + return apiSuccess({ + events: rows.map((row) => ({ + id: row.id, + kind: row.kind, + occurredAt: row.occurredAt.toISOString(), + onTime: row.onTime, + source: row.source, + createdAt: row.createdAt.toISOString(), + })), + meta: { total, limit, offset }, + }); + }, +); diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index a8785b2ae..f51d742ca 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -189,6 +189,9 @@ export const PATCH = apiHandler( ? existing.lastSatisfiedAt : now; updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after); + // v1.37.20 (#223) — a cadence edit recomputes `nextDueAt`, so it clears + // the snooze cursor: the cycle the snooze pushed back no longer exists. + updateData.snoozedUntil = null; const updated = await prisma.measurementReminder.update({ where: { id }, diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts index 7af4ccbb7..d5f35d63d 100644 --- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -64,7 +64,7 @@ export const POST = apiHandler( // auto-resolve and the eventful worker use. A manual "Erledigt" is // always strictly after any prior satisfy, so the forward-only guard // advances it. - await satisfyReminder(prisma, existing, timezone, now); + await satisfyReminder(prisma, existing, timezone, now, "manual"); const updated = await prisma.measurementReminder.findUniqueOrThrow({ where: { id }, diff --git a/src/app/api/measurement-reminders/[id]/skip/route.ts b/src/app/api/measurement-reminders/[id]/skip/route.ts new file mode 100644 index 000000000..7405a958c --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/skip/route.ts @@ -0,0 +1,77 @@ +/** + * v1.37.20 (#223) — honestly skip a Vorsorge reminder's current due cycle. + * + * No body: the skip instant is always the server clock. The interval + * restarts from the skip (rolling: skip + N days at the notify hour; rrule: + * next occurrence strictly after the skip), `lastSkippedAt` is stamped, + * `skipCount` incremented, any snooze cleared, and one SKIPPED row lands in + * the completion ledger. `lastSatisfiedAt` is NEVER touched — a skip is the + * honest alternative to deleting or ignoring a reminder, not a completion. + * + * Screening reminders (PHQ-9 / GAD-7 / WHO-5 / SCI) ARE skippable — the + * satisfy-side 409 guards against CLAIMED fulfilment without an assessment; + * a skip claims the opposite, so refusing it here would only push people + * back to deleting the reminder. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { skipReminder } from "@/lib/measurement-reminders/satisfy"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + // MANAGE — the same level as satisfy/complete: deciding to let a cycle + // go re-anchors a schedule this level already admits editing. + const { user } = await requireRecordAuth("manage", "measurements"); + const { id } = await params; + + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Skipping an appointment would move a date the visit record still + // believes it owns. + const existing = await prisma.measurementReminder.findFirst({ + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Measurement reminder not found", 404); + } + + const userRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { timezone: true }, + }); + const timezone = userRow?.timezone || DEFAULT_TIMEZONE; + + const result = await skipReminder(prisma, existing, timezone, new Date()); + + const updated = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id }, + }); + + await auditLog("measurementReminder.skip", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: id, skipped: result.skipped }, + }); + + annotate({ + action: { name: "measurement-reminders.skip" }, + meta: { reminderId: id, skipped: result.skipped }, + }); + + return apiSuccess({ + skipped: result.skipped, + reminder: toMeasurementReminderDto(updated), + }); + }, +); diff --git a/src/app/api/measurement-reminders/[id]/snooze/route.ts b/src/app/api/measurement-reminders/[id]/snooze/route.ts new file mode 100644 index 000000000..bc5f06826 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/snooze/route.ts @@ -0,0 +1,134 @@ +/** + * v1.37.20 (#223) — push a Vorsorge reminder's current due date back to a + * named calendar day. + * + * Body `{ until: "YYYY-MM-DD" }`, day precision on purpose: the server + * resolves the day to the reminder's notifyHour in the profile timezone, so + * no client-supplied instant ever anchors the cadence. Both `snoozedUntil` + * and `nextDueAt` are set to the SAME resolved instant — every due-state + * consumer (digest, dashboard card, cron, AI block) moves with it without + * knowing snooze exists, and the cursor self-expires when the clock passes + * it. The regular interval is untouched: `lastSatisfiedAt`, + * `lastSkippedAt` and the anchor stay where they are, so after the snoozed + * cycle the cadence resumes exactly as configured. Repeated snoozes: the + * last one wins. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, + returnAllZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { snoozeMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; + +/** Furthest a snooze may reach: five years from now. */ +const SNOOZE_CAP_MS = 5 * 365.25 * 24 * 60 * 60 * 1000; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + // MANAGE — the same level as satisfy/complete: pushing a due date back + // re-anchors a schedule this level already admits editing. + const { user } = await requireRecordAuth("manage", "measurements"); + const { id } = await params; + + const body = await safeJson(request); + if (body.error) return body.error; + const parsed = snoozeMeasurementReminderSchema.safeParse(body.data); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + // An appointment reminder is not addressable here — see the sibling + // routes; snoozing one would move a date the visit record still owns. + const existing = await prisma.measurementReminder.findFirst({ + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Measurement reminder not found", 404); + } + + const userRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { timezone: true }, + }); + const timezone = userRow?.timezone || DEFAULT_TIMEZONE; + + // Resolve the named local day to its notifyHour instant in the profile + // timezone (DST-settled at the target day, not at the request instant). + const [year, month, day] = parsed.data.until.split("-").map(Number); + const until = zonedWallClockToUtc( + { year, month, day, hour: existing.notifyHour, minute: 0, second: 0 }, + timezone, + ); + + // Range gate: at least tomorrow (a snooze to today or the past is a + // no-op wearing a confirmation), at most five years out. + const now = new Date(); + const startOfTomorrow = zonedWallClockToUtc( + (() => { + const probe = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const parts = new Intl.DateTimeFormat("sv-SE", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .format(probe) + .split("-") + .map(Number); + return { + year: parts[0], + month: parts[1], + day: parts[2], + hour: 0, + minute: 0, + second: 0, + }; + })(), + timezone, + ); + if (until.getTime() < startOfTomorrow.getTime()) { + return apiError("Snooze date must be at least tomorrow", 422); + } + if (until.getTime() > now.getTime() + SNOOZE_CAP_MS) { + return apiError("Snooze date must be within five years", 422); + } + + // One write, both cursors, same instant. lastSatisfiedAt/lastSkippedAt + // and the cadence fields are deliberately untouched. + await prisma.measurementReminder.update({ + where: { id }, + data: { snoozedUntil: until, nextDueAt: until }, + }); + + const updated = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id }, + }); + + await auditLog("measurementReminder.snooze", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: id, until: until.toISOString() }, + }); + + annotate({ + action: { name: "measurement-reminders.snooze" }, + meta: { reminderId: id, until: until.toISOString() }, + }); + + return apiSuccess({ reminder: toMeasurementReminderDto(updated) }); + }, +); diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index 9e800346c..957d780ea 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -307,6 +307,9 @@ const REMINDER: MeasurementReminder = { location: null, nextDueAt: "2026-08-10T09:00:00.000Z", lastSatisfiedAt: null, + snoozedUntil: null, + lastSkippedAt: null, + skipCount: 0, enabled: true, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 74f771938..c034989be 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -231,7 +231,8 @@ export function VorsorgeSection({ isError, refetch, } = useMeasurementReminders(enabled); - const { create, update, remove, satisfy } = useMeasurementReminderMutations(); + const { create, update, remove, satisfy, skip, snooze } = + useMeasurementReminderMutations(); // v1.18.6 (MOD-03) — page view (cards/list) + manual order persist // client-side; the settings page writes them, the page reads them. const { prefs } = useModuleListPrefs("vorsorge"); @@ -743,8 +744,16 @@ export function VorsorgeSection({ onFileVisit={() => openVisitFor(reminder)} onRemove={() => remove.mutate(reminder.id)} onEdit={() => openEdit(reminder)} + onSkip={() => skip.mutate(reminder.id)} + onSnooze={(until) => + snooze.mutate({ id: reminder.id, until }) + } busy={ - satisfy.isPending || remove.isPending || update.isPending + satisfy.isPending || + remove.isPending || + update.isPending || + skip.isPending || + snooze.isPending } /> @@ -776,6 +785,18 @@ export function VorsorgeSection({ * means due/overdue now, lower means time still remains in the window. * Returns null when there is no interval to measure against (RRULE / unset). */ +/** v1.37.20 (#223) — the postpone sheet's quick-chip offsets, in days. */ +const SNOOZE_CHIP_DAYS = [7, 30, 90] as const; + +/** + * The local calendar day (ISO `yyyy-MM-dd`) an instant falls on in `tz`. + * Snooze is day-precision by contract: the server resolves the day to the + * reminder's notifyHour in the profile timezone. + */ +function isoDayInTz(atMs: number, tz: string): string { + return new Date(atMs).toLocaleDateString("sv-SE", { timeZone: tz }); +} + function intervalProgress( reminder: MeasurementReminder, now: number, @@ -797,6 +818,8 @@ function VorsorgeCard({ onFileVisit, onRemove, onEdit, + onSkip, + onSnooze, busy, }: { reminder: MeasurementReminder; @@ -807,6 +830,10 @@ function VorsorgeCard({ onFileVisit: () => void; onRemove: () => void; onEdit: () => void; + /** v1.37.20 (#223) — honest skip; the server restarts the interval. */ + onSkip: () => void; + /** v1.37.20 (#223) — snooze to a calendar day (ISO YYYY-MM-DD). */ + onSnooze: (until: string) => void; busy: boolean; }) { const { t } = useTranslations(); @@ -819,6 +846,11 @@ function VorsorgeCard({ // zone from their profile. const displayTz = useDisplayTimezone(); const [confirmDelete, setConfirmDelete] = useState(false); + // v1.37.20 (#223) — the postpone sheet (snooze quick-chips + date field, + // with the honest-skip zone below) and the skip confirm inside it. + const [postponeOpen, setPostponeOpen] = useState(false); + const [snoozeDate, setSnoozeDate] = useState(""); + const [confirmSkip, setConfirmSkip] = useState(false); const due = relativeDueKey(reminder.nextDueAt, now, displayTz); const cadence = reminder.intervalDays != null @@ -897,6 +929,16 @@ function VorsorgeCard({ {t("measurementReminders.edit")} + {/* v1.37.20 (#223) — postpone lives in the kebab, never in the + primary-action bar (v1.27.5 rule: the action button keeps one + constant look in every state). */} + setPostponeOpen(true)} + > + + {t("measurementReminders.postpone.menuItem")} + {/* v1.18.7 (Wave E) — jump to the measurements list pre-filtered to this reminder's type. Only for measurement-linked reminders; a free-text reminder has no readings to show. */} @@ -1007,6 +1049,162 @@ function VorsorgeCard({ ); + // v1.37.20 (#223) — resolved skip/snooze state, clock comparisons only + // (the DTO comment states the contract: the server resolves, the client + // compares). Meta lines are muted text — never a card colour change. + const isSnoozed = + reminder.snoozedUntil != null && + new Date(reminder.snoozedUntil).getTime() > now; + const isSkippedCycle = + reminder.lastSkippedAt != null && + (reminder.lastSatisfiedAt == null || + new Date(reminder.lastSkippedAt).getTime() > + new Date(reminder.lastSatisfiedAt).getTime()); + + const closePostpone = () => { + setPostponeOpen(false); + setSnoozeDate(""); + setConfirmSkip(false); + }; + const minSnoozeDate = isoDayInTz(now + DAY_MS, displayTz); + + const postponeSheet = ( + { + if (!open) closePostpone(); + }} + title={t("measurementReminders.postpone.title")} + description={t("measurementReminders.postpone.description")} + footer={ + <> + + + + } + > +
+ {/* Quick chips write the date field rather than firing directly, so + the person always sees (and can adjust) what will be sent. */} +
+ {SNOOZE_CHIP_DAYS.map((days) => { + const chipDate = isoDayInTz(now + days * DAY_MS, displayTz); + return ( + + ); + })} +
+
+ + +
+ {/* The honest-skip zone sits set apart below the snooze controls: + a different decision (let this cycle go, restart the rhythm) + with its own confirm step. */} +
+

+ {t("measurementReminders.skip.heading")} +

+

+ {t("measurementReminders.skip.explainer")} +

+ +
+
+
+ ); + + const skipConfirmDialog = ( + + + + + {t("measurementReminders.skip.confirmTitle")} + + + {t("measurementReminders.skip.explainer")} + + + + {t("common.cancel")} + { + onSkip(); + closePostpone(); + }} + > + {t("measurementReminders.skip.action")} + + + + + ); + + // Muted meta lines shared by both branches: "pushed back until {date}" / + // "skipped on {date}" (+ lifetime count once it says something). "Last + // done" stays lastSatisfiedAt — a skip never reads as done. + const skipSnoozeMeta = + isSnoozed || isSkippedCycle ? ( +
+ {isSnoozed && reminder.snoozedUntil && ( +

+ {t("measurementReminders.snoozedUntil", { + date: fmt.date(new Date(reminder.snoozedUntil)), + })} +

+ )} + {isSkippedCycle && reminder.lastSkippedAt && ( +

+ {t("measurementReminders.skippedOn", { + date: fmt.date(new Date(reminder.lastSkippedAt)), + })} + {reminder.skipCount > 1 + ? ` · ${t("measurementReminders.skipCount", { + count: reminder.skipCount, + })}` + : null} +

+ )} +
+ ) : null; + // v1.18.6 (MOD-03) — compact list row variant. if (view === "list") { return ( @@ -1030,6 +1228,7 @@ function VorsorgeCard({ {categoryLabel} {cadenceText ? {cadenceText} : null}
+ {skipSnoozeMeta}
{/* The same gate the cards branch applies through @@ -1078,6 +1277,8 @@ function VorsorgeCard({ {deleteDialog} + {postponeSheet} + {skipConfirmDialog} ); } @@ -1156,6 +1357,7 @@ function VorsorgeCard({ /> {nextLastSlot} + {skipSnoozeMeta} {/* v1.18.6 (MOD-06) — light gamification: progress through the current interval toward next-due, reusing the medication card's @@ -1208,6 +1410,8 @@ function VorsorgeCard({ {deleteDialog} + {postponeSheet} + {skipConfirmDialog} ); } diff --git a/src/hooks/use-measurement-reminders.ts b/src/hooks/use-measurement-reminders.ts index de2c35f8c..b42a564c8 100644 --- a/src/hooks/use-measurement-reminders.ts +++ b/src/hooks/use-measurement-reminders.ts @@ -63,6 +63,15 @@ export interface MeasurementReminder { location: string | null; nextDueAt: string | null; lastSatisfiedAt: string | null; + /** + * v1.37.20 (#223) — resolved skip/snooze state. The client only compares + * against the clock: snoozed = `snoozedUntil > now`; a skipped cycle = + * `lastSkippedAt > lastSatisfiedAt` (or set with no satisfy yet). Cadence + * is never recomputed here; `lastSatisfiedAt` stays the only "done". + */ + snoozedUntil: string | null; + lastSkippedAt: string | null; + skipCount: number; enabled: boolean; createdAt: string; updatedAt: string; @@ -129,5 +138,27 @@ export function useMeasurementReminderMutations() { onSuccess: invalidate, }); - return { create, update, remove, satisfy }; + // v1.37.20 (#223) — honest skip: the interval restarts from the skip + // instant server-side; lastSatisfiedAt is never touched. + const skip = useMutation({ + mutationKey: queryKeys.measurementReminderSkip(), + mutationFn: (id: string) => + apiPost<{ skipped: boolean; reminder: MeasurementReminder }>( + `${BASE}/${id}/skip`, + ), + onSuccess: invalidate, + }); + + // v1.37.20 (#223) — snooze to a calendar day; the server resolves it to + // the notifyHour in the profile timezone and moves nextDueAt with it. + const snooze = useMutation({ + mutationKey: queryKeys.measurementReminderSnooze(), + mutationFn: ({ id, until }: { id: string; until: string }) => + apiPost<{ reminder: MeasurementReminder }>(`${BASE}/${id}/snooze`, { + until, + }), + onSuccess: invalidate, + }); + + return { create, update, remove, satisfy, skip, snooze }; } diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 7951e187e..705596437 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -341,7 +341,9 @@ export async function resolveClosableReminder( rrule: true, anchorDate: true, notifyHour: true, + nextDueAt: true, lastSatisfiedAt: true, + lastSkippedAt: true, createdAt: true, }, }); @@ -349,11 +351,14 @@ export async function resolveClosableReminder( if (row.origin === "ENCOUNTER") return undefined; return { id: row.id, + userId: row.userId, intervalDays: row.intervalDays, rrule: row.rrule, anchorDate: row.anchorDate, notifyHour: row.notifyHour, + nextDueAt: row.nextDueAt, lastSatisfiedAt: row.lastSatisfiedAt, + lastSkippedAt: row.lastSkippedAt, createdAt: row.createdAt, }; } @@ -399,6 +404,7 @@ export async function closeCheckupForVisit( reminder, timezone, satisfiedAt, + "encounter", ); } diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index c53f6c365..1151a35ab 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -271,6 +271,9 @@ export async function runMeasurementReminderTick( reminder, timezone, satisfiedAt, + // Which arm of `findSatisfyingEvent` matched: a typed reminder + // resolves from a Measurement, a free-text one from a LabResult. + reminder.measurementType !== null ? "auto_measurement" : "auto_lab", ); if (result.satisfied) { summary.autoResolved += 1; @@ -491,6 +494,7 @@ export async function runReminderSatisfyForUser( reminder, timezone, satisfiedAt, + reminder.measurementType !== null ? "auto_measurement" : "auto_lab", ); if (result.satisfied) { summary.satisfied += 1; diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 25013a93d..af886b612 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -684,6 +684,9 @@ describe("get_preventive_care", () => { location: null, nextDueAt: "2000-01-01T00:00:00.000Z", lastSatisfiedAt: null, + snoozedUntil: null, + lastSkippedAt: null, + skipCount: 0, enabled: true, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -735,6 +738,9 @@ describe("get_preventive_care", () => { location: "Praxis <<>>Dr. Example", nextDueAt: null, lastSatisfiedAt: null, + snoozedUntil: null, + lastSkippedAt: null, + skipCount: 0, enabled: true, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -796,6 +802,9 @@ describe("get_preventive_care", () => { location: null, nextDueAt, lastSatisfiedAt: null, + snoozedUntil: null, + lastSkippedAt: null, + skipCount: 0, enabled: true, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 95695bb7d..87216ec11 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -895,6 +895,13 @@ const getPreventiveCareOutput: z.ZodRawShape = { overdue: z.boolean(), location: z.string().nullable(), lastSatisfiedAt: z.string().nullable(), + // v1.37.20 (#223) — resolved skip/snooze state. Without these the + // model sees a due date jump with no cause and invents one. + // `lastSatisfiedAt` stays the ONLY completed signal: a skip is + // recorded as a skip, never as done. + snoozedUntil: z.string().nullable(), + lastSkippedAt: z.string().nullable(), + skipCount: z.number(), }), ) .optional(), @@ -1714,6 +1721,10 @@ export const MCP_TOOLS: McpToolDefinition[] = [ : false, location: dto.location !== null ? fenceUserText(dto.location) : null, lastSatisfiedAt: dto.lastSatisfiedAt, + // Timestamps + a counter, no free text — nothing to fence here. + snoozedUntil: dto.snoozedUntil, + lastSkippedAt: dto.lastSkippedAt, + skipCount: dto.skipCount, }; }); diff --git a/src/lib/measurement-reminders/__tests__/resolve.test.ts b/src/lib/measurement-reminders/__tests__/resolve.test.ts index 13c665ef7..ac06f2e70 100644 --- a/src/lib/measurement-reminders/__tests__/resolve.test.ts +++ b/src/lib/measurement-reminders/__tests__/resolve.test.ts @@ -33,6 +33,7 @@ const base = { measurementType: null, anchorDate: null, lastSatisfiedAt: null, + lastSkippedAt: null, createdAt: new Date("2026-06-01T00:00:00Z"), } as const; @@ -46,6 +47,63 @@ describe("satisfactionFloor", () => { expect(satisfactionFloor({ ...base, anchorDate: anchor })).toEqual(anchor); expect(satisfactionFloor(base)).toEqual(base.createdAt); }); + + // v1.37.20 (#223) — the skip arm of the floor. Watched red: with the old + // `lastSatisfiedAt ?? anchorDate ?? createdAt` floor both assertions below + // failed (the fresh skip was invisible and a backdated reading resolved + // the cycle the user had just skipped). + it("takes the LATER of lastSatisfiedAt and lastSkippedAt", () => { + const satisfied = new Date("2026-06-10T00:00:00Z"); + const skipped = new Date("2026-06-20T00:00:00Z"); + expect( + satisfactionFloor({ + ...base, + lastSatisfiedAt: satisfied, + lastSkippedAt: skipped, + }), + ).toEqual(skipped); + // And symmetric — a satisfy after the skip wins again. + expect( + satisfactionFloor({ + ...base, + lastSatisfiedAt: skipped, + lastSkippedAt: satisfied, + }), + ).toEqual(skipped); + }); + + it("uses a lone lastSkippedAt as the floor before anchor/created fall backs", () => { + const skipped = new Date("2026-06-20T00:00:00Z"); + const anchor = new Date("2026-06-05T00:00:00Z"); + expect( + satisfactionFloor({ + ...base, + lastSkippedAt: skipped, + anchorDate: anchor, + }), + ).toEqual(skipped); + }); +}); + +describe("findSatisfyingEvent after a skip", () => { + it("does not resolve the fresh skip cycle from a reading older than the skip", async () => { + // A backdated sync (an old reading arriving late) predates the skip the + // user just made. The floor must sit AT the skip, so the query asks for + // events strictly after it — the stale reading cannot match. + const skipped = new Date("2026-06-20T00:00:00Z"); + const prisma = makePrisma({ measurement: null }); + + await findSatisfyingEvent(prisma as never, "u1", { + ...base, + measurementType: "WEIGHT", + lastSkippedAt: skipped, + }); + + const where = prisma.measurement.findFirst.mock.calls[0]![0].where as { + measuredAt: { gt: Date }; + }; + expect(where.measuredAt.gt).toEqual(skipped); + }); }); describe("findSatisfyingEvent", () => { diff --git a/src/lib/measurement-reminders/__tests__/satisfy.test.ts b/src/lib/measurement-reminders/__tests__/satisfy.test.ts index 251737bd8..a557dab13 100644 --- a/src/lib/measurement-reminders/__tests__/satisfy.test.ts +++ b/src/lib/measurement-reminders/__tests__/satisfy.test.ts @@ -3,10 +3,17 @@ * re-anchored reschedule. The cron, the manual satisfy route, and the * eventful worker all route through it, so these tests pin the invariants * every caller relies on. + * + * v1.37.20 (#223) — extended for the skip/snooze feature: the ledger append + * (iOS #68), the skip-aware satisfy anchor, snooze clearing, and the + * `skipReminder` primitive with its hard invariant (`lastSatisfiedAt` is + * never touched by a skip). Watched red: the pre-#223 suite went red on the + * widened `SatisfiableReminder` shape and the new `source` argument before + * this file was updated — proof the type system walks every caller. */ import { describe, expect, it, vi } from "vitest"; -import { satisfyReminder } from "../satisfy"; +import { satisfyReminder, skipReminder } from "../satisfy"; const TZ = "Europe/Berlin"; @@ -15,12 +22,13 @@ function makePrisma(updateManyCount = 1) { where: Record; data: Record; }> = []; + const events: Array> = []; const prisma = { measurementReminder: { - // v1.18.1 — `satisfyReminder` now writes via a conditional - // `updateMany` so the forward-only guard re-asserts at the DB row - // (close the cron-vs-worker TOCTOU). `updateManyCount` simulates a - // racing writer having already advanced the row (count === 0). + // v1.18.1 — `satisfyReminder` writes via a conditional `updateMany` so + // the forward-only guard re-asserts at the DB row (close the + // cron-vs-worker TOCTOU). `updateManyCount` simulates a racing writer + // having already advanced the row (count === 0). updateMany: vi.fn( async ({ where, @@ -34,8 +42,14 @@ function makePrisma(updateManyCount = 1) { }, ), }, + measurementReminderEvent: { + create: vi.fn(async ({ data }: { data: Record }) => { + events.push(data); + return { id: "evt", ...data }; + }), + }, }; - return { prisma, updates }; + return { prisma, updates, events }; } function reminder( @@ -43,11 +57,14 @@ function reminder( ) { return { id: "r1", + userId: "u1", intervalDays: 7, rrule: null, anchorDate: null, notifyHour: 9, + nextDueAt: null, lastSatisfiedAt: null, + lastSkippedAt: null, createdAt: new Date("2026-06-01T00:00:00Z"), ...overrides, }; @@ -58,7 +75,13 @@ describe("satisfyReminder", () => { const { prisma, updates } = makePrisma(); const at = new Date("2026-06-14T18:00:00Z"); - const result = await satisfyReminder(prisma as never, reminder(), TZ, at); + const result = await satisfyReminder( + prisma as never, + reminder(), + TZ, + at, + "manual", + ); expect(result.satisfied).toBe(true); expect(result.nextDueAt).toBeInstanceOf(Date); @@ -74,7 +97,7 @@ describe("satisfyReminder", () => { const { prisma } = makePrisma(); const at = new Date("2026-06-14T18:00:00Z"); - await satisfyReminder(prisma as never, reminder(), TZ, at); + await satisfyReminder(prisma as never, reminder(), TZ, at, "manual"); const call = prisma.measurementReminder.updateMany.mock .calls[0][0] as unknown as { @@ -88,15 +111,23 @@ describe("satisfyReminder", () => { }); it("treats a racing-writer updateMany count of 0 as a forward-only no-op", async () => { - const { prisma } = makePrisma(0); + const { prisma, events } = makePrisma(0); const at = new Date("2026-06-14T18:00:00Z"); - const result = await satisfyReminder(prisma as never, reminder(), TZ, at); + const result = await satisfyReminder( + prisma as never, + reminder(), + TZ, + at, + "manual", + ); // The in-memory guard passed (lastSatisfiedAt null) but the DB write - // matched no row — a concurrent satisfy already advanced it. No-op. + // matched no row — a concurrent satisfy already advanced it. No-op, and + // crucially NO ledger row: an unapplied satisfy must leave no history. expect(result.satisfied).toBe(false); expect(result.nextDueAt).toBeNull(); + expect(events).toHaveLength(0); }); it("advances when the event is strictly after the existing lastSatisfiedAt", async () => { @@ -109,6 +140,7 @@ describe("satisfyReminder", () => { reminder({ lastSatisfiedAt: prev }), TZ, at, + "manual", ); expect(result.satisfied).toBe(true); @@ -125,6 +157,7 @@ describe("satisfyReminder", () => { reminder({ lastSatisfiedAt: prev }), TZ, stale, + "manual", ); expect(result.satisfied).toBe(false); @@ -142,9 +175,198 @@ describe("satisfyReminder", () => { reminder({ lastSatisfiedAt: same }), TZ, same, + "manual", ); expect(result.satisfied).toBe(false); expect(prisma.measurementReminder.updateMany).not.toHaveBeenCalled(); }); + + it("clears the snooze cursor on satisfy", async () => { + const { prisma, updates } = makePrisma(); + const at = new Date("2026-06-14T18:00:00Z"); + + await satisfyReminder(prisma as never, reminder(), TZ, at, "manual"); + + expect(updates[0].data.snoozedUntil).toBeNull(); + }); + + it("appends a SATISFIED ledger row with write-time onTime (on time)", async () => { + const { prisma, events } = makePrisma(); + const due = new Date("2026-06-15T07:00:00Z"); + const at = new Date("2026-06-14T18:00:00Z"); // before due → on time + + await satisfyReminder( + prisma as never, + reminder({ nextDueAt: due }), + TZ, + at, + "auto_measurement", + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + userId: "u1", + reminderId: "r1", + kind: "SATISFIED", + occurredAt: at, + onTime: true, + source: "auto_measurement", + }); + }); + + it("derives onTime=false when the event lands after the pre-event due instant", async () => { + const { prisma, events } = makePrisma(); + const due = new Date("2026-06-10T07:00:00Z"); + const late = new Date("2026-06-14T18:00:00Z"); + + await satisfyReminder( + prisma as never, + reminder({ nextDueAt: due }), + TZ, + late, + "manual", + ); + + expect(events[0].onTime).toBe(false); + }); + + it("never lets a backdated satisfy re-anchor behind a fresh skip (max anchor)", async () => { + const { prisma, updates } = makePrisma(); + const skippedAt = new Date("2026-06-20T10:00:00Z"); + const backdated = new Date("2026-06-14T18:00:00Z"); // real event, older sync + + const result = await satisfyReminder( + prisma as never, + reminder({ lastSkippedAt: skippedAt }), + TZ, + backdated, + "auto_measurement", + ); + + // The event is honestly recorded as lastSatisfiedAt … + expect(result.satisfied).toBe(true); + expect(updates[0].data.lastSatisfiedAt).toEqual(backdated); + // … but the reschedule anchors on the LATER skip: next due lands a full + // interval after the skip decision, never behind it. + expect((updates[0].data.nextDueAt as Date).getTime()).toBeGreaterThan( + skippedAt.getTime(), + ); + }); +}); + +describe("skipReminder", () => { + it("stamps lastSkippedAt, increments skipCount, clears snooze — and never touches lastSatisfiedAt", async () => { + const { prisma, updates } = makePrisma(); + const at = new Date("2026-06-14T18:00:00Z"); + + const result = await skipReminder(prisma as never, reminder(), TZ, at); + + expect(result.skipped).toBe(true); + expect(updates).toHaveLength(1); + expect(updates[0].data.lastSkippedAt).toEqual(at); + expect(updates[0].data.skipCount).toEqual({ increment: 1 }); + expect(updates[0].data.snoozedUntil).toBeNull(); + // THE hard invariant of the whole feature: a skip is not a completion. + expect("lastSatisfiedAt" in updates[0].data).toBe(false); + }); + + it("restarts a rolling interval from the skip instant", async () => { + const { prisma, updates } = makePrisma(); + const at = new Date("2026-06-14T18:00:00Z"); + + const result = await skipReminder(prisma as never, reminder(), TZ, at); + + // +7d at the 09:00 notify hour, strictly after the skip. + expect(result.nextDueAt).toBeInstanceOf(Date); + expect((updates[0].data.nextDueAt as Date).getTime()).toBeGreaterThan( + at.getTime() + 6 * 24 * 60 * 60 * 1000, + ); + }); + + it("walks an rrule cadence to the next occurrence strictly after the skip", async () => { + const { prisma, updates } = makePrisma(); + const at = new Date("2026-06-14T18:00:00Z"); + + const result = await skipReminder( + prisma as never, + reminder({ intervalDays: null, rrule: "FREQ=YEARLY" }), + TZ, + at, + ); + + expect(result.skipped).toBe(true); + expect((updates[0].data.nextDueAt as Date).getTime()).toBeGreaterThan( + at.getTime(), + ); + }); + + it("is a forward-only no-op at or behind the last satisfy OR the last skip", async () => { + const { prisma, events } = makePrisma(); + const cursor = new Date("2026-06-17T08:00:00Z"); + const stale = new Date("2026-06-10T08:00:00Z"); + + const bySatisfy = await skipReminder( + prisma as never, + reminder({ lastSatisfiedAt: cursor }), + TZ, + stale, + ); + const bySkip = await skipReminder( + prisma as never, + reminder({ lastSkippedAt: cursor }), + TZ, + stale, + ); + + expect(bySatisfy.skipped).toBe(false); + expect(bySkip.skipped).toBe(false); + expect(prisma.measurementReminder.updateMany).not.toHaveBeenCalled(); + expect(events).toHaveLength(0); + }); + + it("re-asserts forward-only against BOTH cursors in the conditional updateMany", async () => { + const { prisma } = makePrisma(); + const at = new Date("2026-06-14T18:00:00Z"); + + await skipReminder(prisma as never, reminder(), TZ, at); + + const call = prisma.measurementReminder.updateMany.mock + .calls[0][0] as unknown as { + where: { id: string; AND: unknown[] }; + }; + expect(call.where.id).toBe("r1"); + expect(call.where.AND).toEqual([ + { OR: [{ lastSatisfiedAt: null }, { lastSatisfiedAt: { lt: at } }] }, + { OR: [{ lastSkippedAt: null }, { lastSkippedAt: { lt: at } }] }, + ]); + }); + + it("treats a racing-writer updateMany count of 0 as a no-op with no ledger row", async () => { + const { prisma, events } = makePrisma(0); + const at = new Date("2026-06-14T18:00:00Z"); + + const result = await skipReminder(prisma as never, reminder(), TZ, at); + + expect(result.skipped).toBe(false); + expect(events).toHaveLength(0); + }); + + it("appends a SKIPPED ledger row with source 'skip' and write-time onTime", async () => { + const { prisma, events } = makePrisma(); + const due = new Date("2026-06-10T07:00:00Z"); + const at = new Date("2026-06-14T18:00:00Z"); // skipping while overdue + + await skipReminder(prisma as never, reminder({ nextDueAt: due }), TZ, at); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + userId: "u1", + reminderId: "r1", + kind: "SKIPPED", + occurredAt: at, + onTime: false, + source: "skip", + }); + }); }); diff --git a/src/lib/measurement-reminders/dto.ts b/src/lib/measurement-reminders/dto.ts index 0890cfd8f..0f53f286a 100644 --- a/src/lib/measurement-reminders/dto.ts +++ b/src/lib/measurement-reminders/dto.ts @@ -37,6 +37,16 @@ export interface MeasurementReminderDtoShape { location: string | null; nextDueAt: string | null; lastSatisfiedAt: string | null; + /** + * v1.37.20 (#223) — the three skip/snooze fields are resolved timestamps + * plus a counter; clients only ever compare against the clock (snoozed = + * `snoozedUntil > now`; skipped cycle = `lastSkippedAt > lastSatisfiedAt`) + * and never recompute cadence. `lastSatisfiedAt` stays the ONLY completed + * signal — skip and snooze never move it. + */ + snoozedUntil: string | null; + lastSkippedAt: string | null; + skipCount: number; enabled: boolean; createdAt: string; updatedAt: string; @@ -85,6 +95,9 @@ export function toMeasurementReminderDto( lastSatisfiedAt: row.lastSatisfiedAt ? row.lastSatisfiedAt.toISOString() : null, + snoozedUntil: row.snoozedUntil ? row.snoozedUntil.toISOString() : null, + lastSkippedAt: row.lastSkippedAt ? row.lastSkippedAt.toISOString() : null, + skipCount: row.skipCount, enabled: row.enabled, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), diff --git a/src/lib/measurement-reminders/resolve.ts b/src/lib/measurement-reminders/resolve.ts index 8af993000..fbc664c45 100644 --- a/src/lib/measurement-reminders/resolve.ts +++ b/src/lib/measurement-reminders/resolve.ts @@ -26,6 +26,13 @@ export interface ResolvableReminder { measurementType: import("@/generated/prisma/client").MeasurementType | null; anchorDate: Date | null; lastSatisfiedAt: Date | null; + /** + * v1.37.20 (#223) — the last honest skip. Part of the floor: a reading + * OLDER than a fresh skip must not auto-resolve the cycle the user just + * skipped. Required (not optional) so no call site can silently feed a + * pre-skip shape and reopen the gap. + */ + lastSkippedAt: Date | null; createdAt: Date; /** * Optional so the many callers that construct this shape by hand keep @@ -36,12 +43,26 @@ export interface ResolvableReminder { } /** - * The floor a satisfying event must land strictly after: the last satisfy, - * else the anchor, else the create instant. A reading inside the current - * due cycle means the user already measured. + * The floor a satisfying event must land strictly after: the later of the + * last satisfy and the last skip, else the anchor, else the create instant. + * A reading inside the current due cycle means the user already measured. + * + * v1.37.20 (#223) — the skip arm: skipping restarts the cycle at the skip + * instant, so a reading that predates the skip belongs to the cycle the + * user deliberately let go and must not resolve the fresh one. Without it + * a backdated sync would silently undo the skip decision. */ export function satisfactionFloor(reminder: ResolvableReminder): Date { - return reminder.lastSatisfiedAt ?? reminder.anchorDate ?? reminder.createdAt; + const cursor = + reminder.lastSatisfiedAt !== null || reminder.lastSkippedAt !== null + ? new Date( + Math.max( + reminder.lastSatisfiedAt?.getTime() ?? 0, + reminder.lastSkippedAt?.getTime() ?? 0, + ), + ) + : null; + return cursor ?? reminder.anchorDate ?? reminder.createdAt; } /** diff --git a/src/lib/measurement-reminders/satisfy.ts b/src/lib/measurement-reminders/satisfy.ts index 50ada950c..b167bc025 100644 --- a/src/lib/measurement-reminders/satisfy.ts +++ b/src/lib/measurement-reminders/satisfy.ts @@ -8,21 +8,30 @@ * reschedule logic — one place stamps `lastSatisfiedAt` and recomputes the * server-authoritative `nextDueAt`. * - * Two invariants the primitive owns: + * Invariants the primitive owns: * * 1. **Forward-only.** `lastSatisfiedAt` only ever moves forward. A * cron poll and an ingest enqueue can both fire for the same reading; * the second is a no-op (returns `false`) rather than re-stamping an * older instant. This is what makes the cron a safe idempotent * safety-net behind the eventful hook. - * 2. **Re-anchored reschedule.** `nextDueAt` is recomputed from the - * satisfy instant via the canonical recurrence engine - * (`computeReminderNextDueAt`), so a freshly-satisfied reminder rolls - * strictly past the current due cycle and the self-tracker never gets - * nagged. + * 2. **Re-anchored reschedule.** `nextDueAt` is recomputed via the + * canonical recurrence engine (`computeReminderNextDueAt`) from + * `max(satisfiedAt, lastSkippedAt)` — the satisfy instant, unless the + * user has since skipped, in which case a BACKDATED real result is + * recorded as `lastSatisfiedAt` but never drags the due date back + * behind the skip decision (v1.37.20, #223). + * 3. **Ledger append.** A successful satisfy appends one + * `MeasurementReminderEvent` row (iOS #68) with `onTime` derived at + * write time against the pre-event `nextDueAt` — the only moment that + * value still exists. + * 4. **Snooze clearing.** A satisfy recomputes `nextDueAt`, so it nulls + * `snoozedUntil` — the snooze cursor never outlives the cycle it + * pushed back. * - * Pure-ish: it reads the row fields it is handed and issues one Prisma - * update. The caller supplies the reminder row + the user's timezone. + * Pure-ish: it reads the row fields it is handed and issues one conditional + * update plus one ledger insert. The caller supplies the reminder row, the + * user's timezone, and which engine path it is (`source`). */ import type { PrismaClient } from "@/generated/prisma/client"; import { @@ -30,17 +39,43 @@ import { type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; +/** + * Which engine path produced a ledger event. Closed set — the ledger's + * `source` column is asserted here, not free text. + * + * Declared as a runtime tuple as well as a type (v1.37.20, #223 / iOS #68): + * the backup payload schema validates the `source` a restore is handed + * against this exact set, and a Zod enum needs the values at runtime. One + * declaration, so the validator cannot drift from the engine. + */ +export const REMINDER_EVENT_SOURCES = [ + "manual", + "auto_measurement", + "auto_lab", + "telegram", + "vaccination", + "encounter", + "skip", +] as const; + +export type ReminderEventSource = (typeof REMINDER_EVENT_SOURCES)[number]; + /** * The reminder fields `satisfyReminder` needs. A subset of the Prisma row * so callers (and tests) can construct it without the full model. + * `userId` and the pre-event `nextDueAt` feed the ledger append; + * `lastSkippedAt` feeds the skip-aware re-anchor. */ export interface SatisfiableReminder { id: string; + userId: string; intervalDays: number | null; rrule: string | null; anchorDate: Date | null; notifyHour: number; + nextDueAt: Date | null; lastSatisfiedAt: Date | null; + lastSkippedAt: Date | null; createdAt: Date; } @@ -53,20 +88,40 @@ export interface SatisfyResult { nextDueAt: Date | null; } +/** + * `onTime` is derived at write time, against the reminder's `nextDueAt` as + * it stood BEFORE this event re-anchored it: the event landed at or before + * the due instant, or the cadence had no computable due (a one-shot whose + * slot already fired counts as late only while a due instant exists to be + * late against). After the write the pre-event value is gone, which is why + * clients consume this resolved boolean and never re-derive it. + */ +function deriveOnTime(preEventNextDueAt: Date | null, occurredAt: Date) { + return ( + preEventNextDueAt === null || + occurredAt.getTime() <= preEventNextDueAt.getTime() + ); +} + /** * Mark a reminder's cadence satisfied at `satisfiedAt` and reschedule. * * Forward-only: if `satisfiedAt` is not strictly after the existing * `lastSatisfiedAt`, nothing is written and `{ satisfied: false }` is * returned. Otherwise stamps `lastSatisfiedAt = satisfiedAt`, recomputes - * `nextDueAt` from the satisfy instant, persists both, and returns + * `nextDueAt` anchored at `max(satisfiedAt, lastSkippedAt)`, clears any + * snooze, appends the SATISFIED ledger row, and returns * `{ satisfied: true, nextDueAt }`. + * + * HARD INVARIANT (the mirror of the skip primitive's): only a satisfy ever + * writes `lastSatisfiedAt`. Skip and snooze never touch it. */ export async function satisfyReminder( prisma: PrismaClient, reminder: SatisfiableReminder, timezone: string, satisfiedAt: Date, + source: Exclude, ): Promise { // Forward-only guard. A null `lastSatisfiedAt` always advances. Equal // instants are a no-op so a cron poll behind an already-applied ingest @@ -78,19 +133,26 @@ export async function satisfyReminder( return { satisfied: false, nextDueAt: null }; } + // v1.37.20 (#223) — skip-aware anchor: a backdated real result (an old + // reading synced late, a corrected visit date) is honestly recorded as + // `lastSatisfiedAt`, but the NEXT due date never lands behind a skip the + // user made after that event. Without the max() a fresh skip's re-anchor + // would be silently undone by any older reading arriving afterwards. + const anchorAt = + reminder.lastSkippedAt !== null && + reminder.lastSkippedAt.getTime() > satisfiedAt.getTime() + ? reminder.lastSkippedAt + : satisfiedAt; + const scheduleInput: ReminderScheduleInput = { intervalDays: reminder.intervalDays, rrule: reminder.rrule, anchorDate: reminder.anchorDate, notifyHour: reminder.notifyHour, - lastSatisfiedAt: satisfiedAt, + lastSatisfiedAt: anchorAt, createdAt: reminder.createdAt, }; - const nextDueAt = computeReminderNextDueAt( - scheduleInput, - timezone, - satisfiedAt, - ); + const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, anchorAt); // v1.18.1 — close the forward-only TOCTOU: the in-memory guard above can // pass concurrently in the cron poll AND the eventful worker for the same @@ -104,11 +166,123 @@ export async function satisfyReminder( id: reminder.id, OR: [{ lastSatisfiedAt: null }, { lastSatisfiedAt: { lt: satisfiedAt } }], }, - data: { lastSatisfiedAt: satisfiedAt, nextDueAt }, + // A recompute of `nextDueAt` clears the snooze cursor — the real event + // ended the cycle the snooze was pushing back. + data: { lastSatisfiedAt: satisfiedAt, nextDueAt, snoozedUntil: null }, }); if (result.count === 0) { return { satisfied: false, nextDueAt: null }; } + // Ledger append (iOS #68): exactly one row per applied satisfy, from + // every path, because every path is this primitive. Written after the + // conditional update so a forward-only no-op leaves no row. + await prisma.measurementReminderEvent.create({ + data: { + userId: reminder.userId, + reminderId: reminder.id, + kind: "SATISFIED", + occurredAt: satisfiedAt, + onTime: deriveOnTime(reminder.nextDueAt, satisfiedAt), + source, + }, + }); + return { satisfied: true, nextDueAt }; } + +export interface SkipResult { + /** True when the skip applied; false when it was a forward-only no-op + * (the row already advanced past the skip instant). */ + skipped: boolean; + /** The recomputed next-due instant when skipped, else `null`. */ + nextDueAt: Date | null; +} + +/** + * v1.37.20 (#223) — honestly skip the current due cycle at `skippedAt` + * (always the server clock; the route takes no body). + * + * The interval restarts from the skip instant: `nextDueAt` is recomputed + * with the skip standing in as the rolling anchor (an rrule cadence walks + * to its next occurrence strictly after the skip). `lastSkippedAt` is + * stamped, `skipCount` incremented, any snooze cleared. + * + * HARD INVARIANT: `lastSatisfiedAt` is NEVER touched here. A skip is not a + * completion — the ledger records it as `SKIPPED` and every surface that + * shows "last done" keeps showing the last real result. + * + * Forward-only against BOTH cursors: a skip at/behind the last satisfy or + * the last skip is a no-op, enforced in the conditional `updateMany` (the + * same TOCTOU pattern `satisfyReminder` uses). + */ +export async function skipReminder( + prisma: PrismaClient, + reminder: SatisfiableReminder, + timezone: string, + skippedAt: Date, +): Promise { + const floor = Math.max( + reminder.lastSatisfiedAt?.getTime() ?? 0, + reminder.lastSkippedAt?.getTime() ?? 0, + ); + if (floor > 0 && skippedAt.getTime() <= floor) { + return { skipped: false, nextDueAt: null }; + } + + // The skip stands in as the rolling anchor: skip + interval at the notify + // hour, or the next rrule occurrence strictly after the skip. + const scheduleInput: ReminderScheduleInput = { + intervalDays: reminder.intervalDays, + rrule: reminder.rrule, + anchorDate: reminder.anchorDate, + notifyHour: reminder.notifyHour, + lastSatisfiedAt: skippedAt, + createdAt: reminder.createdAt, + }; + const nextDueAt = computeReminderNextDueAt( + scheduleInput, + timezone, + skippedAt, + ); + + const result = await prisma.measurementReminder.updateMany({ + where: { + id: reminder.id, + AND: [ + { + OR: [ + { lastSatisfiedAt: null }, + { lastSatisfiedAt: { lt: skippedAt } }, + ], + }, + { + OR: [{ lastSkippedAt: null }, { lastSkippedAt: { lt: skippedAt } }], + }, + ], + }, + // `lastSatisfiedAt` is conspicuously absent — see the invariant above. + data: { + lastSkippedAt: skippedAt, + skipCount: { increment: 1 }, + snoozedUntil: null, + nextDueAt, + }, + }); + if (result.count === 0) { + return { skipped: false, nextDueAt: null }; + } + + await prisma.measurementReminderEvent.create({ + data: { + userId: reminder.userId, + reminderId: reminder.id, + kind: "SKIPPED", + occurredAt: skippedAt, + onTime: deriveOnTime(reminder.nextDueAt, skippedAt), + source: "skip", + }, + }); + + return { skipped: true, nextDueAt }; +} diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index d0309e006..f4eb4a2b4 100644 --- a/src/lib/openapi/routes/measurement-reminders.ts +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -13,6 +13,10 @@ import { updateMeasurementReminderSchema, measurementReminderDto, measurementReminderCompletionDto, + measurementReminderSkipDto, + measurementReminderHistoryDto, + snoozeMeasurementReminderSchema, + listReminderHistorySchema, } from "@/lib/validations/measurement-reminders"; import { dataEnvelope, @@ -206,4 +210,89 @@ export const measurementReminderPaths: NonNullable = }, }, }, + "/api/measurement-reminders/{id}/skip": { + post: { + tags: ["MeasurementReminders"], + summary: "Skip the current due cycle (v1.37.20)", + description: + "Honestly skips the current due cycle — no body; the skip instant is the server clock. The interval restarts from the skip (rolling: skip + N days at the notify hour; rrule: next occurrence strictly after the skip), lastSkippedAt is stamped, skipCount incremented, any snooze cleared, and a SKIPPED row lands in the completion ledger. lastSatisfiedAt is never touched: a skip is not a completion. Screening reminders are skippable (the satisfy-side 409 guards claimed fulfilment; a skip claims the opposite). Owner-scoped; an appointment (encounter) reminder 404s.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + ...recordRefusal(), + "200": { + description: + "Skip applied (skipped=true) or forward-only no-op (skipped=false); reminder carries the canonical post-skip DTO.", + content: { + "application/json": { + schema: dataEnvelope( + measurementReminderSkipDto, + "MeasurementReminderSkipEnvelope", + ), + }, + }, + }, + ...reminderNotFound, + ...stdResponses, + }, + }, + }, + "/api/measurement-reminders/{id}/snooze": { + post: { + tags: ["MeasurementReminders"], + summary: "Snooze to a calendar day (v1.37.20)", + description: + "Pushes the current due date back to a named calendar day (YYYY-MM-DD), resolved server-side to the reminder's notifyHour in the profile timezone. snoozedUntil and nextDueAt are set to the same instant, so every due-state consumer moves together and the cursor self-expires. The regular cadence is untouched — lastSatisfiedAt, lastSkippedAt and the anchor stay put. Must be at least tomorrow and at most five years out (422 otherwise). Repeated snoozes: last one wins. Owner-scoped; an appointment reminder 404s.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: snoozeMeasurementReminderSchema }, + }, + }, + responses: { + ...recordRefusal(), + "200": { + description: "Snooze applied; reminder carries the resolved DTO.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ reminder: measurementReminderDto }), + "MeasurementReminderSnoozeEnvelope", + ), + }, + }, + }, + ...reminderNotFound, + ...stdResponses, + }, + }, + }, + "/api/measurement-reminders/{id}/history": { + get: { + tags: ["MeasurementReminders"], + summary: "Completion ledger, newest first (v1.37.20)", + description: + "Paginated completion ledger for one reminder (iOS #68): one row per satisfy (from any path) and per skip, newest first. onTime is derived server-side at write time against the due instant that was current when the event landed. History begins at the release that introduced the ledger — the single-cursor engine holds nothing to backfill from. Owner-scoped; an appointment reminder 404s.", + requestParams: { + path: z.object({ id: z.string() }), + query: listReminderHistorySchema, + }, + responses: { + ...recordRefusal(), + "200": { + description: "The reminder's completion ledger page.", + content: { + "application/json": { + schema: dataEnvelope( + measurementReminderHistoryDto, + "MeasurementReminderHistoryEnvelope", + ), + }, + }, + }, + ...reminderNotFound, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/query-keys/measurement-reminders.ts b/src/lib/query-keys/measurement-reminders.ts index f5cce1af2..d60214a99 100644 --- a/src/lib/query-keys/measurement-reminders.ts +++ b/src/lib/query-keys/measurement-reminders.ts @@ -15,4 +15,7 @@ export const measurementReminderKeys = { measurementReminderDelete: () => ["measurement-reminders", "delete"] as const, measurementReminderSatisfy: () => ["measurement-reminders", "satisfy"] as const, + /** v1.37.20 (#223) — honest skip + snooze mutations. */ + measurementReminderSkip: () => ["measurement-reminders", "skip"] as const, + measurementReminderSnooze: () => ["measurement-reminders", "snooze"] as const, }; diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 61f2746bf..b9e7a128d 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -151,6 +151,17 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.measurementReminderSatisfy", { name, }); + // v1.37.20 (#223) — the honest skip and the snooze read differently in + // the feed on purpose: one says a cycle was let go, the other that a due + // date moved. Neither reads as "done". + case "measurementReminder.skip": + return t("recordSharing.activityVerb.measurementReminderSkip", { + name, + }); + case "measurementReminder.snooze": + return t("recordSharing.activityVerb.measurementReminderSnooze", { + name, + }); case "labResult.update": return t("recordSharing.activityVerb.labResultUpdate", { name }); diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 822b20460..5d05f0239 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -838,13 +838,16 @@ export async function handleCallback(update: TelegramUpdate) { }, select: { id: true, + userId: true, // v1.19.2 — the target metric; drives the optional numeric capture. measurementType: true, intervalDays: true, rrule: true, anchorDate: true, notifyHour: true, + nextDueAt: true, lastSatisfiedAt: true, + lastSkippedAt: true, createdAt: true, }, }); @@ -857,7 +860,7 @@ export async function handleCallback(update: TelegramUpdate) { return; } const tz = user.timezone || DEFAULT_TIMEZONE; - await satisfyReminder(prisma, reminder, tz, new Date()); + await satisfyReminder(prisma, reminder, tz, new Date(), "telegram"); await answerTelegramCallbackQuery( botToken, callback.id, diff --git a/src/lib/vaccinations/satisfy-matching.ts b/src/lib/vaccinations/satisfy-matching.ts index 8518b622c..7ec77fcac 100644 --- a/src/lib/vaccinations/satisfy-matching.ts +++ b/src/lib/vaccinations/satisfy-matching.ts @@ -100,12 +100,15 @@ export async function satisfyBoostersForDose( }, select: { id: true, + userId: true, vaccinationAntigen: true, intervalDays: true, rrule: true, anchorDate: true, notifyHour: true, + nextDueAt: true, lastSatisfiedAt: true, + lastSkippedAt: true, createdAt: true, }, }); @@ -125,11 +128,14 @@ export async function satisfyBoostersForDose( for (const reminder of ordered) { const row: SatisfiableReminder = { id: reminder.id, + userId: reminder.userId, intervalDays: reminder.intervalDays, rrule: reminder.rrule, anchorDate: reminder.anchorDate, notifyHour: reminder.notifyHour, + nextDueAt: reminder.nextDueAt, lastSatisfiedAt: reminder.lastSatisfiedAt, + lastSkippedAt: reminder.lastSkippedAt, createdAt: reminder.createdAt, }; // `satisfyReminder` types its client as the full `PrismaClient` because @@ -142,6 +148,7 @@ export async function satisfyBoostersForDose( row, timezone, occurredAt, + "vaccination", ); if (result.satisfied) satisfiedReminderIds.push(reminder.id); } diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index 22dad2305..31e9d2f6a 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -210,6 +210,9 @@ export const measurementReminderDto = z location: z.string().nullable(), nextDueAt: z.iso.datetime({ offset: true }).nullable(), lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(), + snoozedUntil: z.iso.datetime({ offset: true }).nullable(), + lastSkippedAt: z.iso.datetime({ offset: true }).nullable(), + skipCount: z.number().int(), enabled: z.boolean(), createdAt: z.iso.datetime({ offset: true }), updatedAt: z.iso.datetime({ offset: true }), @@ -217,7 +220,7 @@ export const measurementReminderDto = z .meta({ id: "MeasurementReminderDTO", description: - "A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended).", + "A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended). snoozedUntil/lastSkippedAt/skipCount are resolved values: snoozed means snoozedUntil > now, a skipped cycle means lastSkippedAt > lastSatisfiedAt; clients compare against the clock and never recompute cadence. lastSatisfiedAt stays the only completed signal — skip and snooze never move it.", }); /** @@ -240,5 +243,93 @@ export const measurementReminderCompletionDto = z .meta({ id: "MeasurementReminderCompletion", description: - "Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO.", + "Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO.", + }); + +/** + * v1.37.20 (#223) — snooze body. Day precision on purpose: the client names + * a local calendar day, the server resolves it to that day's notifyHour in + * the profile timezone. No client-supplied instant ever anchors the cadence. + * The range gate (tomorrow .. +5 years) lives in the route, where the + * profile timezone is known. + */ +export const snoozeMeasurementReminderSchema = z + .object({ + until: z.iso.date(), + }) + .meta({ + id: "MeasurementReminderSnooze", + description: + "Push a reminder's current due date back to a calendar day (YYYY-MM-DD). The server resolves the day to the reminder's notifyHour in the profile timezone. Must be at least tomorrow and at most five years out. The regular cadence is untouched: lastSatisfiedAt and the anchor stay where they are, and the reminder resumes its normal interval after the snoozed cycle.", + }); + +export type SnoozeMeasurementReminderInput = z.infer< + typeof snoozeMeasurementReminderSchema +>; + +/** + * v1.37.20 (#223) — skip result. `skipped=false` is the forward-only no-op + * (a concurrent skip or satisfy already advanced the row). + */ +export const measurementReminderSkipDto = z + .object({ + skipped: z.boolean(), + reminder: measurementReminderDto, + }) + .meta({ + id: "MeasurementReminderSkip", + description: + "Result of skipping the current due cycle. skipped=true when this call recorded the skip and restarted the interval from the skip instant; skipped=false when a concurrent skip or satisfy already advanced the row (idempotent no-op). A skip never touches lastSatisfiedAt — it is recorded honestly as a skip, not a completion.", + }); + +/** + * v1.37.20 (iOS #68) — completion-ledger query + row. History starts with + * the release that ships the ledger; the single-cursor engine holds nothing + * to backfill from. + */ +export const listReminderHistorySchema = z + .object({ + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), + }) + .meta({ + id: "MeasurementReminderHistoryQuery", + description: "Pagination for the reminder completion ledger.", + }); + +export const measurementReminderEventDto = z + .object({ + id: z.string(), + kind: z.enum(["SATISFIED", "SKIPPED"]), + occurredAt: z.iso.datetime({ offset: true }), + onTime: z.boolean(), + source: z.enum([ + "manual", + "auto_measurement", + "auto_lab", + "telegram", + "vaccination", + "encounter", + "skip", + ]), + createdAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "MeasurementReminderEvent", + description: + "One completion-ledger row: an honest fulfilment (SATISFIED) or an honest skip (SKIPPED). onTime is derived server-side at write time against the due instant that was current when the event landed — clients never re-derive it. History begins at the release that introduced the ledger.", + }); + +export const measurementReminderHistoryDto = z + .object({ + events: z.array(measurementReminderEventDto), + meta: z.object({ + total: z.number().int(), + limit: z.number().int(), + offset: z.number().int(), + }), + }) + .meta({ + id: "MeasurementReminderHistory", + description: "Paginated completion ledger for one reminder, newest first.", }); diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 37de10ffd..e90d4748e 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -240,6 +240,22 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "update", ), + handler( + "app/api/measurement-reminders/[id]/skip/route.ts", + "/api/measurement-reminders/[id]/skip", + "POST", + "measurements", + "manage", + "update", + ), + handler( + "app/api/measurement-reminders/[id]/snooze/route.ts", + "/api/measurement-reminders/[id]/snooze", + "POST", + "measurements", + "manage", + "update", + ), handler( "app/api/labs/[id]/route.ts", "/api/labs/[id]", From 689cacc0395d75838b3c256caec9a000313262d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:24:08 +0200 Subject: [PATCH 05/20] Keep the measurements and mood filters in the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documents view already round-trips its filters through the query string; the measurements and mood lists kept theirs in memory, so any navigation dropped them. The documents pattern is extracted into a shared hook (use-url-filter-sync) and both lists adopt it: type/source/day-bounds and the measurement value range survive reload, back and sharing, defaults stay out of the URL, invalid facets drop leniently. The insights readings subpage keeps its locked type in memory — that URL belongs to the insights route. /measurements no longer strips ?type=, so the deep links the Vorsorge cards mint now persist as filter state. --- src/app/measurements/page.tsx | 32 +-- src/components/documents/documents-view.tsx | 26 +-- ...asurement-list-apple-health-badge.test.tsx | 1 + .../measurement-list-bp-badge.test.tsx | 1 + .../__tests__/measurement-list-empty.test.tsx | 1 + .../measurement-list-filter-select.test.tsx | 1 + .../measurement-list-filters.test.ts | 81 +++++++ .../measurement-list-locked-type.test.tsx | 1 + .../measurement-list-step-grouping.test.tsx | 1 + .../measurement-list-url-sync.test.tsx | 220 ++++++++++++++++++ .../measurements/measurement-list-filters.ts | 98 ++++++++ .../measurements/measurement-list.tsx | 122 ++++++---- .../mood-list-filter-select.test.tsx | 1 + .../mood/__tests__/mood-list-filters.test.ts | 68 ++++++ .../__tests__/mood-list-url-sync.test.tsx | 194 +++++++++++++++ .../__tests__/mood-note-fulltext.test.tsx | 1 + src/components/mood/mood-list-filters.ts | 69 ++++++ src/components/mood/mood-list.tsx | 40 +++- .../__tests__/use-url-filter-sync.test.tsx | 109 +++++++++ src/hooks/use-url-filter-sync.ts | 72 ++++++ 20 files changed, 1048 insertions(+), 91 deletions(-) create mode 100644 src/components/measurements/__tests__/measurement-list-filters.test.ts create mode 100644 src/components/measurements/__tests__/measurement-list-url-sync.test.tsx create mode 100644 src/components/measurements/measurement-list-filters.ts create mode 100644 src/components/mood/__tests__/mood-list-filters.test.ts create mode 100644 src/components/mood/__tests__/mood-list-url-sync.test.tsx create mode 100644 src/components/mood/mood-list-filters.ts create mode 100644 src/hooks/__tests__/use-url-filter-sync.test.tsx create mode 100644 src/hooks/use-url-filter-sync.ts diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 3f753ed8e..f03556902 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -12,7 +12,6 @@ import { } from "@/components/measurements/measurement-form"; import { MeasurementList } from "@/components/measurements/measurement-list"; import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list"; -import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/ui/page-header"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; @@ -64,18 +63,6 @@ export default function MeasurementsPage() { const addRequestParam = addParam ? `${addParam}\u0000${returnToParam ?? ""}` : null; - // v1.18.7 (Wave E) — a `?type=` deep link (e.g. the - // Vorsorge card's "Show measurements") seeds the list's type filter on - // first paint. Captured once via a lazy initializer so the value is - // stable even after the effect below strips the query string; an unknown - // token is dropped (validated against the canonical type-label map), so a - // stale link never traps the user on a broken filter. - const typeParam = searchParams.get("type"); - const [initialType] = useState(() => - typeParam && typeParam in MEASUREMENT_TYPE_LABEL_KEYS - ? typeParam - : undefined, - ); const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null); const [defaultType, setDefaultType] = useState( () => initialAdd ?? undefined, @@ -118,15 +105,21 @@ export default function MeasurementsPage() { } } - // Drop the query string so the back-button leaves the user on - // `/measurements` rather than re-opening the dialog (or re-seeding the - // type filter). The type filter is already seeded into the list's own - // state via `initialType`, so stripping the param doesn't lose it. + // Drop the one-shot `?add=` / `?returnTo=` params so the back-button + // leaves the user on `/measurements` rather than re-opening the dialog. + // Every OTHER param is preserved: the list's filter state (`?type=`, + // `?source=`, …) now lives in the URL (see `measurement-list-filters.ts`) + // and stripping it would lose the filters this page is meant to keep. // Effect-driven (client-only) because `router.replace` is not callable // during SSR. useEffect(() => { - if (addParam || typeParam || returnToParam) router.replace("/measurements"); - }, [addParam, typeParam, returnToParam, router]); + if (!addParam && !returnToParam) return; + const next = new URLSearchParams(searchParams.toString()); + next.delete("add"); + next.delete("returnTo"); + const search = next.toString(); + router.replace(search ? `/measurements?${search}` : "/measurements"); + }, [addParam, returnToParam, searchParams, router]); useEffect(() => { if (!isLoading && !isAuthenticated) { @@ -214,7 +207,6 @@ export default function MeasurementsPage() { setReturnTo(null); setDialogOpen(true); }} - initialType={initialType} /> diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 7eba90039..04c877b23 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -40,6 +40,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { encounterKindText } from "@/components/encounters/encounter-labels"; @@ -51,7 +52,6 @@ import { useCoachLaunch, } from "@/lib/insights/coach-launch-context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; -import type { DocumentVaultFilters } from "@/lib/query-keys/documents"; import { DOCUMENT_BULK_MAX_IDS, type DocumentBulkAction, @@ -263,23 +263,13 @@ export function DocumentsView() { }, [authLoading, isAuthenticated, moduleEnabled, router]); // ── URL-owned filter state ──────────────────────────────────────────── - const filters = useMemo( - () => parseVaultSearchParams(new URLSearchParams(searchParams.toString())), - [searchParams], - ); - - const applyFilters = useCallback( - (next: DocumentVaultFilters, mode: "push" | "replace") => { - const search = vaultFiltersToSearch(next); - const href = search ? `${pathname}?${search}` : pathname; - if (mode === "push") { - router.push(href, { scroll: false }); - } else { - router.replace(href, { scroll: false }); - } - }, - [pathname, router], - ); + // The parse/serialise round trip stays in `vault-utils.ts`; the router + // plumbing lives in the shared hook (which the measurements and mood + // lists reuse — this surface is the pattern's origin). + const { filters, applyFilters } = useUrlFilterSync({ + parse: parseVaultSearchParams, + serialise: vaultFiltersToSearch, + }); // Search draft debounces into the URL (200 ms); an external URL change // (back button, deep link) re-seeds the draft — render-phase derived-state diff --git a/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx b/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx index 1ab32caa9..7a453203f 100644 --- a/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx @@ -27,6 +27,7 @@ const baseMeasurements = [ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/measurements", + useSearchParams: () => new URLSearchParams(""), })); vi.mock("@tanstack/react-query", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx b/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx index c24a1c746..242b4d84a 100644 --- a/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx @@ -40,6 +40,7 @@ const baseMeasurements = [ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/measurements", + useSearchParams: () => new URLSearchParams(""), })); vi.mock("@tanstack/react-query", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-empty.test.tsx b/src/components/measurements/__tests__/measurement-list-empty.test.tsx index 5ee340e74..5a96a8d20 100644 --- a/src/components/measurements/__tests__/measurement-list-empty.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-empty.test.tsx @@ -20,6 +20,7 @@ import { renderToStaticMarkup } from "react-dom/server"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/measurements", + useSearchParams: () => new URLSearchParams(""), })); vi.mock("@tanstack/react-query", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx index b7615f19d..f7ec021bf 100644 --- a/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx @@ -35,6 +35,7 @@ const baseMeasurements = [ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/measurements", + useSearchParams: () => new URLSearchParams(""), })); vi.mock("@tanstack/react-query", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-filters.test.ts b/src/components/measurements/__tests__/measurement-list-filters.test.ts new file mode 100644 index 000000000..c68fc5bc4 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-filters.test.ts @@ -0,0 +1,81 @@ +/** + * URL round trip for the measurements list filters (`?type&source&from&to& + * min&max`) — the vault-utils contract applied to this surface: parsing is + * lenient (a hand-edited URL drops the invalid facet, never breaks the + * page), serialisation omits defaults, and parse(serialise(x)) is identity + * so deep links stay stable. + */ +import { describe, expect, it } from "vitest"; + +import { + measurementListFiltersToSearch, + numericInputOrUndefined, + parseMeasurementListSearchParams, + type MeasurementListFilters, +} from "../measurement-list-filters"; + +describe("parseMeasurementListSearchParams", () => { + it.each<[string, MeasurementListFilters]>([ + ["", {}], + ["type=WEIGHT", { type: "WEIGHT" }], + ["type=WEIGHT&source=WITHINGS", { type: "WEIGHT", source: "WITHINGS" }], + [ + "from=2026-05-01&to=2026-05-31", + { fromDay: "2026-05-01", toDay: "2026-05-31" }, + ], + ["min=60&max=90.5", { valueMin: "60", valueMax: "90.5" }], + [ + "type=PULSE&source=APPLE_HEALTH&from=2026-01-01&min=-5", + { + type: "PULSE", + source: "APPLE_HEALTH", + fromDay: "2026-01-01", + valueMin: "-5", + }, + ], + ])("parses %j", (search, expected) => { + expect( + parseMeasurementListSearchParams(new URLSearchParams(search)), + ).toEqual(expected); + }); + + it("drops facets a stale or hand-edited URL cannot honour", () => { + expect( + parseMeasurementListSearchParams( + new URLSearchParams( + "type=NOT_A_TYPE&source=NOT_A_SOURCE&from=2026-1-1&to=yesterday&min=abc&max=1e999", + ), + ), + ).toEqual({}); + // 1e999 is Infinity — not finite, so the bound is dropped too. + expect(numericInputOrUndefined("1e999")).toBeUndefined(); + }); +}); + +describe("measurementListFiltersToSearch", () => { + it("serialises the empty filter set to an empty string (bare URL)", () => { + expect(measurementListFiltersToSearch({})).toBe(""); + }); + + it.each([ + {}, + { type: "WEIGHT" }, + { type: "BLOOD_PRESSURE_SYS", source: "MANUAL" }, + { fromDay: "2026-05-01", toDay: "2026-05-31" }, + { valueMin: "60", valueMax: "90.5" }, + { + type: "PULSE", + source: "APPLE_HEALTH", + fromDay: "2026-01-01", + toDay: "2026-12-31", + valueMin: "40", + valueMax: "180", + }, + ])("round-trips %j", (filters) => { + expect( + parseMeasurementListSearchParams( + new URLSearchParams(measurementListFiltersToSearch(filters)), + ), + ).toEqual(filters); + }); +}); diff --git a/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx b/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx index d325646ed..098ca83f9 100644 --- a/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx @@ -11,6 +11,7 @@ import { renderToStaticMarkup } from "react-dom/server"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/insights/values/WEIGHT", + useSearchParams: () => new URLSearchParams(""), })); vi.mock("@tanstack/react-query", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx b/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx index 3eb426017..13ef32e92 100644 --- a/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx @@ -51,6 +51,7 @@ vi.mock("@tanstack/react-query", () => ({ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/measurements", + useSearchParams: () => new URLSearchParams(""), })); vi.mock("@/hooks/use-auth", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-url-sync.test.tsx b/src/components/measurements/__tests__/measurement-list-url-sync.test.tsx new file mode 100644 index 000000000..6f7545643 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-url-sync.test.tsx @@ -0,0 +1,220 @@ +/** + * URL-owned filter state on the measurements management list (the + * documents-vault pattern via `useUrlFilterSync`). Two directions, both + * pinned: + * + * - a filter change writes the serialised facet set to the page URL + * (push for the discrete selects, replace for the committed date + * bounds, bare pathname when everything clears); + * - mounting with a query string restores every facet — into the filter + * rail's controls AND into the list's TanStack query key, so the + * restored view refetches exactly what the shared link showed. + * + * SSR-only per project convention: the rail primitives are stubbed with + * prop-capturing markers, and the captured callbacks stand in for the + * user's click (post-render setState is a no-op under the server renderer; + * the router spies record the navigation the click causes). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const baseMeasurements = [ + { + id: "m-1", + type: "WEIGHT", + value: 81.5, + unit: "kg", + source: "MANUAL", + measuredAt: "2026-05-09T08:30:00.000Z", + notes: null, + }, +]; + +let mockSearch = ""; +const pushMock = vi.fn(); +const replaceMock = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock, replace: replaceMock }), + usePathname: () => "/measurements", + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +const recordedQueryKeys: unknown[] = []; +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { queryKey: unknown }) => { + recordedQueryKeys.push(options.queryKey); + return { + data: { measurements: baseMeasurements, meta: { total: 1 } }, + isLoading: false, + }; + }, + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "testuser", role: "USER" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +// Prop-capturing stubs for the rail primitives (render order in the list: +// date range, type, source, value range). +interface SelectProps { + label: string; + value: string; + onValueChange: (value: string) => void; +} +interface RangeProps { + from?: string; + to?: string; + min?: string; + max?: string; + onFromChange?: (value: string) => void; + onToChange?: (value: string) => void; + onMinChange?: (value: string) => void; + onMaxChange?: (value: string) => void; +} +let selects: SelectProps[] = []; +let barProps: { isFiltered: boolean; onReset: () => void } | null = null; +let dateRange: RangeProps | null = null; +let valueRange: RangeProps | null = null; +vi.mock("@/components/ui/filter-bar", () => ({ + FilterBar: (props: { + isFiltered: boolean; + onReset: () => void; + children?: unknown; + }) => { + barProps = { isFiltered: props.isFiltered, onReset: props.onReset }; + return
{props.children as never}
; + }, + FilterBarSelect: (props: SelectProps) => { + selects.push(props); + return ; + }, + FilterBarDateRange: (props: RangeProps) => { + dateRange = props; + return ; + }, + FilterBarNumberRange: (props: RangeProps) => { + valueRange = props; + return ; + }, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { MeasurementList } from "../measurement-list"; + +function render(search: string) { + mockSearch = search; + selects = []; + barProps = null; + dateRange = null; + valueRange = null; + recordedQueryKeys.length = 0; + return renderToStaticMarkup( + + + , + ); +} + +const typeSelect = () => selects[0]!; +const sourceSelect = () => selects[1]!; + +beforeEach(() => { + pushMock.mockClear(); + replaceMock.mockClear(); +}); + +describe("MeasurementList — filter changes write the URL", () => { + it("pushes the type facet", () => { + render(""); + typeSelect().onValueChange("WEIGHT"); + expect(pushMock).toHaveBeenCalledWith("/measurements?type=WEIGHT", { + scroll: false, + }); + }); + + it("pushes the source facet on top of the active type", () => { + render("type=WEIGHT"); + sourceSelect().onValueChange("WITHINGS"); + expect(pushMock).toHaveBeenCalledWith( + "/measurements?type=WEIGHT&source=WITHINGS", + { scroll: false }, + ); + }); + + it("replaces (no history entry) for a committed date bound", () => { + render(""); + dateRange!.onFromChange!("2026-05-01"); + expect(replaceMock).toHaveBeenCalledWith("/measurements?from=2026-05-01", { + scroll: false, + }); + expect(pushMock).not.toHaveBeenCalled(); + }); + + it("clearing the last facet restores the bare pathname", () => { + render("type=WEIGHT"); + typeSelect().onValueChange("ALL"); + expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false }); + }); + + it("reset clears every facet in one push", () => { + render("type=WEIGHT&source=WITHINGS&from=2026-05-01&min=60"); + expect(barProps!.isFiltered).toBe(true); + barProps!.onReset(); + expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false }); + }); +}); + +describe("MeasurementList — mounting with a query string restores the filters", () => { + it("seeds every rail control from the URL", () => { + render( + "type=WEIGHT&source=WITHINGS&from=2026-05-01&to=2026-05-31&min=60&max=90", + ); + expect(typeSelect().value).toBe("WEIGHT"); + expect(sourceSelect().value).toBe("WITHINGS"); + expect(dateRange!.from).toBe("2026-05-01"); + expect(dateRange!.to).toBe("2026-05-31"); + expect(valueRange!.min).toBe("60"); + expect(valueRange!.max).toBe("90"); + expect(barProps!.isFiltered).toBe(true); + }); + + it("keys the list query off the restored facets (TZ-pinned day bounds)", () => { + render( + "type=WEIGHT&source=WITHINGS&from=2026-05-01&to=2026-05-31&min=60&max=90", + ); + // Suite TZ is pinned to UTC, so the local day bounds are deterministic. + expect(recordedQueryKeys).toContainEqual( + queryKeys.measurementsList({ + type: "WEIGHT", + sourceEq: "WITHINGS", + from: "2026-05-01T00:00:00.000Z", + to: "2026-05-31T23:59:59.999Z", + valueMin: 60, + valueMax: 90, + page: 1, + sortBy: "measuredAt", + sortDir: "desc", + mode: "raw", + }), + ); + }); + + it("drops an invalid facet instead of breaking the page", () => { + render("type=NOT_A_TYPE&source=WITHINGS"); + expect(typeSelect().value).toBe("ALL"); + expect(sourceSelect().value).toBe("WITHINGS"); + }); +}); diff --git a/src/components/measurements/measurement-list-filters.ts b/src/components/measurements/measurement-list-filters.ts new file mode 100644 index 000000000..d8e546779 --- /dev/null +++ b/src/components/measurements/measurement-list-filters.ts @@ -0,0 +1,98 @@ +/** + * Pure URL-facet helpers for the measurements management list, following the + * documents-vault pattern (`vault-utils.ts`): the list's filter state lives in + * the page URL (`?type&source&from&to&min&max`) so a filtered view is + * deep-linkable and survives navigation, reload and back/forward. Parsing is + * lenient — a hand-edited or stale URL drops the invalid facet rather than + * breaking the page — and serialisation omits defaults so the unfiltered list + * keeps a bare URL. Kept out of the client component so the round trip stays + * trivially unit-testable without a render harness. + */ +import { measurementSourceEnum } from "@/lib/validations/measurement"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "./measurement-list-meta"; + +export interface MeasurementListFilters { + /** Canonical `MeasurementType`; absent = all types. */ + type?: string; + /** `MeasurementSource` enum value; absent = all sources. */ + source?: string; + /** Inclusive day bounds, `YYYY-MM-DD` (the date inputs' committed values). */ + fromDay?: string; + toDay?: string; + /** Validated numeric input strings for the value-range pill. */ + valueMin?: string; + valueMax?: string; +} + +const SOURCE_SET = new Set(measurementSourceEnum.options); +const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function dayOrUndefined(value: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed && DAY_PATTERN.test(trimmed) ? trimmed : undefined; +} + +/** + * A value-range bound survives the URL only as a finite number the list's + * `valueMin`/`valueMax` query params would accept; anything else (including + * a mid-typing fragment someone pasted) is dropped, not 422'd. + */ +export function numericInputOrUndefined( + value: string | null | undefined, +): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || trimmed.length > 20) return undefined; + return Number.isFinite(Number(trimmed)) ? trimmed : undefined; +} + +/** + * Parse the list's URL search params (`?type&source&from&to&min&max`) into + * the filter object. Unknown types/sources are dropped (a stale deep link + * never traps the user on a broken filter — same contract the old + * `?type=` seeding had), malformed days and non-numeric bounds likewise. + */ +export function parseMeasurementListSearchParams( + params: URLSearchParams, +): MeasurementListFilters { + const filters: MeasurementListFilters = {}; + + const type = params.get("type")?.trim(); + if (type && type in MEASUREMENT_TYPE_LABEL_KEYS) filters.type = type; + + const source = params.get("source")?.trim(); + if (source && SOURCE_SET.has(source)) filters.source = source; + + const fromDay = dayOrUndefined(params.get("from")); + if (fromDay) filters.fromDay = fromDay; + + const toDay = dayOrUndefined(params.get("to")); + if (toDay) filters.toDay = toDay; + + const valueMin = numericInputOrUndefined(params.get("min")); + if (valueMin) filters.valueMin = valueMin; + + const valueMax = numericInputOrUndefined(params.get("max")); + if (valueMax) filters.valueMax = valueMax; + + return filters; +} + +/** + * Serialise the filter object back into the page URL's search string (no + * leading `?`; empty string for the default view). Inverse of + * `parseMeasurementListSearchParams` — round-tripping is pinned by test so + * the `?type=` deep links from the Vorsorge card and the type-badge + * drill-down stay stable. + */ +export function measurementListFiltersToSearch( + filters: MeasurementListFilters, +): string { + const sp = new URLSearchParams(); + if (filters.type) sp.set("type", filters.type); + if (filters.source) sp.set("source", filters.source); + if (filters.fromDay) sp.set("from", filters.fromDay); + if (filters.toDay) sp.set("to", filters.toDay); + if (filters.valueMin) sp.set("min", filters.valueMin); + if (filters.valueMax) sp.set("max", filters.valueMax); + return sp.toString(); +} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 8b7b25a69..455dd9e69 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -60,8 +60,9 @@ import { ChevronDown, MoreHorizontal, } from "lucide-react"; -import { Fragment, useCallback, useId, useState } from "react"; +import { Fragment, useCallback, useEffect, useId, useState } from "react"; import { useTableSort } from "@/hooks/use-table-sort"; +import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { formatDateOrRelative, formatDateTime } from "@/lib/format"; @@ -97,6 +98,11 @@ import { MEASUREMENT_TYPE_ICONS as TYPE_ICONS, MEASUREMENT_TYPE_COLORS as TYPE_COLORS, } from "./measurement-list-meta"; +import { + measurementListFiltersToSearch, + numericInputOrUndefined, + parseMeasurementListSearchParams, +} from "./measurement-list-filters"; import { ApiError, apiDelete, @@ -190,13 +196,6 @@ interface MeasurementListProps { * scoped to this type; the count caption still shows. */ lockedType?: string; - /** - * v1.18.7 (Wave E) — seed the (still user-editable) type filter from a - * `?type=` deep link, e.g. the Vorsorge card's "Show - * measurements" action. Unlike `lockedType` the full filter rail stays - * visible so the user can widen or change the filter afterwards. - */ - initialType?: string; } const PAGE_SIZE = 25; @@ -281,7 +280,6 @@ export function MeasurementList({ onEdit, onAddFirst, lockedType, - initialType, }: MeasurementListProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -342,22 +340,45 @@ export function MeasurementList({ : `/measurements?type=${encodeURIComponent(type)}`, ); }; - // v1.8.5 — when `lockedType` is set the list is pinned to that metric - // and the type selector is hidden; the filter state seeds from it. - // v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the - // same filter but keeps the rail interactive so the user can change it. - const [typeFilter, setTypeFilterRaw] = useState( - lockedType ?? initialType ?? "ALL", - ); + // URL-owned filter state (the documents-vault pattern): type, source, date + // range and value range parse from `?type&source&from&to&min&max` and every + // change writes back through the router, so a filtered view survives + // navigation, reload and back/forward. The `?type=` deep links (Vorsorge + // card, type-badge drill-down) now simply ARE the filter state. + // v1.8.5 — when `lockedType` is set the list is pinned to that metric, the + // type selector is hidden, and the URL belongs to the insights subpage — + // the remaining filters stay in component memory (`syncToUrl: false`). + const { filters, applyFilters } = useUrlFilterSync({ + parse: parseMeasurementListSearchParams, + serialise: measurementListFiltersToSearch, + syncToUrl: lockedType === undefined, + }); + const typeFilter = lockedType ?? filters.type ?? "ALL"; // v1.15.13 — management-list source filter + optional date range. // `ALL` clears the source filter; empty date strings clear the bound. - const [sourceFilter, setSourceFilterRaw] = useState("ALL"); - const [fromDay, setFromDayRaw] = useState(""); - const [toDay, setToDayRaw] = useState(""); - // v1.18.5 — value-range filter (backlog G). Raw input strings; empty - // means the bound is unset (open-ended on that side). - const [valueMinInput, setValueMinInputRaw] = useState(""); - const [valueMaxInput, setValueMaxInputRaw] = useState(""); + const sourceFilter = filters.source ?? "ALL"; + const fromDay = filters.fromDay ?? ""; + const toDay = filters.toDay ?? ""; + // v1.18.5 — value-range filter (backlog G). The inputs fire per keystroke, + // so they edit a local draft that debounces into the URL (200 ms, replace — + // never a history entry per keystroke); an external URL change (back + // button, deep link) re-seeds the draft via the render-phase derived-state + // adjustment below, exactly like the vault's search field. + const urlValueRange = `${filters.valueMin ?? ""}${filters.valueMax ?? ""}`; + const [valueDraft, setValueDraft] = useState(() => ({ + min: filters.valueMin ?? "", + max: filters.valueMax ?? "", + })); + const [lastUrlValueRange, setLastUrlValueRange] = useState(urlValueRange); + if (urlValueRange !== lastUrlValueRange) { + setLastUrlValueRange(urlValueRange); + setValueDraft({ + min: filters.valueMin ?? "", + max: filters.valueMax ?? "", + }); + } + const valueMinInput = valueDraft.min; + const valueMaxInput = valueDraft.max; const [page, setPage] = useState(1); // Shared column-sort state (measuredAt opens descending). const { @@ -417,42 +438,63 @@ export function MeasurementList({ // are about to unmount). const clearSelection = () => setSelectedIds(new Set()); + // Discrete facet changes push (Back steps through filter states, like the + // vault's chips); the committed date bounds replace — a from/to pair set in + // one sitting should not cost two Back presses. const setTypeFilter = (value: string) => { - setTypeFilterRaw(value); + applyFilters({ ...filters, type: value === "ALL" ? undefined : value }); setPage(1); clearSelection(); }; const setSourceFilter = (value: string) => { - setSourceFilterRaw(value); + applyFilters({ ...filters, source: value === "ALL" ? undefined : value }); setPage(1); clearSelection(); }; const setFromDay = (value: string) => { - setFromDayRaw(value); + applyFilters({ ...filters, fromDay: value || undefined }, "replace"); setPage(1); clearSelection(); }; const setToDay = (value: string) => { - setToDayRaw(value); + applyFilters({ ...filters, toDay: value || undefined }, "replace"); setPage(1); clearSelection(); }; const setValueMinInput = (value: string) => { - setValueMinInputRaw(value); + setValueDraft((prev) => ({ ...prev, min: value })); setPage(1); clearSelection(); }; const setValueMaxInput = (value: string) => { - setValueMaxInputRaw(value); + setValueDraft((prev) => ({ ...prev, max: value })); setPage(1); clearSelection(); }; + // The value-range draft debounces into the URL; a bound that does not + // normalise to a finite number never reaches it (matching the previous + // in-memory behaviour, where non-numeric input meant "unset"). + useEffect(() => { + const handle = setTimeout(() => { + const min = numericInputOrUndefined(valueDraft.min); + const max = numericInputOrUndefined(valueDraft.max); + if ( + (min ?? "") === (filters.valueMin ?? "") && + (max ?? "") === (filters.valueMax ?? "") + ) { + return; + } + applyFilters({ ...filters, valueMin: min, valueMax: max }, "replace"); + }, 200); + return () => clearTimeout(handle); + }, [valueDraft, filters, applyFilters]); + const goToPage = (updater: (p: number) => number) => { setPage(updater); clearSelection(); @@ -494,13 +536,9 @@ export function MeasurementList({ // TIME-ASLEEP), where filtering on a raw `value` column carries no // meaning, so the range pill is hidden for those types. const valueMin = - valueMinInput.trim() !== "" && Number.isFinite(Number(valueMinInput)) - ? Number(valueMinInput) - : undefined; + filters.valueMin !== undefined ? Number(filters.valueMin) : undefined; const valueMax = - valueMaxInput.trim() !== "" && Number.isFinite(Number(valueMaxInput)) - ? Number(valueMaxInput) - : undefined; + filters.valueMax !== undefined ? Number(filters.valueMax) : undefined; const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter ? "sleep-night" : isCumulativeFilter @@ -874,12 +912,14 @@ export function MeasurementList({ (valueMinInput !== "" || valueMaxInput !== "")) } onReset={() => { - setTypeFilter("ALL"); - setSourceFilter("ALL"); - setFromDay(""); - setToDay(""); - setValueMinInput(""); - setValueMaxInput(""); + // One push clears every facet (and so one Back restores them); + // the value-range draft is cleared directly because a bound + // that never committed to the URL has no change to re-seed + // from. + applyFilters({}); + setValueDraft({ min: "", max: "" }); + setPage(1); + clearSelection(); }} count={ data?.meta?.total !== undefined diff --git a/src/components/mood/__tests__/mood-list-filter-select.test.tsx b/src/components/mood/__tests__/mood-list-filter-select.test.tsx index 745fbf998..51ea0a8f0 100644 --- a/src/components/mood/__tests__/mood-list-filter-select.test.tsx +++ b/src/components/mood/__tests__/mood-list-filter-select.test.tsx @@ -35,6 +35,7 @@ const baseEntries = [ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/mood", + useSearchParams: () => new URLSearchParams(""), })); vi.mock("@tanstack/react-query", () => ({ diff --git a/src/components/mood/__tests__/mood-list-filters.test.ts b/src/components/mood/__tests__/mood-list-filters.test.ts new file mode 100644 index 000000000..e86d54ad8 --- /dev/null +++ b/src/components/mood/__tests__/mood-list-filters.test.ts @@ -0,0 +1,68 @@ +/** + * URL round trip for the mood list filters (`?mood&source&from&to`) — the + * vault-utils contract applied to this surface: parsing is lenient (a + * hand-edited URL drops the invalid facet, never breaks the page), + * serialisation omits defaults, and parse(serialise(x)) is identity so deep + * links stay stable. + */ +import { describe, expect, it } from "vitest"; + +import { + moodListFiltersToSearch, + parseMoodListSearchParams, + type MoodListFilters, +} from "../mood-list-filters"; + +describe("parseMoodListSearchParams", () => { + it.each<[string, MoodListFilters]>([ + ["", {}], + ["mood=GUT", { mood: "GUT" }], + ["mood=LAUSIG&source=TELEGRAM", { mood: "LAUSIG", source: "TELEGRAM" }], + [ + "from=2026-05-01&to=2026-05-31", + { fromDay: "2026-05-01", toDay: "2026-05-31" }, + ], + // MOODLOG is legacy provenance — rows keep their label, so the filter + // must keep resolving it. + ["source=MOODLOG", { source: "MOODLOG" }], + ])("parses %j", (search, expected) => { + expect(parseMoodListSearchParams(new URLSearchParams(search))).toEqual( + expected, + ); + }); + + it("drops facets a stale or hand-edited URL cannot honour", () => { + expect( + parseMoodListSearchParams( + new URLSearchParams( + "mood=ECSTATIC&source=NOT_A_SOURCE&from=05-01-2026&to=yesterday", + ), + ), + ).toEqual({}); + }); +}); + +describe("moodListFiltersToSearch", () => { + it("serialises the empty filter set to an empty string (bare URL)", () => { + expect(moodListFiltersToSearch({})).toBe(""); + }); + + it.each([ + {}, + { mood: "SUPER_GUT" }, + { mood: "OKAY", source: "WEB" }, + { fromDay: "2026-05-01", toDay: "2026-05-31" }, + { + mood: "SCHLECHT", + source: "DAYLIO", + fromDay: "2026-01-01", + toDay: "2026-12-31", + }, + ])("round-trips %j", (filters) => { + expect( + parseMoodListSearchParams( + new URLSearchParams(moodListFiltersToSearch(filters)), + ), + ).toEqual(filters); + }); +}); diff --git a/src/components/mood/__tests__/mood-list-url-sync.test.tsx b/src/components/mood/__tests__/mood-list-url-sync.test.tsx new file mode 100644 index 000000000..5717de85c --- /dev/null +++ b/src/components/mood/__tests__/mood-list-url-sync.test.tsx @@ -0,0 +1,194 @@ +/** + * URL-owned filter state on the mood management list (the documents-vault + * pattern via `useUrlFilterSync`), mirroring the measurements-list guard: + * a filter change writes the serialised facet set to the page URL, and + * mounting with a query string restores every facet into the rail's + * controls and the list's TanStack query key. + * + * SSR-only per project convention: the rail primitives are stubbed with + * prop-capturing markers, and the captured callbacks stand in for the + * user's click (post-render setState is a no-op under the server renderer; + * the router spies record the navigation the click causes). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const baseEntries = [ + { + id: "e-1", + date: "2026-05-09", + mood: "GUT", + score: 4, + tags: [], + tagKeys: [], + note: null, + source: "MANUAL", + moodLoggedAt: "2026-05-09T20:00:00.000Z", + }, +]; + +let mockSearch = ""; +const pushMock = vi.fn(); +const replaceMock = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock, replace: replaceMock }), + usePathname: () => "/mood", + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +const recordedQueryKeys: unknown[] = []; +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { queryKey: unknown }) => { + recordedQueryKeys.push(options.queryKey); + return { + data: { entries: baseEntries, meta: { total: 1 } }, + isLoading: false, + }; + }, + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "testuser", role: "USER" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +// Prop-capturing stubs for the rail primitives (render order in the list: +// date range, mood, source). +interface SelectProps { + label: string; + value: string; + onValueChange: (value: string) => void; +} +interface RangeProps { + from: string; + to: string; + onFromChange: (value: string) => void; + onToChange: (value: string) => void; +} +let selects: SelectProps[] = []; +let barProps: { isFiltered: boolean; onReset: () => void } | null = null; +let dateRange: RangeProps | null = null; +vi.mock("@/components/ui/filter-bar", () => ({ + FilterBar: (props: { + isFiltered: boolean; + onReset: () => void; + children?: unknown; + }) => { + barProps = { isFiltered: props.isFiltered, onReset: props.onReset }; + return
{props.children as never}
; + }, + FilterBarSelect: (props: SelectProps) => { + selects.push(props); + return ; + }, + FilterBarDateRange: (props: RangeProps) => { + dateRange = props; + return ; + }, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { MoodList } from "../mood-list"; + +function render(search: string) { + mockSearch = search; + selects = []; + barProps = null; + dateRange = null; + recordedQueryKeys.length = 0; + return renderToStaticMarkup( + + + , + ); +} + +const moodSelect = () => selects[0]!; +const sourceSelect = () => selects[1]!; + +beforeEach(() => { + pushMock.mockClear(); + replaceMock.mockClear(); +}); + +describe("MoodList — filter changes write the URL", () => { + it("pushes the mood facet", () => { + render(""); + moodSelect().onValueChange("GUT"); + expect(pushMock).toHaveBeenCalledWith("/mood?mood=GUT", { scroll: false }); + }); + + it("pushes the source facet on top of the active mood", () => { + render("mood=GUT"); + sourceSelect().onValueChange("TELEGRAM"); + expect(pushMock).toHaveBeenCalledWith("/mood?mood=GUT&source=TELEGRAM", { + scroll: false, + }); + }); + + it("replaces (no history entry) for a committed date bound", () => { + render(""); + dateRange!.onToChange("2026-05-31"); + expect(replaceMock).toHaveBeenCalledWith("/mood?to=2026-05-31", { + scroll: false, + }); + expect(pushMock).not.toHaveBeenCalled(); + }); + + it("clearing the last facet restores the bare pathname", () => { + render("mood=GUT"); + moodSelect().onValueChange("ALL"); + expect(pushMock).toHaveBeenCalledWith("/mood", { scroll: false }); + }); + + it("reset clears every facet in one push", () => { + render("mood=GUT&source=TELEGRAM&from=2026-05-01"); + expect(barProps!.isFiltered).toBe(true); + barProps!.onReset(); + expect(pushMock).toHaveBeenCalledWith("/mood", { scroll: false }); + }); +}); + +describe("MoodList — mounting with a query string restores the filters", () => { + it("seeds every rail control from the URL", () => { + render("mood=OKAY&source=MOODLOG&from=2026-05-01&to=2026-05-31"); + expect(moodSelect().value).toBe("OKAY"); + expect(sourceSelect().value).toBe("MOODLOG"); + expect(dateRange!.from).toBe("2026-05-01"); + expect(dateRange!.to).toBe("2026-05-31"); + expect(barProps!.isFiltered).toBe(true); + }); + + it("keys the list query off the restored facets", () => { + render("mood=OKAY&source=MOODLOG&from=2026-05-01&to=2026-05-31"); + expect(recordedQueryKeys).toContainEqual( + queryKeys.moodEntriesList({ + mood: "OKAY", + source: "MOODLOG", + from: "2026-05-01", + to: "2026-05-31", + page: 1, + sortBy: "moodLoggedAt", + sortDir: "desc", + }), + ); + }); + + it("drops an invalid facet instead of breaking the page", () => { + render("mood=ECSTATIC&source=WEB"); + expect(moodSelect().value).toBe("ALL"); + expect(sourceSelect().value).toBe("WEB"); + }); +}); diff --git a/src/components/mood/__tests__/mood-note-fulltext.test.tsx b/src/components/mood/__tests__/mood-note-fulltext.test.tsx index 34ab6ee8f..bd350ff46 100644 --- a/src/components/mood/__tests__/mood-note-fulltext.test.tsx +++ b/src/components/mood/__tests__/mood-note-fulltext.test.tsx @@ -46,6 +46,7 @@ const baseEntries = [ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/mood", + useSearchParams: () => new URLSearchParams(""), })); vi.mock("@tanstack/react-query", () => ({ diff --git a/src/components/mood/mood-list-filters.ts b/src/components/mood/mood-list-filters.ts new file mode 100644 index 000000000..f0d4509aa --- /dev/null +++ b/src/components/mood/mood-list-filters.ts @@ -0,0 +1,69 @@ +/** + * Pure URL-facet helpers for the mood management list, following the + * documents-vault pattern (`vault-utils.ts`): the list's filter state lives + * in the page URL (`?mood&source&from&to`) so a filtered view is + * deep-linkable and survives navigation, reload and back/forward. Parsing is + * lenient — a hand-edited or stale URL drops the invalid facet rather than + * breaking the page — and serialisation omits defaults so the unfiltered + * list keeps a bare URL. Kept out of the client component so the round trip + * stays trivially unit-testable without a render harness. + */ +import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood"; + +export interface MoodListFilters { + /** `MoodLevel` enum value; absent = all moods. */ + mood?: string; + /** `MoodSource` enum value; absent = all sources. */ + source?: string; + /** Inclusive day bounds, `YYYY-MM-DD` (the date inputs' committed values). */ + fromDay?: string; + toDay?: string; +} + +const MOOD_SET = new Set(moodLevelEnum.options); +const SOURCE_SET = new Set(moodSourceEnum.options); +const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function dayOrUndefined(value: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed && DAY_PATTERN.test(trimmed) ? trimmed : undefined; +} + +/** + * Parse the list's URL search params (`?mood&source&from&to`) into the + * filter object. Unknown moods/sources and malformed days are dropped, so a + * stale deep link never traps the user on a broken filter. + */ +export function parseMoodListSearchParams( + params: URLSearchParams, +): MoodListFilters { + const filters: MoodListFilters = {}; + + const mood = params.get("mood")?.trim(); + if (mood && MOOD_SET.has(mood)) filters.mood = mood; + + const source = params.get("source")?.trim(); + if (source && SOURCE_SET.has(source)) filters.source = source; + + const fromDay = dayOrUndefined(params.get("from")); + if (fromDay) filters.fromDay = fromDay; + + const toDay = dayOrUndefined(params.get("to")); + if (toDay) filters.toDay = toDay; + + return filters; +} + +/** + * Serialise the filter object back into the page URL's search string (no + * leading `?`; empty string for the default view). Inverse of + * `parseMoodListSearchParams`; round-tripping is pinned by test. + */ +export function moodListFiltersToSearch(filters: MoodListFilters): string { + const sp = new URLSearchParams(); + if (filters.mood) sp.set("mood", filters.mood); + if (filters.source) sp.set("source", filters.source); + if (filters.fromDay) sp.set("from", filters.fromDay); + if (filters.toDay) sp.set("to", filters.toDay); + return sp.toString(); +} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index a950b2fe8..b9bbefd4a 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -59,6 +59,11 @@ import { import { useCallback, useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTableSort } from "@/hooks/use-table-sort"; +import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; +import { + moodListFiltersToSearch, + parseMoodListSearchParams, +} from "./mood-list-filters"; // Columns that open descending when first selected. The logged-at column // reads newest-first by default; every other column opens ascending. @@ -174,11 +179,19 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { // list stays readable and every control that would change it is absent. const { canManage } = useRecordCapabilities(); const queryClient = useQueryClient(); - const [moodFilter, setMoodFilterRaw] = useState("ALL"); + // URL-owned filter state (the documents-vault pattern): mood, source and + // date range parse from `?mood&source&from&to` and every change writes back + // through the router, so a filtered view survives navigation, reload and + // back/forward. // v1.15.13 — management-list source filter + optional date range. - const [sourceFilter, setSourceFilterRaw] = useState("ALL"); - const [fromDay, setFromDayRaw] = useState(""); - const [toDay, setToDayRaw] = useState(""); + const { filters, applyFilters } = useUrlFilterSync({ + parse: parseMoodListSearchParams, + serialise: moodListFiltersToSearch, + }); + const moodFilter = filters.mood ?? "ALL"; + const sourceFilter = filters.source ?? "ALL"; + const fromDay = filters.fromDay ?? ""; + const toDay = filters.toDay ?? ""; const [page, setPage] = useState(1); // Shared column-sort state (moodLoggedAt opens descending). const { @@ -268,26 +281,29 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { // clears the page-scoped selection. const clearSelection = () => setSelectedIds(new Set()); + // Discrete facet changes push (Back steps through filter states, like the + // vault's chips); the committed date bounds replace — a from/to pair set in + // one sitting should not cost two Back presses. const setMoodFilter = (value: string) => { - setMoodFilterRaw(value); + applyFilters({ ...filters, mood: value === "ALL" ? undefined : value }); setPage(1); clearSelection(); }; const setSourceFilter = (value: string) => { - setSourceFilterRaw(value); + applyFilters({ ...filters, source: value === "ALL" ? undefined : value }); setPage(1); clearSelection(); }; const setFromDay = (value: string) => { - setFromDayRaw(value); + applyFilters({ ...filters, fromDay: value || undefined }, "replace"); setPage(1); clearSelection(); }; const setToDay = (value: string) => { - setToDayRaw(value); + applyFilters({ ...filters, toDay: value || undefined }, "replace"); setPage(1); clearSelection(); }; @@ -624,10 +640,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { toDay !== "" } onReset={() => { - setMoodFilter("ALL"); - setSourceFilter("ALL"); - setFromDay(""); - setToDay(""); + // One push clears every facet, so one Back restores them. + applyFilters({}); + setPage(1); + clearSelection(); }} count={ data?.meta?.total !== undefined diff --git a/src/hooks/__tests__/use-url-filter-sync.test.tsx b/src/hooks/__tests__/use-url-filter-sync.test.tsx new file mode 100644 index 000000000..36aac48c7 --- /dev/null +++ b/src/hooks/__tests__/use-url-filter-sync.test.tsx @@ -0,0 +1,109 @@ +/** + * The shared URL-owned-filter hook (`use-url-filter-sync.ts`) — the + * documents-vault pattern extracted for the measurements and mood lists. + * Pinned here: mount parses the live query string, a filter change writes + * the serialised form back through the router (push by default, replace on + * request), an empty filter set restores the bare pathname, and + * `syncToUrl: false` detaches the state from the URL entirely. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +let mockSearch = ""; +const pushMock = vi.fn(); +const replaceMock = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock, replace: replaceMock }), + usePathname: () => "/measurements", + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +import { useUrlFilterSync } from "../use-url-filter-sync"; + +interface ProbeFilters { + type?: string; + source?: string; +} + +function parse(params: URLSearchParams): ProbeFilters { + const filters: ProbeFilters = {}; + const type = params.get("type"); + if (type) filters.type = type; + const source = params.get("source"); + if (source) filters.source = source; + return filters; +} + +function serialise(filters: ProbeFilters): string { + const sp = new URLSearchParams(); + if (filters.type) sp.set("type", filters.type); + if (filters.source) sp.set("source", filters.source); + return sp.toString(); +} + +interface ProbeResult { + filters: ProbeFilters; + applyFilters: (next: ProbeFilters, mode?: "push" | "replace") => void; +} + +function mount(search: string, syncToUrl?: boolean): ProbeResult { + mockSearch = search; + // Ref-shaped capture cell: the render-scoped assignment happens inside a + // closure the control-flow analysis cannot see through. + const captured: { current: ProbeResult | null } = { current: null }; + function Probe() { + captured.current = useUrlFilterSync({ + parse, + serialise, + syncToUrl, + }); + return null; + } + renderToStaticMarkup(); + if (!captured.current) throw new Error("probe did not render"); + return captured.current; +} + +beforeEach(() => { + pushMock.mockClear(); + replaceMock.mockClear(); +}); + +describe("useUrlFilterSync", () => { + it("parses the mounted query string into the filter object", () => { + const { filters } = mount("type=WEIGHT&source=WITHINGS"); + expect(filters).toEqual({ type: "WEIGHT", source: "WITHINGS" }); + }); + + it("writes a filter change to the URL via push by default", () => { + const { applyFilters } = mount(""); + applyFilters({ type: "WEIGHT" }); + expect(pushMock).toHaveBeenCalledWith("/measurements?type=WEIGHT", { + scroll: false, + }); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("writes via replace when asked (high-frequency input)", () => { + const { applyFilters } = mount(""); + applyFilters({ source: "MANUAL" }, "replace"); + expect(replaceMock).toHaveBeenCalledWith("/measurements?source=MANUAL", { + scroll: false, + }); + expect(pushMock).not.toHaveBeenCalled(); + }); + + it("restores the bare pathname when every facet clears", () => { + const { applyFilters } = mount("type=WEIGHT"); + applyFilters({}); + expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false }); + }); + + it("syncToUrl: false ignores the URL and never navigates", () => { + const { filters, applyFilters } = mount("type=WEIGHT", false); + expect(filters).toEqual({}); + applyFilters({ type: "PULSE" }); + expect(pushMock).not.toHaveBeenCalled(); + expect(replaceMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-url-filter-sync.ts b/src/hooks/use-url-filter-sync.ts new file mode 100644 index 000000000..0050182ca --- /dev/null +++ b/src/hooks/use-url-filter-sync.ts @@ -0,0 +1,72 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useMemo, useState } from "react"; + +/** + * URL-owned filter state — the documents-vault pattern + * (`src/components/documents/documents-view.tsx`), extracted so the + * measurements and mood management lists carry the same contract: the page's + * filter object is parsed from the live query string and every change writes + * the serialised form back through the router, so a filtered view survives + * navigation, reload, back/forward and sharing. + * + * `parse` and `serialise` must be inverse, module-level functions (stable + * identities): round-tripping is what keeps a deep link and the UI agreeing, + * and `serialise` omits defaults so the unfiltered view keeps a bare URL. + * + * Mode grammar (matching the vault): a discrete facet change (`push`, the + * default) creates a history entry so Back steps through filter states, like + * the vault's kind/year chips; high-frequency typed input passes `replace` so + * the history never fills with keystrokes, like the vault's debounced search. + * + * `syncToUrl: false` keeps the identical contract in component memory only — + * for a list embedded under a URL that belongs to another surface (the + * type-locked insights readings subpage). + */ +export function useUrlFilterSync({ + parse, + serialise, + syncToUrl = true, +}: { + parse: (params: URLSearchParams) => T; + serialise: (filters: T) => string; + syncToUrl?: boolean; +}): { + filters: T; + applyFilters: (next: T, mode?: "push" | "replace") => void; +} { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const urlFilters = useMemo( + () => parse(new URLSearchParams(searchParams.toString())), + [parse, searchParams], + ); + + // Memory-only fallback for `syncToUrl: false`. The hook always mounts both + // sources (hooks are unconditional); only one is read. + const [localFilters, setLocalFilters] = useState(() => + parse(new URLSearchParams()), + ); + + const applyFilters = useCallback( + (next: T, mode: "push" | "replace" = "push") => { + if (!syncToUrl) { + setLocalFilters(next); + return; + } + const search = serialise(next); + const href = search ? `${pathname}?${search}` : pathname; + if (mode === "push") { + router.push(href, { scroll: false }); + } else { + router.replace(href, { scroll: false }); + } + }, + [syncToUrl, serialise, pathname, router], + ); + + return { filters: syncToUrl ? urlFilters : localFilters, applyFilters }; +} From 7bf8dbd8af25134ce5e3a4c43132abeaa81dc7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:34:43 +0200 Subject: [PATCH 06/20] Give custom-metric values the same second chance as every other entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs A3-11. Deleting a logged custom-metric value was the one hard delete left on an entry surface — everywhere else a deletion tombstones and the toast offers Undo. The DELETE verb now stamps deletedAt, every read (list, latest-value, counts, revive check) filters the tombstone, and the new entries/restore endpoint clears it scoped to owner and parent metric — a foreign, live or mismatched id is a silent no-op, never an existence leak. The history table's delete toast carries the Undo action. --- docs/api/openapi.yaml | 102 ++++++++++- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- .../[id]/entries/[entryId]/route.ts | 27 ++- .../__tests__/soft-delete-restore.test.ts | 173 ++++++++++++++++++ .../[id]/entries/restore/route.ts | 79 ++++++++ .../api/custom-metrics/[id]/entries/route.ts | 2 +- src/app/api/custom-metrics/route.ts | 7 +- .../custom-metric-history-list.tsx | 29 ++- src/lib/openapi/routes/custom-metrics.ts | 35 +++- 14 files changed, 467 insertions(+), 23 deletions(-) create mode 100644 src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts create mode 100644 src/app/api/custom-metrics/[id]/entries/restore/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 64f302d78..a3fbda2be 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13972,7 +13972,8 @@ paths: tags: - Custom metrics summary: Delete a logged value - description: Hard-deletes the value. Audits as `customMetricEntry.delete`. + description: Tombstones the value (soft delete, v1.37.20); the sibling entries/restore endpoint clears the tombstone. + Audits as `customMetricEntry.delete`. parameters: - in: path name: id @@ -13995,6 +13996,36 @@ paths: "404": *a25 "422": *a3 "429": *a4 + /api/custom-metrics/{id}/entries/restore: + post: + tags: + - Custom metrics + summary: Restore tombstoned values (v1.37.20) + description: Clears deletedAt on every owned, currently-tombstoned entry of this metric named in ids (1..200). A + foreign, live or mismatched-parent id is a silent no-op, never an existence leak. Backs the delete toast's Undo. + Audits as `customMetricEntry.restore`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RestoreCustomMetricEntriesRequest" + responses: + "200": + description: Count of entries restored. + content: + application/json: + schema: + $ref: "#/components/schemas/RestoreCustomMetricEntriesResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/onboarding/disclaimer: post: tags: @@ -18483,6 +18514,18 @@ components: - de required: - message + RestoreCustomMetricEntriesRequest: + type: object + properties: + ids: + minItems: 1 + maxItems: 200 + type: array + items: + type: string + minLength: 1 + required: + - ids DisclaimerAckRequest: type: object properties: @@ -39690,7 +39733,8 @@ components: additionalProperties: false description: One completed screener administration. `severityBand` is a descriptive label for the screen (never a diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis - resources when it is. Raw per-item answers are intentionally absent. + resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read + returns them decrypted. CreateMentalHealthAssessmentEnvelope: type: object properties: @@ -41414,6 +41458,31 @@ components: - data - error additionalProperties: false + RestoreCustomMetricEntriesResponse: + type: object + properties: + data: + type: object + properties: + restored: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - restored + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false DisclaimerAckEnvelope: type: object properties: @@ -41803,6 +41872,12 @@ components: type: array items: $ref: "#/components/schemas/MetricFreshnessEntry" + syncProgress: + description: Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from + the PATCH echo. + anyOf: + - $ref: "#/components/schemas/AppleHealthSyncProgress" + - type: "null" required: - entries - lastSyncedAt @@ -41886,6 +41961,29 @@ components: additionalProperties: false description: Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row. + AppleHealthSyncProgress: + type: object + properties: + recordsAccepted: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Measurement + workout rows carrying the `APPLE_HEALTH` source (live batch ingest and the one-shot export + import both write it). Soft-deleted measurement rows are excluded. + oldestMeasuredAt: + anyOf: + - 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)))$ + - type: "null" + description: Earliest instant reached across those rows (`measuredAt` for measurements, `startedAt` for workouts); null + when nothing has arrived. During a first-run backfill this walks backwards as history lands. + required: + - recordsAccepted + - oldestMeasuredAt + additionalProperties: false + description: "Server-side Apple Health backfill progress: how many rows have been accepted and how far back in time they + reach. Only what the server actually knows — no ETA, no device-side queue state." HealthKitConfigPatchEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 604e98166..2c836d87a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9053,7 +9053,9 @@ "flaggedBadge": "Hilfe angezeigt", "chartTitle": "Punkteverlauf", "totalLabel": "Punkte", - "bandLabel": "Einstufung" + "bandLabel": "Einstufung", + "answersToggle": "Antworten", + "answersUnavailable": "Die gespeicherten Antworten dieses Check-ins können nicht entschlüsselt werden." }, "crisis": { "title": "Du musst das nicht allein durchstehen", @@ -9708,6 +9710,8 @@ "deleteConfirmTitle": "Diese eigene Metrik löschen?", "deleteConfirmDescription": "Die Metrik wird aus deiner Liste entfernt. Erfasste Werte bleiben erhalten.", "deletedToast": "Eigene Metrik gelöscht", + "restoredToast": "Wert wiederhergestellt", + "restoreError": "Wiederherstellen fehlgeschlagen", "deleteError": "Eigene Metrik konnte nicht gelöscht werden", "showAllValues": "Alle Werte anzeigen", "emptyDescription": "Definiere eigene Messwerte, um sie zu verfolgen und darzustellen.", diff --git a/messages/en.json b/messages/en.json index 846315bde..3e30da5f2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9053,7 +9053,9 @@ "flaggedBadge": "Support shown", "chartTitle": "Score trend", "totalLabel": "Score", - "bandLabel": "Category" + "bandLabel": "Category", + "answersToggle": "Answers", + "answersUnavailable": "The stored answers for this check-in cannot be decrypted." }, "crisis": { "title": "You don't have to face this alone", @@ -9708,6 +9710,8 @@ "deleteConfirmTitle": "Delete this custom metric?", "deleteConfirmDescription": "The metric is removed from your list. Its logged values are kept.", "deletedToast": "Custom metric deleted", + "restoredToast": "Value restored", + "restoreError": "Restore failed", "deleteError": "Could not delete the custom metric", "showAllValues": "Show all values", "emptyDescription": "Define your own measurements to track and chart them.", diff --git a/messages/es.json b/messages/es.json index ba5f61255..72134f305 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9053,7 +9053,9 @@ "flaggedBadge": "Apoyo mostrado", "chartTitle": "Evolución de la puntuación", "totalLabel": "Puntuación", - "bandLabel": "Categoría" + "bandLabel": "Categoría", + "answersToggle": "Respuestas", + "answersUnavailable": "Las respuestas guardadas de esta revisión no se pueden descifrar." }, "crisis": { "title": "No tienes que afrontar esto a solas", @@ -9708,6 +9710,8 @@ "deleteConfirmTitle": "¿Eliminar esta métrica personalizada?", "deleteConfirmDescription": "La métrica se quita de tu lista. Los valores registrados se conservan.", "deletedToast": "Métrica personalizada eliminada", + "restoredToast": "Valor restaurado", + "restoreError": "No se pudo restaurar", "deleteError": "No se pudo eliminar la métrica personalizada", "showAllValues": "Ver todos los valores", "emptyDescription": "Define tus propias mediciones para registrarlas y representarlas.", diff --git a/messages/fr.json b/messages/fr.json index 2035de369..ce9f99f5e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9053,7 +9053,9 @@ "flaggedBadge": "Aide affichée", "chartTitle": "Évolution du score", "totalLabel": "Score", - "bandLabel": "Catégorie" + "bandLabel": "Catégorie", + "answersToggle": "Réponses", + "answersUnavailable": "Les réponses enregistrées de ce bilan ne peuvent pas être déchiffrées." }, "crisis": { "title": "Tu n'as pas à affronter cela seul(e)", @@ -9708,6 +9710,8 @@ "deleteConfirmTitle": "Supprimer cette mesure personnalisée ?", "deleteConfirmDescription": "La mesure est retirée de votre liste. Les valeurs enregistrées sont conservées.", "deletedToast": "Mesure personnalisée supprimée", + "restoredToast": "Valeur restaurée", + "restoreError": "Échec de la restauration", "deleteError": "Impossible de supprimer la mesure personnalisée", "showAllValues": "Afficher toutes les valeurs", "emptyDescription": "Définissez vos propres mesures pour les suivre et les représenter.", diff --git a/messages/it.json b/messages/it.json index a3ffb3c95..f24b77f37 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9053,7 +9053,9 @@ "flaggedBadge": "Supporto mostrato", "chartTitle": "Andamento del punteggio", "totalLabel": "Punteggio", - "bandLabel": "Categoria" + "bandLabel": "Categoria", + "answersToggle": "Risposte", + "answersUnavailable": "Le risposte salvate di questo check-in non possono essere decifrate." }, "crisis": { "title": "Non devi affrontare tutto questo da solo/a", @@ -9708,6 +9710,8 @@ "deleteConfirmTitle": "Eliminare questa metrica personalizzata?", "deleteConfirmDescription": "La metrica viene rimossa dal tuo elenco. I valori registrati vengono mantenuti.", "deletedToast": "Metrica personalizzata eliminata", + "restoredToast": "Valore ripristinato", + "restoreError": "Ripristino non riuscito", "deleteError": "Impossibile eliminare la metrica personalizzata", "showAllValues": "Mostra tutti i valori", "emptyDescription": "Definisci le tue misurazioni per monitorarle e rappresentarle.", diff --git a/messages/pl.json b/messages/pl.json index 5d494ecd6..cbc72c276 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9053,7 +9053,9 @@ "flaggedBadge": "Pokazano wsparcie", "chartTitle": "Przebieg wyniku", "totalLabel": "Wynik", - "bandLabel": "Kategoria" + "bandLabel": "Kategoria", + "answersToggle": "Odpowiedzi", + "answersUnavailable": "Nie można odszyfrować zapisanych odpowiedzi tego sprawdzenia." }, "crisis": { "title": "Nie musisz mierzyć się z tym sam(a)", @@ -9708,6 +9710,8 @@ "deleteConfirmTitle": "Usunąć tę metrykę własną?", "deleteConfirmDescription": "Metryka zostanie usunięta z listy. Zapisane wartości pozostaną.", "deletedToast": "Usunięto metrykę własną", + "restoredToast": "Przywrócono wartość", + "restoreError": "Nie udało się przywrócić", "deleteError": "Nie udało się usunąć metryki własnej", "showAllValues": "Pokaż wszystkie wartości", "emptyDescription": "Zdefiniuj własne pomiary, aby je śledzić i przedstawiać na wykresie.", diff --git a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts index 84c60839d..aaaa0b7ff 100644 --- a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts @@ -22,8 +22,11 @@ import { updateCustomMetricEntrySchema } from "@/lib/validations/custom-metrics" * * PATCH applies a partial edit (`data` built field-by-field; an explicit `null` * on `note` clears it, an omitted key leaves the column untouched). DELETE - * hard-deletes the value. Every read/write is narrowed by both `userId` and the - * parent `customMetricId`; a cross-user or mismatched-parent id surfaces as 404. + * tombstones the value (v1.37.20, A3-11 — undo parity with every peer entry + * surface; the sibling `restore` route clears the tombstone for the toast's + * Undo). Every read/write is narrowed by both `userId` and the parent + * `customMetricId`; a cross-user, tombstoned or mismatched-parent id surfaces + * as 404. */ type RouteParams = { params: Promise<{ id: string; entryId: string }> }; @@ -34,7 +37,12 @@ export const PATCH = apiHandler( const { id, entryId } = await params; const existing = await prisma.customMetricEntry.findFirst({ - where: { id: entryId, userId: user.id, customMetricId: id }, + where: { + id: entryId, + userId: user.id, + customMetricId: id, + deletedAt: null, + }, }); if (!existing) { return apiError("Custom metric entry not found", 404); @@ -104,13 +112,22 @@ export const DELETE = apiHandler( const { id, entryId } = await params; const existing = await prisma.customMetricEntry.findFirst({ - where: { id: entryId, userId: user.id, customMetricId: id }, + where: { + id: entryId, + userId: user.id, + customMetricId: id, + deletedAt: null, + }, }); if (!existing) { return apiError("Custom metric entry not found", 404); } - await prisma.customMetricEntry.delete({ where: { id: entryId } }); + // Tombstone, not a hard delete: the Undo toast's restore clears it. + await prisma.customMetricEntry.update({ + where: { id: entryId }, + data: { deletedAt: new Date() }, + }); await auditLog("customMetricEntry.delete", { userId: user.id, diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts new file mode 100644 index 000000000..735060c17 --- /dev/null +++ b/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts @@ -0,0 +1,173 @@ +/** + * v1.37.20 (A3-11) — custom-metric entries move from hard delete to the + * tombstone + Undo contract every peer entry surface carries. + * + * Watched red: before the conversion the DELETE verb called + * `customMetricEntry.delete` and no restore route existed — the first two + * suites below fail against that state (no `update` with a `deletedAt` + * stamp; module not found), which is the proof the assertions bite. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + customMetricEntry: { + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + delete: vi.fn(), + }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserCorrelationPatterns: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { DELETE } from "../[entryId]/route"; +import { POST as RESTORE } from "../restore/route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, +}; + +const ENTRY = { + id: "e-1", + userId: "user-1", + customMetricId: "cm-1", + value: 42, + unit: "kg", + measuredAt: new Date("2026-06-10T00:00:00.000Z"), + note: null, + createdAt: new Date("2026-06-10T00:00:00.000Z"), + deletedAt: null, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("DELETE /api/custom-metrics/[id]/entries/[entryId]", () => { + it("tombstones instead of hard-deleting", async () => { + vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue( + ENTRY as never, + ); + vi.mocked(prisma.customMetricEntry.update).mockResolvedValue({} as never); + + const res = await DELETE( + new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", { + method: "DELETE", + }), + { params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) }, + ); + + expect(res.status).toBe(200); + expect(prisma.customMetricEntry.delete).not.toHaveBeenCalled(); + const call = vi.mocked(prisma.customMetricEntry.update).mock.calls[0][0]; + expect(call.where).toEqual({ id: "e-1" }); + expect(call.data.deletedAt).toBeInstanceOf(Date); + }); + + it("404s an already-tombstoned entry (the lookup filters the tombstone)", async () => { + vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(null); + + const res = await DELETE( + new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", { + method: "DELETE", + }), + { params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) }, + ); + + expect(res.status).toBe(404); + const where = vi.mocked(prisma.customMetricEntry.findFirst).mock.calls[0][0] + .where; + expect(where.deletedAt).toBeNull(); + expect(prisma.customMetricEntry.update).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/custom-metrics/[id]/entries/restore", () => { + function restoreRequest(body: unknown): NextRequest { + return new NextRequest( + "http://localhost/api/custom-metrics/cm-1/entries/restore", + { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); + } + + it("clears the tombstone scoped to owner + parent metric", async () => { + vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({ + count: 1, + } as never); + + const res = await RESTORE(restoreRequest({ ids: ["e-1"] }), { + params: Promise.resolve({ id: "cm-1" }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ restored: 1 }); + const call = vi.mocked(prisma.customMetricEntry.updateMany).mock + .calls[0][0]; + expect(call.where).toEqual({ + id: { in: ["e-1"] }, + userId: "user-1", + customMetricId: "cm-1", + deletedAt: { not: null }, + }); + expect(call.data).toEqual({ deletedAt: null }); + }); + + it("answers restored: 0 for foreign / live ids — a silent no-op, not a leak", async () => { + vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({ + count: 0, + } as never); + + const res = await RESTORE(restoreRequest({ ids: ["not-mine"] }), { + params: Promise.resolve({ id: "cm-1" }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ restored: 0 }); + }); + + it("422s an empty id list", async () => { + const res = await RESTORE(restoreRequest({ ids: [] }), { + params: Promise.resolve({ id: "cm-1" }), + }); + expect(res.status).toBe(422); + expect(prisma.customMetricEntry.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/custom-metrics/[id]/entries/restore/route.ts b/src/app/api/custom-metrics/[id]/entries/restore/route.ts new file mode 100644 index 000000000..1ff0bea2e --- /dev/null +++ b/src/app/api/custom-metrics/[id]/entries/restore/route.ts @@ -0,0 +1,79 @@ +/** + * `POST /api/custom-metrics/{id}/entries/restore` — un-tombstone for the + * entry delete-Undo affordance (v1.37.20, A3-11). + * + * Body: `{ ids: string[] }` — 1..200 entry ids, scoped to the caller and the + * parent metric. + * + * Clears `deletedAt` on every owned, currently-tombstoned row of THIS metric + * in one `updateMany`, so the value re-surfaces in normal reads. A forged / + * foreign / live / mismatched-parent id is a silent no-op (never a 404 + * existence leak): the mutation `where` pins `userId` and `customMetricId`, + * so it only ever touches the caller's rows. Mirrors `/api/labs/restore`. + * Same session auth as the delete beside it — custom-metric entry writes are + * not delegable. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +const MAX_IDS_PER_BATCH = 200; + +const restoreSchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH), +}); + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = restoreSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const { count } = await prisma.customMetricEntry.updateMany({ + where: { + id: { in: parsed.data.ids }, + userId: user.id, + customMetricId: id, + deletedAt: { not: null }, + }, + data: { deletedAt: null }, + }); + + if (count > 0) invalidateUserCorrelationPatterns(user.id); + + await auditLog("customMetricEntry.restore", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customMetricId: id, restored: count }, + }); + + annotate({ + action: { name: "custom-metric.entry.restore" }, + meta: { customMetricId: id, restored: count }, + }); + + return apiSuccess({ restored: count }); + }, +); diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 2defc447f..71a54f7e0 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -57,7 +57,7 @@ export const GET = apiHandler( } const { limit, offset, sortDir } = parsed.data; - const where = { userId: user.id, customMetricId: id }; + const where = { userId: user.id, customMetricId: id, deletedAt: null }; const [rows, total] = await Promise.all([ prisma.customMetricEntry.findMany({ diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index e3c0d948d..d4d98b477 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -36,11 +36,12 @@ export const GET = apiHandler(async () => { orderBy: { name: "asc" }, include: { entries: { + where: { deletedAt: null }, orderBy: { measuredAt: "desc" }, take: 1, select: { value: true, unit: true, measuredAt: true }, }, - _count: { select: { entries: true } }, + _count: { select: { entries: { where: { deletedAt: null } } } }, }, }); @@ -164,12 +165,12 @@ async function postCustomMetric(request: NextRequest) { if (existing) { const [latestEntry, count] = await Promise.all([ prisma.customMetricEntry.findFirst({ - where: { userId: user.id, customMetricId: created.id }, + where: { userId: user.id, customMetricId: created.id, deletedAt: null }, orderBy: { measuredAt: "desc" }, select: { value: true, unit: true, measuredAt: true }, }), prisma.customMetricEntry.count({ - where: { userId: user.id, customMetricId: created.id }, + where: { userId: user.id, customMetricId: created.id, deletedAt: null }, }), ]); latest = latestEntry ?? null; diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx index b3ffd4f94..ee09485b1 100644 --- a/src/components/custom-metrics/custom-metric-history-list.tsx +++ b/src/components/custom-metrics/custom-metric-history-list.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { useTableSort } from "@/hooks/use-table-sort"; -import { ApiError, apiDelete, apiPatch } from "@/lib/api/api-fetch"; +import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { formatDateShort } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -48,8 +48,8 @@ function parseDecimal(raw: string): number | null { /** * v1.25.5 — reverse-chronological value history for one custom metric, with - * full edit + delete. An edit overwrites in place; delete hard-deletes (the - * value store has no soft-delete tier). + * full edit + delete. An edit overwrites in place; delete tombstones with an + * Undo toast (v1.37.20, A3-11 — parity with every other entry surface). */ export function CustomMetricHistoryList({ customMetricId, @@ -87,12 +87,31 @@ export function CustomMetricHistoryList({ }); } + // v1.37.20 (A3-11) — undo parity with every peer entry surface: the + // delete tombstones server-side and the toast offers the restore. + async function restoreEntry(id: string) { + try { + await apiPost(`/api/custom-metrics/${customMetricId}/entries/restore`, { + ids: [id], + }); + invalidate(); + toast.success(t("customMetrics.entry.restoredToast")); + } catch { + toast.error(t("customMetrics.entry.restoreError")); + } + } + const deleteMutation = useMutation({ mutationFn: (id: string) => apiDelete(`/api/custom-metrics/${customMetricId}/entries/${id}`), - onSuccess: () => { + onSuccess: (_data, id) => { invalidate(); - toast.success(t("customMetrics.entry.deletedToast")); + toast.success(t("customMetrics.entry.deletedToast"), { + action: { + label: t("common.undo"), + onClick: () => void restoreEntry(id), + }, + }); }, onError: () => toast.error(t("customMetrics.entry.deleteError")), }); diff --git a/src/lib/openapi/routes/custom-metrics.ts b/src/lib/openapi/routes/custom-metrics.ts index ae6d2183b..a150d98f8 100644 --- a/src/lib/openapi/routes/custom-metrics.ts +++ b/src/lib/openapi/routes/custom-metrics.ts @@ -340,7 +340,7 @@ export const customMetricPaths: NonNullable = { tags: ["Custom metrics"], summary: "Delete a logged value", description: - "Hard-deletes the value. Audits as `customMetricEntry.delete`.", + "Tombstones the value (soft delete, v1.37.20); the sibling entries/restore endpoint clears the tombstone. Audits as `customMetricEntry.delete`.", requestParams: { path: z.object({ id: z.string(), entryId: z.string() }), }, @@ -361,4 +361,37 @@ export const customMetricPaths: NonNullable = { }, }, }, + "/api/custom-metrics/{id}/entries/restore": { + post: { + tags: ["Custom metrics"], + summary: "Restore tombstoned values (v1.37.20)", + description: + "Clears deletedAt on every owned, currently-tombstoned entry of this metric named in ids (1..200). A foreign, live or mismatched-parent id is a silent no-op, never an existence leak. Backs the delete toast's Undo. Audits as `customMetricEntry.restore`.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { + schema: z + .object({ ids: z.array(z.string().min(1)).min(1).max(200) }) + .meta({ id: "RestoreCustomMetricEntriesRequest" }), + }, + }, + }, + responses: { + "200": { + description: "Count of entries restored.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ restored: z.number().int() }), + "RestoreCustomMetricEntriesResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; From a1ca0dc911aa192720b69561c5464c7e3552638c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:34:54 +0200 Subject: [PATCH 07/20] Let a German search find an English letter, and the other way round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document search does whole-token equality over a blind index, so 'Blutdruck' never found the discharge letter that says 'blood pressure' and 'hypertension' missed every German Arztbrief. A curated German/English medical synonym list (vitals, common labs, imaging, specialties, document words — static, no vendor names) now expands the QUERY tokens before they are hashed: the stored index, its tokenizer version and the schema are untouched, the expansion happens ahead of the one-way hash, and recall simply widens. Typed tokens always survive the expansion cap. --- .../__tests__/search-synonyms.test.ts | 89 +++++++++++++ src/lib/documents/content-index.ts | 8 +- src/lib/documents/search-synonyms.ts | 118 ++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 src/lib/documents/__tests__/search-synonyms.test.ts create mode 100644 src/lib/documents/search-synonyms.ts diff --git a/src/lib/documents/__tests__/search-synonyms.test.ts b/src/lib/documents/__tests__/search-synonyms.test.ts new file mode 100644 index 000000000..c99efb1d8 --- /dev/null +++ b/src/lib/documents/__tests__/search-synonyms.test.ts @@ -0,0 +1,89 @@ +/** + * v1.37.20 — the query-time synonym expansion behind document search. + * + * Watched red: the cross-register assertion below ("blutdruck" query hash + * set overlaps a "blood pressure" document's token set) fails against the + * pre-expansion `hashQueryTokens`, which hashed only the typed tokens. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/crypto", () => ({ + // Deterministic fake subkey — the HMAC itself is irrelevant here; only + // that query and index hashing agree. + deriveSubkey: vi.fn(() => Buffer.alloc(32, 7)), +})); + +import { + expandQueryTokens, + MAX_EXPANDED_QUERY_TOKENS, + SEARCH_SYNONYM_GROUPS, +} from "../search-synonyms"; +import { hashQueryTokens, tokenise, tokeniseAndHash } from "../content-index"; + +describe("the curated groups", () => { + it("are all in tokeniser normal form (lowercase, no diacritics, length-gated)", () => { + for (const group of SEARCH_SYNONYM_GROUPS) { + for (const token of group) { + // Round-tripping through the real tokeniser must return the token + // unchanged — otherwise it could never match an indexed tag. + expect(tokenise(token), token).toEqual([token]); + } + } + }); + + it("hold at least two members each (a group of one expands nothing)", () => { + for (const group of SEARCH_SYNONYM_GROUPS) { + expect(group.length).toBeGreaterThanOrEqual(2); + } + }); +}); + +describe("expandQueryTokens", () => { + it("keeps the typed tokens first and appends the group members", () => { + const out = expandQueryTokens(["blutdruck"]); + expect(out[0]).toBe("blutdruck"); + expect(out).toContain("blood"); + expect(out).toContain("pressure"); + expect(out).toContain("hypertension"); + }); + + it("passes an unknown token through untouched", () => { + expect(expandQueryTokens(["zeruwatvz"])).toEqual(["zeruwatvz"]); + }); + + it("caps the expanded set without ever trimming typed tokens", () => { + const typed = SEARCH_SYNONYM_GROUPS.slice(0, 24).map((g) => g[0]); + const out = expandQueryTokens(typed); + expect(out.length).toBeLessThanOrEqual(MAX_EXPANDED_QUERY_TOKENS); + for (const token of typed) expect(out).toContain(token); + }); +}); + +describe("hashQueryTokens with expansion", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lets a German query match an English document (cross-register recall)", () => { + const documentTags = tokeniseAndHash( + "Blood pressure readings were stable throughout the stay", + ); + const queryTags = hashQueryTokens("Blutdruck"); + expect(queryTags.some((tag) => documentTags.includes(tag))).toBe(true); + }); + + it("lets an English query match a German letter", () => { + const documentTags = tokeniseAndHash( + "Der Blutdruck war im Verlauf unauffaellig", + ); + const queryTags = hashQueryTokens("hypertension"); + expect(queryTags.some((tag) => documentTags.includes(tag))).toBe(true); + }); + + it("still matches the typed word itself, expansion or not", () => { + const documentTags = tokeniseAndHash("Koloskopie ohne Befund"); + const queryTags = hashQueryTokens("Koloskopie"); + expect(queryTags.some((tag) => documentTags.includes(tag))).toBe(true); + }); +}); diff --git a/src/lib/documents/content-index.ts b/src/lib/documents/content-index.ts index b71ca769b..4b88616f3 100644 --- a/src/lib/documents/content-index.ts +++ b/src/lib/documents/content-index.ts @@ -27,6 +27,7 @@ import { createHmac } from "node:crypto"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { deriveSubkey } from "@/lib/crypto"; import { prisma } from "@/lib/db"; +import { expandQueryTokens } from "@/lib/documents/search-synonyms"; /** * Tokeniser algorithm version. Bump when the normalisation / tokenisation rules @@ -249,7 +250,12 @@ export function tokeniseAndHash(text: string): string[] { */ export function hashQueryTokens(query: string): string[] { const subkey = indexSubkey(); - const tokens = tokenise(query).slice(0, MAX_QUERY_TOKENS); + // v1.37.20 — expand through the curated German/English medical synonym + // groups (`search-synonyms.ts`) BEFORE hashing, so "Blutdruck" also + // matches a letter that says "blood pressure". Query-time only: the + // stored index and the tokenizer version are untouched, and the expansion + // happens before the one-way hash, so nothing readable moves anywhere new. + const tokens = expandQueryTokens(tokenise(query).slice(0, MAX_QUERY_TOKENS)); const hashes = new Set(); for (const token of tokens) hashes.add(hashToken(token, subkey)); return [...hashes]; diff --git a/src/lib/documents/search-synonyms.ts b/src/lib/documents/search-synonyms.ts new file mode 100644 index 000000000..a9b142e3b --- /dev/null +++ b/src/lib/documents/search-synonyms.ts @@ -0,0 +1,118 @@ +/** + * v1.37.20 — German/English medical synonym groups for document search. + * + * The blind token index (see `content-index.ts`) does whole-token equality + * only, so a search for "Blutdruck" never found the letter that says "blood + * pressure" and a search for "Zucker" missed "Glukose". This module closes + * that gap at QUERY time: the query's tokens are expanded with their group + * members before hashing, so the index itself never changes — no re-index, + * no tokenizer-version bump, no schema movement. Recall widens; the match + * still runs over the same opaque hashes. + * + * Deliberately a static curated list. Each group is a set of interchangeable + * search terms, stored in the SAME normal form the tokeniser produces: + * lowercase, diacritics stripped ("röntgen" → "rontgen"), single tokens of + * three characters or more (shorter ones never reach the index). A + * multi-word clinical phrase appears as its individual content-bearing + * tokens; array-overlap matching means a partial hit still surfaces the + * document, which is the right trade for a search box. + * + * Curation rule: common terms a German- or English-speaking self-hoster + * would type against a letter written in the other register (lay term ↔ + * clinical term, German ↔ English). No vendor names, no drug brand names. + */ + +/** Interchangeable search-term groups, in tokeniser normal form. */ +export const SEARCH_SYNONYM_GROUPS: ReadonlyArray> = [ + // Vitals + ["blutdruck", "blood", "pressure", "hypertonie", "hypertension"], + ["puls", "pulse", "herzfrequenz", "heart", "rate"], + ["gewicht", "weight", "korpergewicht", "body"], + ["blutzucker", "zucker", "glukose", "glucose", "sugar", "diabetes"], + ["sauerstoffsattigung", "sauerstoff", "oxygen", "saturation", "spo2"], + ["temperatur", "temperature", "fieber", "fever"], + // Common labs + ["blutbild", "hamogramm", "hemogram", "cbc", "blutwerte", "laborwerte"], + ["cholesterin", "cholesterol", "ldl", "hdl", "blutfette", "lipide", "lipids"], + ["hba1c", "langzeitzucker", "glykohamoglobin"], + ["schilddruse", "thyroid", "tsh"], + ["leber", "liver", "leberwerte", "got", "gpt", "transaminasen"], + ["niere", "kidney", "renal", "kreatinin", "creatinine", "gfr"], + ["eisen", "iron", "ferritin", "hamoglobin", "hemoglobin", "anamie", "anemia"], + ["vitamin", "vitamine"], + ["entzundung", "inflammation", "crp"], + ["harnsaure", "urat", "urate", "gicht", "gout"], + // Imaging / procedures + ["rontgen", "xray", "radiograph"], + ["ultraschall", "sonographie", "sonografie", "ultrasound"], + ["mrt", "mri", "kernspintomographie", "kernspin"], + ["computertomographie", "tomographie"], + ["ekg", "ecg", "elektrokardiogramm", "electrocardiogram"], + ["darmspiegelung", "koloskopie", "colonoscopy"], + ["magenspiegelung", "gastroskopie", "gastroscopy"], + // Specialties / encounter words + ["impfung", "impfpass", "vaccination", "vaccine", "immunisierung"], + ["hausarzt", "allgemeinmedizin", "practitioner"], + ["kardiologie", "kardiologe", "cardiology", "herz", "cardiac"], + ["dermatologie", "hautarzt", "dermatology", "haut", "skin"], + ["orthopadie", "orthopade", "orthopedics", "orthopaedics"], + ["augenarzt", "ophthalmologie", "ophthalmology", "auge", "eye"], + ["zahnarzt", "dental", "dentist", "zahn", "tooth"], + ["gynakologie", "frauenarzt", "gynecology", "gynaecology"], + ["urologie", "urologe", "urology"], + // Documents / admin + ["arztbrief", "befund", "bericht", "letter", "report", "findings"], + ["uberweisung", "referral"], + ["rezept", "verordnung", "prescription"], + ["entlassung", "entlassungsbrief", "discharge"], + ["operation", "surgery", "eingriff"], + ["allergie", "allergy", "unvertraglichkeit", "intolerance"], + ["medikament", "medikation", "medication", "arznei"], + ["schwangerschaft", "pregnancy"], + ["schlaf", "sleep", "apnoe", "apnea"], +]; + +/** Cap on the expanded token set — bounds the hash array the query matches. */ +export const MAX_EXPANDED_QUERY_TOKENS = 64; + +// Token → its group members, built once at module load. +const SYNONYMS_BY_TOKEN: ReadonlyMap> = (() => { + const map = new Map(); + for (const group of SEARCH_SYNONYM_GROUPS) { + for (const token of group) { + const existing = map.get(token); + if (existing) { + // A token may sit in more than one group ("blood" rides both the + // pressure and the count groups); merge, keep unique. + for (const member of group) { + if (!existing.includes(member)) existing.push(member); + } + } else { + map.set(token, [...group]); + } + } + } + return map; +})(); + +/** + * Expand normalised query tokens with their synonym-group members. The + * original tokens always survive (and lead the list, so the cap trims + * expansions before it ever trims what the user actually typed). + */ +export function expandQueryTokens(tokens: readonly string[]): string[] { + const out: string[] = []; + const seen = new Set(); + const push = (token: string) => { + if (seen.has(token) || out.length >= MAX_EXPANDED_QUERY_TOKENS) return; + seen.add(token); + out.push(token); + }; + for (const token of tokens) push(token); + for (const token of tokens) { + const members = SYNONYMS_BY_TOKEN.get(token); + if (!members) continue; + for (const member of members) push(member); + } + return out; +} From b1be67bfa795d4c17784230f6a52a5ca5f032611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:36:03 +0200 Subject: [PATCH 08/20] Carry the reminder schedule and its ledger through backup and restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reminder cadences were the oldest entry on the coverage-pending register: carried in name, restored never — an account came back silent until every reminder was rebuilt by hand. Both models are two-ended now, in the house shape (both ends in one file, reminders-backup.ts): every column travels, nextDueAt verbatim rather than recomputed, the completion ledger beside it, tombstones in disaster recovery only, and a portable export omits a tombstoned reminder together with its ledger rows. Encounter.reminderId and VaccinationRecord.reminderId stop being dropped on restore: both remap against the restored reminders and fall to NULL with a skip report only when the file genuinely lacks the row. The wipe plan names the ledger child-before-parent; the classification guard, completeness guards and the integration round trip all demand the new coverage (proven failing with the section removed). Refs #223 --- .../backup-plan-classification.test.ts | 14 + .../[id]/restore/__tests__/round-trip.test.ts | 4 + .../api/admin/backups/[id]/restore/route.ts | 44 +- .../export/__tests__/per-type-routes.test.ts | 8 + .../export/encrypted/__tests__/route.test.ts | 4 + src/lib/data-wipe/wipe-plan.ts | 4 + .../__tests__/full-backup-payload.test.ts | 4 + src/lib/export/backup-plan.ts | 34 +- src/lib/export/full-backup-payload.ts | 20 +- src/lib/export/reminders-backup.ts | 394 ++++++++++++++++++ src/lib/export/restore-skips.ts | 23 +- src/lib/export/vaccinations-backup.ts | 57 ++- src/lib/export/visits-backup.ts | 63 ++- src/lib/validations/backup.ts | 78 +++- tests/integration/backup-round-trip.test.ts | 199 ++++++++- 15 files changed, 864 insertions(+), 86 deletions(-) create mode 100644 src/lib/export/reminders-backup.ts diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 00811393c..fca11a857 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -462,6 +462,20 @@ describe("every backed-up model travels both ways, or is named as debt", () => { ).toEqual([]); }); + it("proves the reminder engine models are on the covered side", () => { + // v1.37.20 (#223 / iOS #68) — the reminder came off the debt register and + // the completion ledger landed carried from the release that introduced + // it. Pinned by name so a future edit cannot quietly move either back to + // COVERAGE_PENDING: the ledger is the only copy of every satisfy and skip + // before the latest one, so "pending" would be a silent history loss, not + // a deferral. + for (const model of ["MeasurementReminder", "MeasurementReminderEvent"]) { + expect(BACKED_UP_MODELS).toContain(model); + expect(TWO_ENDED_MODELS).toContain(model); + expect(COVERAGE_PENDING).not.toHaveProperty(model); + } + }); + it("proves the structured-profile models are on the covered side", () => { // These account-owned rows are exported and restored explicitly rather // than being treated as reconstructible data. diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 4d42c8519..9d6921831 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -292,6 +292,10 @@ function sourceClient() { encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 13976d771..20506c963 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -50,6 +50,7 @@ import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { restoreVisitsData } from "@/lib/export/visits-backup"; import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; +import { restoreRemindersData } from "@/lib/export/reminders-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -95,6 +96,8 @@ interface RestoreResponse { encounterLinks: number; vaccinations: number; vaccinationLinks: number; + measurementReminders: number; + measurementReminderEvents: number; }; } @@ -1329,12 +1332,28 @@ const handler = apiHandler( }); } - // Visits, the address book and the three link tables. Last on - // purpose: a link is written only when both of its ends exist, and - // the documents above are the final far side to land. Both ends of - // this section live in `src/lib/export/visits-backup.ts`, so a grep - // of THIS file alone will not find them — the same delegation as the - // cycle, profile, intraday and score restores above. + // The Vorsorge reminders and their completion ledger (v1.37.20, + // #223 / iOS #68), BEFORE the visits and the vaccinations: both of + // those remap a `reminderId` against the reminders in the database, + // so the reminders have to be in the database first or every + // appointment and booster reference would read as unresolvable and + // drop. Both ends of this section live in + // `src/lib/export/reminders-backup.ts`. + const remindersCleared = await restoreRemindersData( + tx, + ownerId, + payload, + skips, + ); + + // Visits, the address book and the three link tables. After the + // documents and the reminders on purpose: a link is written only + // when both of its ends exist, and the encounter's reminder + // reference remaps against the rows the branch above has just + // written. Both ends of this section live in + // `src/lib/export/visits-backup.ts`, so a grep of THIS file alone + // will not find them — the same delegation as the cycle, profile, + // intraday and score restores above. const visitsCleared = await restoreVisitsData( tx, ownerId, @@ -1343,10 +1362,10 @@ const handler = apiHandler( ); // The immunization log, after the visits: a dose remaps its - // practitioner and its encounter against rows the branch above has - // just written, and its document link needs the documents that - // landed before that. Both ends of this section live in - // `src/lib/export/vaccinations-backup.ts`. + // practitioner, its encounter and its booster reminder against rows + // the branches above have just written, and its document link needs + // the documents that landed before that. Both ends of this section + // live in `src/lib/export/vaccinations-backup.ts`. const vaccinationsCleared = await restoreVaccinationsData( tx, ownerId, @@ -1385,6 +1404,9 @@ const handler = apiHandler( encounterLinks: visitsCleared.encounterLinks, vaccinations: vaccinationsCleared.vaccinations, vaccinationLinks: vaccinationsCleared.vaccinationLinks, + measurementReminders: remindersCleared.measurementReminders, + measurementReminderEvents: + remindersCleared.measurementReminderEvents, }; return { cleared, skipped: summarizeRestoreSkips(skips) }; }, @@ -1537,6 +1559,8 @@ const handler = apiHandler( encounterLinks: summary.encounterLinks, vaccinations: summary.vaccinations, vaccinationLinks: summary.vaccinationLinks, + measurementReminders: summary.measurementReminders, + measurementReminderEvents: summary.measurementReminderEvents, }, }, }); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 2237b5c1f..dbd70b192 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -38,6 +38,10 @@ vi.mock("@/lib/db", () => ({ encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -121,6 +125,10 @@ beforeEach(() => { vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurementReminderEvent.findMany).mockResolvedValue( + [] as never, + ); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 47fc52e16..e124d475f 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -36,6 +36,10 @@ vi.mock("@/lib/db", () => ({ encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 522296d04..311b68487 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -60,6 +60,10 @@ export const WIPE_MODELS = [ // the account was doing, day by day, after a confirmation that said // otherwise. "HealthScoreRecord", + // The completion ledger before the reminder it hangs off: it cascades either + // way, but the child deleted first is what keeps its own count truthful on + // the audit row (v1.37.20, #223 / iOS #68). + "MeasurementReminderEvent", "MeasurementReminder", // ── Medication ────────────────────────────────────────────────────────── diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 1bfe3d447..0992c9f81 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -259,6 +259,10 @@ function makePrisma() { encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index d3f42b992..2302b88f4 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -99,6 +99,12 @@ export const BACKED_UP_MODELS = [ "UserHealthProfile", "HealthProfileFactRevision", "MeasurementReminder", + // The Vorsorge completion ledger (v1.37.20, #223 / iOS #68). Looks derived + // and is the opposite: the reminder engine is single-cursor, so each satisfy + // overwrites the previous one on the reminder row and this table is the only + // copy of everything before the latest. Nothing can rebuild it, which is the + // exact test for BACKED_UP. + "MeasurementReminderEvent", // ── Cycle ───────────────────────────────────────────────────────────────── "CycleProfile", @@ -202,6 +208,9 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // reason: `documentLinks` is a field name on three models now, so each of // these reads through its OWN delegate here. "src/lib/export/vaccinations-backup.ts", + // The Vorsorge reminders and their completion ledger, both ends beside each + // other like the visits and vaccinations above. + "src/lib/export/reminders-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -212,6 +221,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/health-score-backup.ts", "src/lib/export/visits-backup.ts", "src/lib/export/vaccinations-backup.ts", + "src/lib/export/reminders-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -264,12 +274,11 @@ export const TWO_ENDED_MODELS = [ "Workout", "NutrientIntakeDay", "InboundDocument", - // Visits travel both ways from the release that introduces them. Two - // COVERAGE_PENDING neighbours already say what the alternative costs: - // reminders restore silent, and the filing between documents and conditions - // is lost. Leaving the visit links pending would return visits and documents - // with nothing between them — the same loss, one layer deeper — so the links - // land carried rather than owed. + // Visits travel both ways from the release that introduces them. A + // COVERAGE_PENDING neighbour already says what the alternative costs — the + // filing between documents and conditions is lost. Leaving the visit links + // pending would return visits and documents with nothing between them — the + // same loss, one layer deeper — so the links land carried rather than owed. "Practitioner", "Encounter", "EncounterDocumentLink", @@ -284,6 +293,17 @@ export const TWO_ENDED_MODELS = [ // else. "VaccinationRecord", "VaccinationDocumentLink", + // The Vorsorge reminders, off the debt register at last (v1.37.20, #223 / + // iOS #68), and the completion ledger carried with them from the release + // that introduces it. The pairing is deliberate: the ledger arriving is + // what turned "a restore leaves the account silent" from an inconvenience + // into a history loss, because the single-cursor engine keeps no other copy + // of past satisfies and skips. Restoring the reminder without its ledger + // would hand back a cadence with no evidence it was ever followed; the two + // land together. The encounter and vaccination `reminderId` references stop + // dropping to NULL in the same release — the rows they name come back now. + "MeasurementReminder", + "MeasurementReminderEvent", ] as const; /** One model claimed to travel both ways. */ @@ -340,8 +360,6 @@ export const COVERAGE_PENDING: Readonly> = { "Completed WHO-5, PHQ and GAD instruments with their scores and dates. Clinically meaningful history that no integration can re-sync, and the single largest gap on this list.", EcgRecording: "ECG traces and their rhythm classification. The originating device may still hold them, but a self-hoster who exported and wiped has nothing to re-sync from.", - MeasurementReminder: - "Per-metric reminder cadences the person configured. A restore leaves the account silent until each one is set up again.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 22b31a229..af51b9d1d 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -49,6 +49,12 @@ import { type VaccinationsBackupCounts, type VaccinationsBackupSection, } from "@/lib/export/vaccinations-backup"; +import { + buildRemindersBackupSection, + countRemindersBackupSection, + type RemindersBackupCounts, + type RemindersBackupSection, +} from "@/lib/export/reminders-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -63,7 +69,8 @@ export interface FullBackupCounts IntradayProfileBackupCounts, HealthScoreBackupCounts, VisitsBackupCounts, - VaccinationsBackupCounts { + VaccinationsBackupCounts, + RemindersBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -277,6 +284,7 @@ export async function buildFullBackupPayload( healthScoreRecords, visits, vaccinations, + reminders, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -416,6 +424,13 @@ export async function buildFullBackupPayload( buildVaccinationsBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The Vorsorge reminders and their completion ledger (v1.37.20, #223 / + // iOS #68). Both ends live in `src/lib/export/reminders-backup.ts` beside + // each other, the same arrangement as the two sections above and for the + // same reason. + buildRemindersBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -444,6 +459,7 @@ export async function buildFullBackupPayload( const healthScoreSection: HealthScoreBackupSection = healthScoreRecords; const visitsSection: VisitsBackupSection = visits; const vaccinationsSection: VaccinationsBackupSection = vaccinations; + const remindersSection: RemindersBackupSection = reminders; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -679,6 +695,7 @@ export async function buildFullBackupPayload( ...healthScoreSection, ...visitsSection, ...vaccinationsSection, + ...remindersSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -708,6 +725,7 @@ export async function buildFullBackupPayload( ...countHealthScoreBackupSection(healthScoreRecords), ...countVisitsBackupSection(visits), ...countVaccinationsBackupSection(vaccinations), + ...countRemindersBackupSection(reminders), }, }; } diff --git a/src/lib/export/reminders-backup.ts b/src/lib/export/reminders-backup.ts new file mode 100644 index 000000000..5ad1a8b15 --- /dev/null +++ b/src/lib/export/reminders-backup.ts @@ -0,0 +1,394 @@ +/** + * The Vorsorge reminders and their completion ledger, with both backup ends + * in one file. + * + * Same arrangement as `visits-backup.ts` and `vaccinations-backup.ts`, for + * the same reason: a reader asking "is this carried at both ends?" answers it + * here, and a reader who greps only the restore ROUTE gets a false negative + * because the route delegates. + * + * Two models ride: the reminder and the ledger row behind it + * (v1.37.20, #223 / iOS #68). Both are carried rather than owed. The + * reminder spent its time on the coverage-pending register — "a restore + * leaves the account silent until each one is set up again" — and the ledger + * raised the cost of that debt the release it landed: the engine is + * single-cursor, so the ledger is the ONLY copy of every satisfy and skip + * before the latest one. A restore without this section hands back a silent + * account with no history of it ever having been diligent. + * + * Each model uses its own named select constant and its own delegate call. + * `events` is a relation field name here and nowhere else today, but the + * delegate-per-model shape is kept anyway — `documentLinks` taught this + * directory what a shared relation name does to a structural matcher. + * + * One reference needs care on the way back: `MeasurementReminderEvent.reminderId` + * addresses a reminder in this same section. The builder filters the ledger to + * the reminders it actually carries — a portable export omits tombstoned + * reminders, and a ledger row whose reminder is not in the file could never + * restore — so a file this release writes always resolves completely. A + * hand-edited or truncated file that does not is reported, never guessed at. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { + MeasurementReminderEventKind, + MeasurementType, + ReminderOrigin, +} from "@/generated/prisma/client"; + +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export interface RemindersBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One configured Vorsorge cadence, exactly as the engine holds it. */ +export interface MeasurementReminderBackupEntry { + /** Always carried: the ledger rows, an encounter and a vaccination record + * all address a reminder by it. */ + id: string; + label: string; + measurementType: MeasurementType | null; + intervalDays: number | null; + rrule: string | null; + anchorDate: string | null; + endsOn: string | null; + origin: ReminderOrigin; + notifyHour: number; + location: string | null; + /** Server-computed and carried as computed — the restore writes it back + * verbatim rather than recomputing, so the account resumes exactly the + * due-state it was exported with. */ + nextDueAt: string | null; + lastSatisfiedAt: string | null; + enabled: boolean; + vaccinationAntigen: string | null; + /** v1.37.20 — the snooze cursor. Verbatim: a snooze that has since expired + * restores as an instant in the past, which every consumer already treats + * as "not snoozed". */ + snoozedUntil: string | null; + /** v1.37.20 — last honest skip. Never folded into `lastSatisfiedAt`; done + * and skipped stay distinguishable through a restore too. */ + lastSkippedAt: string | null; + /** v1.37.20 — lifetime skip counter. */ + skipCount: number; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + +/** One completion-ledger row: an honest satisfy or an honest skip. */ +export interface MeasurementReminderEventBackupEntry { + id: string; + reminderId: string; + kind: MeasurementReminderEventKind; + occurredAt: string; + /** Derived at write time against the pre-event `nextDueAt`, which is gone. + * Carried verbatim because nothing can ever re-derive it. */ + onTime: boolean; + source: string; + createdAt: string; +} + +export interface RemindersBackupSection { + measurementReminders: MeasurementReminderBackupEntry[]; + measurementReminderEvents: MeasurementReminderEventBackupEntry[]; +} + +export interface RemindersBackupCounts { + measurementReminders: number; + measurementReminderEvents: number; +} + +/** + * Named select constants, one per model — a structural matcher binds a model + * to the literal beside its delegate call, matching the other section files. + */ +const MEASUREMENT_REMINDER_BACKUP_SELECT = { + id: true, + label: true, + measurementType: true, + intervalDays: true, + rrule: true, + anchorDate: true, + endsOn: true, + origin: true, + notifyHour: true, + location: true, + nextDueAt: true, + lastSatisfiedAt: true, + enabled: true, + vaccinationAntigen: true, + snoozedUntil: true, + lastSkippedAt: true, + skipCount: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} as const; + +const MEASUREMENT_REMINDER_EVENT_BACKUP_SELECT = { + id: true, + reminderId: true, + kind: true, + occurredAt: true, + onTime: true, + source: true, + createdAt: true, +} as const; + +/** + * Build the reminders slice of a user's full backup. + * + * Takes the delegates it uses rather than a whole client, matching the other + * section builders. A portable export omits tombstoned reminders so a restore + * cannot resurrect them — and it omits the ledger rows of an omitted reminder + * with it, because a ledger row whose reminder is not in the file could only + * ever restore as a reported drop. A disaster-recovery payload keeps both so + * the account comes back as itself. + */ +export async function buildRemindersBackupSection( + prisma: Pick< + PrismaClient, + "measurementReminder" | "measurementReminderEvent" + >, + userId: string, + options: RemindersBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + const [reminderRows, eventRows] = await Promise.all([ + prisma.measurementReminder.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: { createdAt: "asc" }, + select: MEASUREMENT_REMINDER_BACKUP_SELECT, + }), + prisma.measurementReminderEvent.findMany({ + where: { userId }, + orderBy: { occurredAt: "asc" }, + select: MEASUREMENT_REMINDER_EVENT_BACKUP_SELECT, + }), + ]); + + // The ledger is filtered to the reminders the file actually carries. On a + // DR payload every reminder is carried and this is a no-op; on a portable + // export it removes the ledger of each omitted tombstone, so the file never + // states an event against a reminder it refuses to restore. + const carriedReminderIds = new Set(reminderRows.map((row) => row.id)); + + return { + measurementReminders: reminderRows.map((row) => ({ + id: row.id, + label: row.label, + measurementType: row.measurementType, + intervalDays: row.intervalDays, + rrule: row.rrule, + anchorDate: row.anchorDate?.toISOString() ?? null, + endsOn: row.endsOn?.toISOString() ?? null, + origin: row.origin, + notifyHour: row.notifyHour, + location: row.location, + nextDueAt: row.nextDueAt?.toISOString() ?? null, + lastSatisfiedAt: row.lastSatisfiedAt?.toISOString() ?? null, + enabled: row.enabled, + vaccinationAntigen: row.vaccinationAntigen, + snoozedUntil: row.snoozedUntil?.toISOString() ?? null, + lastSkippedAt: row.lastSkippedAt?.toISOString() ?? null, + skipCount: row.skipCount, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(disasterRecovery + ? { deletedAt: row.deletedAt?.toISOString() ?? null } + : {}), + })), + measurementReminderEvents: eventRows + .filter((row) => carriedReminderIds.has(row.reminderId)) + .map((row) => ({ + id: row.id, + reminderId: row.reminderId, + kind: row.kind, + occurredAt: row.occurredAt.toISOString(), + onTime: row.onTime, + source: row.source, + createdAt: row.createdAt.toISOString(), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countRemindersBackupSection( + section: RemindersBackupSection, +): RemindersBackupCounts { + return { + measurementReminders: section.measurementReminders.length, + measurementReminderEvents: section.measurementReminderEvents.length, + }; +} + +/** Counts the reminder restore wiped, for the audit trail. */ +export interface RemindersRestoreCleared { + measurementReminders: number; + measurementReminderEvents: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: the payload schema defaults every key, so + * every caller already satisfies this, and one that stops satisfying it fails + * to compile instead of passing `undefined` into a loop that iterates zero + * times and reports success. + */ +export interface RemindersRestoreInput { + measurementReminders: RestoredMeasurementReminder[]; + measurementReminderEvents: RestoredMeasurementReminderEvent[]; +} + +/** + * What the restore reads, as the parsed file actually presents it — a wider + * set than what this release writes: a portable export omits the tombstone, + * and every optional column arrives as `undefined` rather than `null`. + */ +type OptionalNullable = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredMeasurementReminder = Pick< + MeasurementReminderBackupEntry, + "id" | "label" | "createdAt" | "updatedAt" +> & + OptionalNullable< + Pick< + MeasurementReminderBackupEntry, + | "measurementType" + | "intervalDays" + | "rrule" + | "anchorDate" + | "endsOn" + | "origin" + | "notifyHour" + | "location" + | "nextDueAt" + | "lastSatisfiedAt" + | "enabled" + | "vaccinationAntigen" + | "snoozedUntil" + | "lastSkippedAt" + | "skipCount" + > + > & { deletedAt?: string | null }; + +export type RestoredMeasurementReminderEvent = Pick< + MeasurementReminderEventBackupEntry, + "id" | "reminderId" | "occurredAt" | "onTime" | "source" | "createdAt" +> & { + kind: MeasurementReminderEventKind; +}; + +/** + * Re-create the account's Vorsorge reminders and their completion ledger. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. The ledger is deleted before the reminders so its cleared count is + * truthful (the cascade would empty it anyway, silently), and recreated after + * them, because a ledger row addresses its reminder by id. + * + * MUST be called BEFORE the encounters and the vaccination records are + * restored: both carry a `reminderId` that they remap against the reminders + * in the database, and calling this later would make every one of those + * references read as unresolvable and be dropped — a restore counted as + * successful while it quietly disconnected every booster and appointment. + */ +export async function restoreRemindersData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: RemindersRestoreInput, + skips: RestoreSkipLog, +): Promise { + const clearedEvents = await tx.measurementReminderEvent.deleteMany({ + where: { userId: ownerId }, + }); + const clearedReminders = await tx.measurementReminder.deleteMany({ + where: { userId: ownerId }, + }); + + if (payload.measurementReminders.length > 0) { + await tx.measurementReminder.createMany({ + data: payload.measurementReminders.map((entry) => ({ + id: entry.id, + userId: ownerId, + label: entry.label, + measurementType: entry.measurementType ?? null, + intervalDays: entry.intervalDays ?? null, + rrule: entry.rrule ?? null, + anchorDate: entry.anchorDate ? new Date(entry.anchorDate) : null, + endsOn: entry.endsOn ? new Date(entry.endsOn) : null, + // A file written before a column existed says nothing about it, and + // the schema default is what those rows were living as. + origin: entry.origin ?? "VORSORGE", + notifyHour: entry.notifyHour ?? 9, + location: entry.location ?? null, + // Verbatim, not recomputed: `nextDueAt` is server-authoritative and + // the exported value IS what the server had resolved. Recomputing + // here would move a due date the person had already been shown. + nextDueAt: entry.nextDueAt ? new Date(entry.nextDueAt) : null, + lastSatisfiedAt: entry.lastSatisfiedAt + ? new Date(entry.lastSatisfiedAt) + : null, + enabled: entry.enabled ?? true, + vaccinationAntigen: entry.vaccinationAntigen ?? null, + snoozedUntil: entry.snoozedUntil ? new Date(entry.snoozedUntil) : null, + lastSkippedAt: entry.lastSkippedAt + ? new Date(entry.lastSkippedAt) + : null, + skipCount: entry.skipCount ?? 0, + createdAt: new Date(entry.createdAt), + updatedAt: new Date(entry.updatedAt), + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + })), + }); + } + + // The builder filters the ledger to carried reminders, so a file this + // release writes never drops here. A file that does reference a reminder it + // does not carry cannot have that row invented, and a dangling id would + // fail the foreign key and roll the whole restore back over one ledger + // line. The row is dropped and reported instead. + const restoredReminders = new Set( + payload.measurementReminders.map((entry) => entry.id), + ); + const writableEvents: RestoredMeasurementReminderEvent[] = []; + const droppedReminderRefs: string[] = []; + for (const entry of payload.measurementReminderEvents) { + if (restoredReminders.has(entry.reminderId)) { + writableEvents.push(entry); + } else { + droppedReminderRefs.push(entry.reminderId); + } + } + if (writableEvents.length > 0) { + await tx.measurementReminderEvent.createMany({ + data: writableEvents.map((entry) => ({ + id: entry.id, + userId: ownerId, + reminderId: entry.reminderId, + kind: entry.kind, + occurredAt: new Date(entry.occurredAt), + onTime: entry.onTime, + source: entry.source, + createdAt: new Date(entry.createdAt), + })), + }); + } + recordUnknownKeys( + skips, + "reminderReference", + [...new Set(droppedReminderRefs)], + droppedReminderRefs, + ); + + return { + measurementReminders: clearedReminders.count, + measurementReminderEvents: clearedEvents.count, + }; +} diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 24fa35348..4336bf999 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -36,18 +36,26 @@ * mechanism because the cost and the honest response are identical: an edge is * lost, the record it hangs off survives, and the operator is told which one. * What it names is a row the file pointed at that the restore did not put - * back — a reminder, which is on the coverage-pending register and never - * travels, or a document, lab result or condition episode whose id a portable - * export does not carry. + * back — a reminder the file does not carry (a portable export omits + * tombstoned ones since v1.37.20; before that no reminder travelled at all), + * or a document, lab result or condition episode whose id a portable export + * does not carry. * * The sixth, `vaccinationReference`, is the same kind as `visitReference` and * is kept separate rather than folded into it so the report says which part of * the record lost an edge. It names a row an immunization entry pointed at - * that the restore did not put back — a booster reminder, which never travels, - * a practitioner or visit the file referenced without carrying, or a scanned - * page whose id a portable export does not include. + * that the restore did not put back — a booster reminder the file does not + * carry, a practitioner or visit the file referenced without carrying, or a + * scanned page whose id a portable export does not include. * - * The seventh, `checkupClosure`, is not about a restore at all, and it borrows + * The seventh, `reminderReference`, is the same kind as the two above, for + * the completion ledger (v1.37.20, #223 / iOS #68): a ledger row whose + * reminder the file does not carry. The builder filters the ledger to carried + * reminders at write time, so a file this release writes never trips it — + * it exists for the hand-edited or truncated file, where inventing the + * reminder and silently dropping the row are equally wrong. + * + * The eighth, `checkupClosure`, is not about a restore at all, and it borrows * this shape deliberately rather than growing a second reporting mechanism * beside it. The situation is the same one: something a write was asked to do * could not be done, the record itself survives, and the person is told which @@ -62,6 +70,7 @@ export type SkippedCatalogue = | "moodTag" | "visitReference" | "vaccinationReference" + | "reminderReference" | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts index e4515747e..f88bcc151 100644 --- a/src/lib/export/vaccinations-backup.ts +++ b/src/lib/export/vaccinations-backup.ts @@ -23,9 +23,12 @@ * - `practitionerId` and `encounterId` point at models that restore in the * same run. They remap to the restored rows, and drop to NULL when the * file references a row it does not carry. - * - `reminderId` points at a `MeasurementReminder`, which is on the - * coverage-pending register and does not come back. It resolves to NULL, - * never to a dangling id, and the drop is reported. + * - `reminderId` points at a `MeasurementReminder`, which also restores in + * the same run (v1.37.20, #223 / iOS #68 — it was on the coverage-pending + * register before that and every reference was a known drop). It remaps + * the same way, and resolves to NULL, with the drop reported, only when + * the file genuinely lacks the reminder — a portable export omits + * tombstoned ones. * - `antigenSlug` is restored verbatim with no validation against the * current catalogue. A slug this release no longer resolves must still * come back; the renderer degrades to the free-text arm, which is the @@ -63,9 +66,10 @@ export interface VaccinationBackupEntry { practitionerId: string | null; encounterId: string | null; /** - * Carried so the loss is visible in the file rather than inferred from its - * absence. The restore resolves it to NULL and names the drop: the reminder - * it points at is not in the backup. + * The booster reminder this dose recorded satisfying, which restores in the + * same run since v1.37.20. The restore remaps it against the restored + * reminders and drops it to NULL, with the drop named, only when the file + * lacks the reminder. */ reminderId: string | null; /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ @@ -253,11 +257,11 @@ function decodeCiphertext(encoded: string): Uint8Array { * section. The dose first, then the link, because a link addresses both of its * ends by id. * - * MUST be called after the practitioners, encounters and documents have been - * restored: the dose remaps two of those references against the database and - * the link is written only when the document actually came back. Calling it - * earlier would drop every reference and be counted as a successful restore of - * a thinner record. + * MUST be called after the practitioners, encounters, documents and + * measurement reminders have been restored: the dose remaps three of those + * references against the database and the link is written only when the + * document actually came back. Calling it earlier would drop every reference + * and be counted as a successful restore of a thinner record. */ export async function restoreVaccinationsData( tx: Prisma.TransactionClient, @@ -272,10 +276,10 @@ export async function restoreVaccinationsData( where: { userId: ownerId }, }); - // Both far sides are restored by other branches of the same transaction, so - // the check is against the database rather than against the payload — this - // is the only place that can see all three sections at once. - const [practitionerIds, encounterIds] = await Promise.all([ + // All three far sides are restored by other branches of the same + // transaction, so the check is against the database rather than against the + // payload — this is the only place that can see every section at once. + const [practitionerIds, encounterIds, reminderIds] = await Promise.all([ tx.practitioner.findMany({ where: { userId: ownerId }, select: { id: true }, @@ -284,18 +288,24 @@ export async function restoreVaccinationsData( where: { userId: ownerId }, select: { id: true }, }), + tx.measurementReminder.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), ]); const restoredPractitioners = new Set(practitionerIds.map((row) => row.id)); const restoredEncounters = new Set(encounterIds.map((row) => row.id)); + const restoredReminders = new Set(reminderIds.map((row) => row.id)); // A row the file references but the restore did not bring back cannot be // invented, and a dangling id would fail the foreign key and roll the whole // restore back over one dose. The reference drops to NULL instead — the - // dose, its date, its antigen and its batch code all survive. + // dose, its date, its antigen and its batch code all survive. That includes + // the reminder since v1.37.20: it restores in the same run, so a drop here + // now means the file genuinely lacked it (a portable export omits + // tombstoned reminders), not that reminders never travel. const droppedPractitioners: string[] = []; const droppedEncounters: string[] = []; - // The reminder is on the coverage-pending register: it is not in the file at - // all, so every reference to one is a known, reportable drop. const droppedReminders: string[] = []; if (payload.vaccinations.length > 0) { @@ -316,7 +326,13 @@ export async function restoreVaccinationsData( if (entry.encounterId && encounterId === null) { droppedEncounters.push(entry.encounterId); } - if (entry.reminderId) droppedReminders.push(entry.reminderId); + const reminderId = + entry.reminderId && restoredReminders.has(entry.reminderId) + ? entry.reminderId + : null; + if (entry.reminderId && reminderId === null) { + droppedReminders.push(entry.reminderId); + } return { id: entry.id, userId: ownerId, @@ -332,8 +348,7 @@ export async function restoreVaccinationsData( site: entry.site ?? null, practitionerId, encounterId, - // Never the id the file carried: the row it named is not restored. - reminderId: null, + reminderId, noteEncrypted: entry.noteEncrypted == null ? null diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts index 4b5eae712..b34dde4ba 100644 --- a/src/lib/export/visits-backup.ts +++ b/src/lib/export/visits-backup.ts @@ -8,11 +8,11 @@ * * Five models ride: the practitioners, the encounters, and the three link * tables between an encounter and a document, a lab result or a condition - * episode. All five are carried rather than owed. Two neighbours on the debt - * register already say what the alternative costs — reminders restore silent, - * and the filing between documents and conditions is lost — and a restore that - * returned visits and documents with nothing between them would be the same - * loss one layer deeper. + * episode. All five are carried rather than owed. A neighbour on the debt + * register already says what the alternative costs — the filing between + * documents and conditions is lost — and a restore that returned visits and + * documents with nothing between them would be the same loss one layer + * deeper. * * Every read is scoped to the owner. Each model uses its own named select * constant and its own delegate call rather than riding a parent's `include`: @@ -20,12 +20,15 @@ * relation-shaped write here would be attributed to whichever of them a * matcher happened to find first. Delegate calls cannot be confused that way. * - * Two references deliberately do not survive a restore, and both are reported - * rather than dropped: + * Two references can fail to resolve on a restore, and both are reported + * rather than dropped in silence: * - * - `Encounter.reminderId` points at a `MeasurementReminder`, which is on the - * coverage-pending register and does not come back. The column resolves to - * NULL, never to a dangling id. + * - `Encounter.reminderId` points at a `MeasurementReminder`, which restores + * in the same run (v1.37.20, #223 / iOS #68 — it was on the + * coverage-pending register before that and every reference was a known + * drop). It remaps against the restored reminders and resolves to NULL, + * never to a dangling id, only when the file genuinely lacks the reminder + * — a portable export omits tombstoned ones. * - A link whose far side was not restored. A portable export carries no lab * result ids, so a lab link in one cannot resolve; a disaster-recovery * payload carries every id and they all do. @@ -72,9 +75,9 @@ export interface EncounterBackupEntry { reasonEncrypted: string | null; outcomeEncrypted: string | null; /** - * Carried so the loss is visible in the file rather than inferred from its - * absence. The restore resolves it to NULL and names the drop: the reminder - * it points at is not in the backup. + * The appointment's reminder row, which restores in the same run since + * v1.37.20. The restore remaps it against the restored reminders and drops + * it to NULL, with the drop named, only when the file lacks the reminder. */ reminderId: string | null; createdAt: string; @@ -351,10 +354,12 @@ function decodeCiphertext(encoded: string): Uint8Array { * section. Parents first — practitioners, then encounters, then the links — * because a link addresses both of its ends by id. * - * MUST be called after the documents, lab results and condition episodes have - * been restored: a link is written only when both of its ends exist, and the - * existence check is what decides. Calling it earlier would report every link - * as unresolvable and be counted as a successful restore of nothing. + * MUST be called after the documents, lab results, condition episodes AND the + * measurement reminders have been restored: a link is written only when both + * of its ends exist, the encounter's `reminderId` remaps against the restored + * reminders, and the existence check is what decides. Calling it earlier would + * report every one of those references as unresolvable and be counted as a + * successful restore of a thinner record. */ export async function restoreVisitsData( tx: Prisma.TransactionClient, @@ -406,8 +411,17 @@ export async function restoreVisitsData( ); const droppedPractitioners: string[] = []; - // The reminder is on the coverage-pending register: it is not in the file at - // all, so every reference to one is a known, reportable drop. + // The reminders are restored by another branch of the same transaction + // (v1.37.20 — they used to be on the coverage-pending register, when every + // reference was a known drop), so the check is against the database rather + // than against the payload. A reference the file genuinely lacks — a + // portable export omits tombstoned reminders — still drops to NULL and is + // still reported. + const reminderRows = await tx.measurementReminder.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }); + const restoredReminders = new Set(reminderRows.map((row) => row.id)); const droppedReminders: string[] = []; if (payload.encounters.length > 0) { @@ -421,7 +435,13 @@ export async function restoreVisitsData( if (entry.practitionerId && practitionerId === null) { droppedPractitioners.push(entry.practitionerId); } - if (entry.reminderId) droppedReminders.push(entry.reminderId); + const reminderId = + entry.reminderId && restoredReminders.has(entry.reminderId) + ? entry.reminderId + : null; + if (entry.reminderId && reminderId === null) { + droppedReminders.push(entry.reminderId); + } return { id: entry.id, userId: ownerId, @@ -437,8 +457,7 @@ export async function restoreVisitsData( entry.outcomeEncrypted == null ? null : decodeCiphertext(entry.outcomeEncrypted), - // Never the id the file carried: the row it named is not restored. - reminderId: null, + reminderId, createdAt: new Date(entry.createdAt), updatedAt: new Date(entry.updatedAt), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 2f1e696e1..88c20d4c5 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -46,6 +46,7 @@ import { IntakeAttributionSource, IntakeSource, MeasurementAggregationProvenance, + MeasurementReminderEventKind, MeasurementSource, MeasurementType, MedicationCategory, @@ -54,6 +55,7 @@ import { MedicationSideEffectCategory, MedicationSideEffectEntry, OvulationTest, + ReminderOrigin, RhythmClassification, SecondarySymptom, SleepStage, @@ -70,6 +72,7 @@ import { emergencyBloodTypeSchema, organDonorStatusSchema, } from "@/lib/validations/emergency-profile"; +import { REMINDER_EVENT_SOURCES } from "@/lib/measurement-reminders/satisfy"; export const BACKUP_SCHEMA_VERSION = "2" as const; const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const; @@ -890,8 +893,8 @@ const encounterBackupSchema = z practitionerId: z.string().nullable().optional(), reasonEncrypted: base64BytesSchema.nullable().optional(), outcomeEncrypted: base64BytesSchema.nullable().optional(), - // Carried so the loss is visible in the file. The restore resolves it to - // NULL and names the drop: the reminder it points at does not travel. + // Remapped against the restored reminders (they travel since v1.37.20); + // dropped to NULL, with the drop named, only when the file lacks the row. reminderId: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, @@ -927,8 +930,8 @@ const vaccinationBackupSchema = z site: z.enum(VaccinationSite).nullable().optional(), practitionerId: z.string().nullable().optional(), encounterId: z.string().nullable().optional(), - // Carried so the loss is visible in the file. The restore resolves it to - // NULL and names the drop: the reminder it points at does not travel. + // Remapped against the restored reminders (they travel since v1.37.20); + // dropped to NULL, with the drop named, only when the file lacks the row. reminderId: z.string().nullable().optional(), noteEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime, @@ -945,6 +948,56 @@ const vaccinationLinkBackupSchema = z }) .passthrough(); +/** + * One Vorsorge reminder, and one row of its completion ledger (v1.37.20, + * #223 / iOS #68). + * + * `id` is required on both: an encounter, a vaccination record and every + * ledger row address a reminder by it, and a ledger row is addressed by its + * own id on re-insert. The enums are exactly the API's own — `origin` and + * `kind` are the Prisma enums, and the ledger `source` is validated against + * the engine's closed call-site set rather than accepted as free text, so a + * file cannot smuggle a value the engine itself would never write. + */ +const measurementReminderBackupSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + measurementType: z.enum(MeasurementType).nullable().optional(), + intervalDays: z.number().int().nullable().optional(), + rrule: z.string().nullable().optional(), + anchorDate: isoDateTime.nullable().optional(), + endsOn: isoDateTime.nullable().optional(), + origin: z.enum(ReminderOrigin).optional(), + notifyHour: z.number().int().min(0).max(23).optional(), + location: z.string().nullable().optional(), + // Server-computed and restored verbatim — recomputing on restore would + // move a due date the person had already been shown. + nextDueAt: isoDateTime.nullable().optional(), + lastSatisfiedAt: isoDateTime.nullable().optional(), + enabled: z.boolean().optional(), + vaccinationAntigen: z.string().nullable().optional(), + snoozedUntil: isoDateTime.nullable().optional(), + lastSkippedAt: isoDateTime.nullable().optional(), + skipCount: z.number().int().min(0).optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + +const measurementReminderEventBackupSchema = z + .object({ + id: z.string().min(1), + reminderId: z.string().min(1), + kind: z.enum(MeasurementReminderEventKind), + occurredAt: isoDateTime, + onTime: z.boolean(), + source: z.enum(REMINDER_EVENT_SOURCES), + createdAt: isoDateTime, + }) + .passthrough(); + const allergyBackupSchema = z .object({ id: z.string().min(1), @@ -1107,6 +1160,14 @@ export const backupPayloadSchema = z // writes []. vaccinations: z.array(vaccinationBackupSchema).default([]), vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), + // The Vorsorge reminders and their completion ledger (v1.37.20, #223 / + // iOS #68). Defaulted for the same reason as the sections above: a file + // written before the reminders travelled carries no key, and an account + // with none writes []. + measurementReminders: z.array(measurementReminderBackupSchema).default([]), + measurementReminderEvents: z + .array(measurementReminderEventBackupSchema) + .default([]), manifest: backupManifestSchema.nullable().default(null), // v1.37.19 (A6-9) — field paths a PORTABLE export could not decrypt // (fail-soft nulls). Disclosed in the file so a nulled field is @@ -1189,6 +1250,10 @@ export interface BackupSummary { vaccinations: number; /** v1.37.19 (A6-8) — vaccination↔document links. */ vaccinationLinks: number; + /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ + measurementReminders: number; + /** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */ + measurementReminderEvents: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -1240,6 +1305,11 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { payload.encounterConditionLinks.length, vaccinations: payload.vaccinations.length, vaccinationLinks: payload.vaccinationDocumentLinks.length, + // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, + // so the admin's "what did I just restore" answer never under-counts a + // file with reminders the way it once did for visits. + measurementReminders: payload.measurementReminders.length, + measurementReminderEvents: payload.measurementReminderEvents.length, }; } diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 966919fd2..2923d738b 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -153,6 +153,10 @@ const COUNT_BACK: Record< p.vaccinationRecord.count({ where: { userId } }), VaccinationDocumentLink: (p, userId) => p.vaccinationDocumentLink.count({ where: { userId } }), + MeasurementReminder: (p, userId) => + p.measurementReminder.count({ where: { userId } }), + MeasurementReminderEvent: (p, userId) => + p.measurementReminderEvent.count({ where: { userId } }), }; /** @@ -438,6 +442,51 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { noteEncrypted: encryptToBytes("ring the upper bell"), }, }); + + // One Vorsorge reminder with the v1.37.20 skip/snooze columns all set, and + // two completion-ledger rows — one honest satisfy, one honest skip. Fat like + // the vaccination fixture below and for the same reason: the count-back + // proves the rows returned, the column-by-column assertion after the restore + // proves they returned WHOLE. The encounter and the dose below point their + // `reminderId` here, because both references now remap against the restored + // reminders and this fixture is what proves they survive rather than drop. + const reminder = await prisma.measurementReminder.create({ + data: { + userId: OWNER_ID, + label: "Blutdruck messen", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 7, + notifyHour: 8, + location: "Zuhause", + nextDueAt: AT("2026-07-08T06:00:00.000Z"), + lastSatisfiedAt: AT("2026-07-01T06:05:00.000Z"), + vaccinationAntigen: "tetanus", + snoozedUntil: AT("2026-07-08T06:00:00.000Z"), + lastSkippedAt: AT("2026-06-24T06:00:00.000Z"), + skipCount: 2, + }, + }); + await prisma.measurementReminderEvent.create({ + data: { + userId: OWNER_ID, + reminderId: reminder.id, + kind: "SATISFIED", + occurredAt: AT("2026-07-01T06:05:00.000Z"), + onTime: true, + source: "manual", + }, + }); + await prisma.measurementReminderEvent.create({ + data: { + userId: OWNER_ID, + reminderId: reminder.id, + kind: "SKIPPED", + occurredAt: AT("2026-06-24T06:00:00.000Z"), + onTime: false, + source: "skip", + }, + }); + const encounter = await prisma.encounter.create({ data: { userId: OWNER_ID, @@ -449,6 +498,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { outcomeEncrypted: encryptToBytes( "ferritin back in range, recheck in a year", ), + reminderId: reminder.id, }, }); await prisma.encounterDocumentLink.create({ @@ -477,13 +527,12 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { // transcribed from. Fat on purpose, against this file's own minimal-fixture // rule: a count-back proves the row returned, and the assertion after the // restore reads each column back so it also proves the row returned WHOLE. - // The practitioner and the encounter are the ones seeded above, because both - // are remapped on the way back and a reference to something the restore did - // not put back is a different case (covered by the skip test below). - // - // `reminderId` is deliberately NOT set here: it is the one reference that - // can never resolve, so setting it would make this test's "nothing was - // skipped" assertion false for a reason that is correct behaviour. + // The practitioner, the encounter and the reminder are the ones seeded + // above, because all three are remapped on the way back and a reference to + // something the restore did not put back is a different case (covered by + // the skip test below). `reminderId` used to be deliberately absent here — + // it was the one reference that could never resolve — and is deliberately + // present since v1.37.20, because resolving is now the behaviour under test. const vaccination = await prisma.vaccinationRecord.create({ data: { userId: OWNER_ID, @@ -496,6 +545,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { site: "LEFT_ARM", practitionerId: practitioner.id, encounterId: encounter.id, + reminderId: reminder.id, noteEncrypted: encryptToBytes("sore arm for a day, nothing else"), }, }); @@ -646,6 +696,7 @@ describe("every model the plan claims two-ended survives a real restore", () => include: { practitioner: { select: { name: true } }, encounter: { select: { occurredAt: true } }, + reminder: { select: { label: true } }, documentLinks: { select: { documentId: true } }, }, }); @@ -659,6 +710,10 @@ describe("every model the plan claims two-ended survives a real restore", () => site: dose.site, practitioner: dose.practitioner?.name ?? null, encounterAt: dose.encounter?.occurredAt.toISOString() ?? null, + // The booster reference, resolved through the relation: non-null proves + // the id survived the round trip instead of dropping to NULL the way it + // had to before the reminders travelled. + reminder: dose.reminder?.label ?? null, links: dose.documentLinks.length, note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null, }).toEqual({ @@ -671,21 +726,119 @@ describe("every model the plan claims two-ended survives a real restore", () => site: "LEFT_ARM", practitioner: "Round-trip practice", encounterAt: "2026-06-30T08:00:00.000Z", + reminder: "Blutdruck messen", links: 1, note: "sore arm for a day, nothing else", }); + + // The reminder, read back column by column — including the three + // v1.37.20 columns (snooze cursor, last skip, skip counter), because a + // restore that defaulted them would hand back a cadence that looks never + // snoozed and never skipped, which is a history the account does not have. + const restoredReminder = await prisma.measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect({ + label: restoredReminder.label, + measurementType: restoredReminder.measurementType, + intervalDays: restoredReminder.intervalDays, + rrule: restoredReminder.rrule, + anchorDate: restoredReminder.anchorDate, + endsOn: restoredReminder.endsOn, + origin: restoredReminder.origin, + notifyHour: restoredReminder.notifyHour, + location: restoredReminder.location, + nextDueAt: restoredReminder.nextDueAt?.toISOString() ?? null, + lastSatisfiedAt: restoredReminder.lastSatisfiedAt?.toISOString() ?? null, + enabled: restoredReminder.enabled, + vaccinationAntigen: restoredReminder.vaccinationAntigen, + snoozedUntil: restoredReminder.snoozedUntil?.toISOString() ?? null, + lastSkippedAt: restoredReminder.lastSkippedAt?.toISOString() ?? null, + skipCount: restoredReminder.skipCount, + deletedAt: restoredReminder.deletedAt, + }).toEqual({ + label: "Blutdruck messen", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 7, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + notifyHour: 8, + location: "Zuhause", + nextDueAt: "2026-07-08T06:00:00.000Z", + lastSatisfiedAt: "2026-07-01T06:05:00.000Z", + enabled: true, + vaccinationAntigen: "tetanus", + snoozedUntil: "2026-07-08T06:00:00.000Z", + lastSkippedAt: "2026-06-24T06:00:00.000Z", + skipCount: 2, + deletedAt: null, + }); + + // Both ledger rows, whole: the satisfy and the skip, with the resolved + // `onTime` verdicts. A restore cannot re-derive `onTime` — the pre-event + // `nextDueAt` is gone — so a defaulted value here would be an invented + // punctuality record. + const ledger = await prisma.measurementReminderEvent.findMany({ + where: { userId: OWNER_ID }, + orderBy: { occurredAt: "asc" }, + }); + expect( + ledger.map((event) => ({ + reminderId: event.reminderId, + kind: event.kind, + occurredAt: event.occurredAt.toISOString(), + onTime: event.onTime, + source: event.source, + })), + ).toEqual([ + { + reminderId: restoredReminder.id, + kind: "SKIPPED", + occurredAt: "2026-06-24T06:00:00.000Z", + onTime: false, + source: "skip", + }, + { + reminderId: restoredReminder.id, + kind: "SATISFIED", + occurredAt: "2026-07-01T06:05:00.000Z", + onTime: true, + source: "manual", + }, + ]); + + // The appointment's reminder reference survives too — same remap, other + // referrer. + const restoredEncounter = await prisma.encounter.findFirstOrThrow({ + where: { userId: OWNER_ID }, + select: { reminderId: true }, + }); + expect( + restoredEncounter.reminderId, + "the encounter's reminder reference must survive now that the reminder travels", + ).toBe(restoredReminder.id); }); /** - * The two references a dose can carry that the restore cannot honour, and - * the slug it must not judge. + * The reference a portable file genuinely lacks, and the slug the restore + * must not judge. * - * Separated from the round trip above because both are correct behaviour - * that shows up as a reported skip, and that file asserts nothing was + * The reminders travel since v1.37.20, so a reference to one is no longer a + * guaranteed drop — the round trip above proves it surviving. What can still + * genuinely be missing is a TOMBSTONED reminder in a portable export: the + * builder omits it (a restore must not resurrect a deletion) and omits its + * completion ledger with it, while a live dose that recorded satisfying it + * keeps its reference in the database. That reference then points at a row + * the file does not carry, which is exactly the case the restore reports. + * + * Separated from the round trip above because this is correct behaviour + * that shows up as a reported skip, and that test asserts nothing was * skipped. Reported is the whole point: a reference dropped in silence is * the same defect as a row dropped in silence, one field smaller. */ - it("reports the reminder it cannot restore and keeps a slug it cannot resolve", async () => { + it("reports the tombstoned reminder a portable file omits and keeps a slug it cannot resolve", async () => { const prisma = getPrismaClient(); await seedAdminSession(prisma); await createOwner(prisma); @@ -696,6 +849,20 @@ describe("every model the plan claims two-ended survives a real restore", () => label: "Tetanus booster", intervalDays: 3650, vaccinationAntigen: "tetanus", + deletedAt: AT("2026-07-01T00:00:00.000Z"), + }, + }); + // A ledger row on the tombstoned reminder. The builder must drop it from + // the file along with its reminder — a ledger row whose reminder is not + // carried could only ever restore as a reported loss. + await prisma.measurementReminderEvent.create({ + data: { + userId: OWNER_ID, + reminderId: reminder.id, + kind: "SATISFIED", + occurredAt: AT("2026-06-01T09:00:00.000Z"), + onTime: true, + source: "vaccination", }, }); await prisma.vaccinationRecord.create({ @@ -713,8 +880,14 @@ describe("every model the plan claims two-ended survives a real restore", () => }); const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { - purpose: "disaster-recovery", + purpose: "portable-export", }); + // The file must be silent about the tombstoned reminder AND its ledger — + // carrying the events of a reminder it refuses to restore would make + // every portable file this release writes restore with a reported loss. + expect(payload.measurementReminders).toEqual([]); + expect(payload.measurementReminderEvents).toEqual([]); + await prisma.user.delete({ where: { id: OWNER_ID } }); await createOwner(prisma); From a3561b4600df059abedbf9071e2e5a1cf588f016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:46:24 +0200 Subject: [PATCH 09/20] Tell a first-run what Apple Health is actually doing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #778. Two honesty gaps from the first-run report: the web onboarding presented Apple Health like a web connector although the connection only completes in the iPhone app, and the initial backfill gave no sign of progress, so a working first sync looked broken. The onboarding source step now says plainly that Apple Health is set up in the iPhone app after onboarding, without dead-ending the flow. The integration card gains a server-authoritative progress summary — rows accepted, oldest reading reached, and an honest flowing/waiting line derived from the last accepted batch. Nothing the server does not know is shown: no percentage, no ETA, no invented throttle state; a quiet stretch reads as waiting for the phone, not as an error. --- e2e/settings-integrations-mobile.spec.ts | 3 + .../healthkit/__tests__/route.test.ts | 56 ++++++++- src/app/api/integrations/healthkit/route.ts | 12 ++ .../__tests__/source-card-grid.test.tsx | 20 ++++ .../onboarding/source-card-grid.tsx | 12 ++ .../__tests__/apple-health-card.test.tsx | 109 ++++++++++++++++++ .../integrations/apple-health-card.tsx | 92 ++++++++++++++- .../__tests__/apple-health-progress.test.ts | 93 +++++++++++++++ src/lib/integrations/apple-health-progress.ts | 63 ++++++++++ src/lib/openapi/routes/integrations.ts | 30 +++++ 10 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 src/lib/integrations/__tests__/apple-health-progress.test.ts create mode 100644 src/lib/integrations/apple-health-progress.ts diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index b06f1bb41..f827442c8 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -243,6 +243,9 @@ test.describe("Apple Health guidance", () => { lastSyncedAt: null, syncHealth: { verdict: "pending_first_sync", since: null }, metricFreshness: [], + // #778 — nothing accepted yet, so the card must show no + // progress section for this pending-first-sync account. + syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null }, }, error: null, }), diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 067113a7f..26ac0eae7 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -6,9 +6,10 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(), update: vi.fn() }, // v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths. auditLog: { create: vi.fn() }, - // Per-metric freshness for the APPLE_HEALTH source. - measurement: { groupBy: vi.fn() }, - workout: { groupBy: vi.fn() }, + // Per-metric freshness + first-run sync progress for the APPLE_HEALTH + // source (#778). + measurement: { groupBy: vi.fn(), aggregate: vi.fn() }, + workout: { groupBy: vi.fn(), aggregate: vi.fn() }, }, toJson: (v: T) => v, })); @@ -54,6 +55,14 @@ beforeEach(() => { vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.aggregate).mockResolvedValue({ + _count: { _all: 0 }, + _min: { measuredAt: null }, + } as never); + vi.mocked(prisma.workout.aggregate).mockResolvedValue({ + _count: { _all: 0 }, + _min: { startedAt: null }, + } as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -227,6 +236,10 @@ describe("GET /api/integrations/healthkit — sync health", () => { lastSeenAt: string; stale: boolean; }>; + syncProgress: { + recordsAccepted: number; + oldestMeasuredAt: string | null; + } | null; }; async function read(): Promise { @@ -327,6 +340,43 @@ describe("GET /api/integrations/healthkit — sync health", () => { expect(body.lastBackgroundSyncAt).toBeNull(); }); + // #778 — a first-run backfill was invisible from the web: nothing said how + // many records had arrived or how far back they reached. The GET now carries + // the two figures the server actually holds. + it("publishes the accepted-row count and the oldest instant reached", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + } as never); + vi.mocked(prisma.measurement.aggregate).mockResolvedValue({ + _count: { _all: 4200 }, + _min: { measuredAt: new Date("2019-05-04T06:00:00.000Z") }, + } as never); + vi.mocked(prisma.workout.aggregate).mockResolvedValue({ + _count: { _all: 12 }, + _min: { startedAt: new Date("2021-01-01T09:00:00.000Z") }, + } as never); + + const body = await read(); + expect(body.syncProgress).toEqual({ + recordsAccepted: 4212, + oldestMeasuredAt: "2019-05-04T06:00:00.000Z", + }); + }); + + it("degrades to a null progress summary rather than failing the config read", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(DAY), + } as never); + vi.mocked(prisma.measurement.aggregate).mockRejectedValue( + new Error("aggregate hiccup"), + ); + const body = await read(); + expect(body.syncProgress).toBeNull(); + expect(body.syncHealth.verdict).toBe("fresh"); + }); + it("degrades to no per-metric data rather than failing the config read", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ healthKitConfigJson: null, diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index 8509b12ba..e9428e4c1 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -20,6 +20,7 @@ import { import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; +import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress"; import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; import { classifyMetricFreshness, @@ -168,6 +169,16 @@ export const GET = apiHandler(async () => { const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch( () => [], ); + // Issue #778 — a first-run backfill used to be invisible from the web: the + // card said "last sync 2 minutes ago" while giving no sense of how much had + // arrived or how far back it reached. These two figures (row count + oldest + // instant) are everything the server actually knows about backfill + // progress; the phone's queue and throttle state stay on the phone and are + // not guessed at. Fail-soft for the same reason the freshness read is — + // a progress line is never worth 500-ing the config the iOS client needs. + const syncProgress = await getAppleHealthSyncProgress(user.id).catch( + () => null, + ); return apiSuccess({ entries, @@ -176,6 +187,7 @@ export const GET = apiHandler(async () => { lastBackgroundSyncAt, syncHealth, metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict), + syncProgress, }); }); diff --git a/src/components/onboarding/__tests__/source-card-grid.test.tsx b/src/components/onboarding/__tests__/source-card-grid.test.tsx index 381cbcce9..ca25ec0d6 100644 --- a/src/components/onboarding/__tests__/source-card-grid.test.tsx +++ b/src/components/onboarding/__tests__/source-card-grid.test.tsx @@ -112,3 +112,23 @@ describe(" Withings card", () => { expect(html).not.toContain("Connect Withings"); }); }); + +// #778 — the step listed Apple Health next to the web connectors with nothing +// saying its setup happens in the iOS app afterwards, so it read like one more +// OAuth flow to hunt for. +describe(" Apple Health note", () => { + it("says Apple Health setup completes in the iPhone app, not on the web", () => { + integrationStatusState.data = undefined; + const html = render(); + expect(html).toContain('data-testid="source-more-apple-health-note"'); + expect(html).toContain("open the HealthLog app on your iPhone"); + expect(html).toContain("no web connection"); + }); + + it("keeps the Apple Health chip tappable so the note is not a dead end", () => { + integrationStatusState.data = undefined; + const html = render(); + expect(html).toContain('data-testid="source-more-apple-health"'); + expect(html).toContain('href="/settings/integrations"'); + }); +}); diff --git a/src/components/onboarding/source-card-grid.tsx b/src/components/onboarding/source-card-grid.tsx index e4d0d369c..4e872c0a6 100644 --- a/src/components/onboarding/source-card-grid.tsx +++ b/src/components/onboarding/source-card-grid.tsx @@ -222,6 +222,18 @@ export function SourceCardGrid() { ))} + {/* #778 — Apple Health looked like one more web connector here, but + there is nothing to connect on the web: the sync is set up in the + iOS app after onboarding. Say so, so nobody hunts Settings for an + OAuth flow that does not exist. The chip stays tappable (it lands + on the Integrations page, whose Apple Health card walks through + the iPhone-app setup) and Next / Skip advance as always. */} +

+ {t("onboarding.source.more.appleHealthHint")} +

; + syncProgress?: { + recordsAccepted: number; + oldestMeasuredAt: string | null; + } | null; }; let statusPayload: Payload | undefined; @@ -295,3 +299,108 @@ describe(" — delivery diagnostic (#586)", () => { expect(html).not.toContain("Last background sync"); }); }); + +// #778 — the first-run backfill used to be invisible: a user watching the web +// app had no way to tell "still flowing" from "looks dead but is working". +describe(" — sync progress (#778)", () => { + beforeEach(() => { + statusPayload = undefined; + statusLoading = false; + statusError = false; + }); + + it("shows the accepted-row count and the oldest reading reached", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + syncProgress: { + recordsAccepted: 12345, + oldestMeasuredAt: "2019-05-04T06:00:00.000Z", + }, + }; + + const html = render(); + + expect(html).toContain('data-testid="apple-health-progress"'); + expect(html).toContain('data-testid="apple-health-progress-received"'); + // Locale-grouped integer, so the count reads as a number, not a code. + expect(html).toContain("12,345"); + expect(html).toContain('data-testid="apple-health-progress-oldest"'); + // The 2019 date renders with its year — the whole point is showing how + // far back the backfill has reached. + expect(html).toContain("2019"); + }); + + it("reads as flowing while batches keep arriving", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null }, + }; + + const html = render(); + + expect(html).toContain('data-testid="apple-health-progress-state"'); + expect(html).toContain('data-state="flowing"'); + expect(html).toContain("still delivering"); + }); + + it("waits honestly instead of inventing throttle state when batches pause", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null }, + }; + + const html = render(); + + expect(html).toContain('data-state="waiting"'); + // The server does not track the phone's throttle/queue state, so the copy + // says "waiting for the iPhone app", never a fabricated percentage or ETA. + expect(html).toContain("Waiting for the iPhone app to send more data"); + expect(html).not.toContain("%"); + }); + + it("renders no progress section before anything has arrived", () => { + statusPayload = { + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null }, + }; + + const html = render(); + + // The delivery section's "no sync yet" line already owns the blank state. + expect(html).not.toContain('data-testid="apple-health-progress"'); + }); + + it("renders no progress section when the server read failed", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + syncProgress: null, + }; + + const html = render(); + + expect(html).not.toContain('data-testid="apple-health-progress"'); + }); + + it("defines every progress key in all six locale catalogs", () => { + const requiredKeys = [ + "title", + "receivedLabel", + "oldestLabel", + "flowing", + "waiting", + ] as const; + + for (const catalog of [en, de, es, fr, itMessages, pl]) { + const progress = catalog.settings.appleHealth.progress; + for (const key of requiredKeys) { + expect(progress[key]).toBeTypeOf("string"); + expect(progress[key].length).toBeGreaterThan(0); + } + } + }); +}); diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index d35563e9d..adb54f414 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -24,7 +24,7 @@ import { } from "@/components/settings/integration-status-pill"; import { Button } from "@/components/ui/button"; import { apiGet } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { MetricFreshnessEntry, SyncHealth, @@ -44,8 +44,23 @@ interface HealthKitStatus { lastBackgroundSyncAt?: string | null; syncHealth?: SyncHealth; metricFreshness?: MetricFreshnessEntry[]; + /** Server-side backfill progress (#778); null when the read failed. */ + syncProgress?: { + recordsAccepted: number; + oldestMeasuredAt: string | null; + } | null; } +/** + * How long after the last accepted batch the progress line stops claiming + * data is flowing. The server tracks no device-side throttle or queue state, + * so the only honest signal is the age of the newest batch: within the hour + * reads as "still arriving", beyond it as "waiting for the iPhone app" — a + * backfill that iOS has parked and a backfill that finished look identical + * from here, and the copy says neither is an error. + */ +const PROGRESS_WAITING_AFTER_MS = 60 * 60 * 1000; + const TRIGGER_KEYS: Record = { foreground: "settings.appleHealth.delivery.trigger.foreground", background: "settings.appleHealth.delivery.trigger.background", @@ -119,6 +134,79 @@ function DeliveryDiagnostic({ ); } +/** + * First-run backfill progress (#778). During the initial HealthKit backfill + * the card said nothing about how much had arrived or how far back it + * reached, so a working first run looked broken. This renders the figures + * the server actually holds — accepted-row count and the oldest instant + * reached — plus one honest state line derived from the age of the newest + * batch. No ETA and no percentage: the remaining queue lives on the phone + * and the server cannot see it. + */ +function SyncProgress({ + status, + t, + now, +}: { + status: HealthKitStatus; + t: (key: string, params?: Record) => string; + /** Injectable reference instant, mirroring the pill's own prop. */ + now?: Date; +}) { + const formatters = useFormatters(); + const progress = status.syncProgress; + // Nothing accepted and nothing synced: the delivery section's "no sync yet" + // line already covers the blank state — a zero-row table would only repeat it. + if (!progress || (progress.recordsAccepted <= 0 && !status.lastSyncedAt)) { + return null; + } + const reference = now ?? new Date(); + const lastBatchAge = status.lastSyncedAt + ? Math.max(0, reference.getTime() - new Date(status.lastSyncedAt).getTime()) + : null; + const flowing = + lastBatchAge !== null && lastBatchAge < PROGRESS_WAITING_AFTER_MS; + + return ( +
+

+ {t("settings.appleHealth.progress.title")} +

+
+
+
+ {t("settings.appleHealth.progress.receivedLabel")} +
+
+ {formatters.integer(progress.recordsAccepted)} +
+
+ {progress.oldestMeasuredAt ? ( +
+
+ {t("settings.appleHealth.progress.oldestLabel")} +
+
+ {formatters.date(new Date(progress.oldestMeasuredAt))} +
+
+ ) : null} +
+ {lastBatchAge !== null ? ( +

+ {flowing + ? t("settings.appleHealth.progress.flowing") + : t("settings.appleHealth.progress.waiting")} +

+ ) : null} +
+ ); +} + export function AppleHealthCard({ enabled }: { enabled: boolean }) { const { t } = useTranslations(); const { @@ -175,6 +263,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) { {showPill && } + {showPill && } + ({ + prisma: { + measurement: { aggregate: vi.fn() }, + workout: { aggregate: vi.fn() }, + }, +})); + +import { getAppleHealthSyncProgress } from "../apple-health-progress"; +import { prisma } from "@/lib/db"; + +function mockAggregates( + measurement: { count: number; min: Date | null }, + workout: { count: number; min: Date | null }, +) { + vi.mocked(prisma.measurement.aggregate).mockResolvedValue({ + _count: { _all: measurement.count }, + _min: { measuredAt: measurement.min }, + } as never); + vi.mocked(prisma.workout.aggregate).mockResolvedValue({ + _count: { _all: workout.count }, + _min: { startedAt: workout.min }, + } as never); +} + +describe("getAppleHealthSyncProgress", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("sums measurement and workout rows into one accepted count", async () => { + mockAggregates( + { count: 1200, min: new Date("2019-03-01T08:00:00.000Z") }, + { count: 45, min: new Date("2021-06-01T06:30:00.000Z") }, + ); + + const progress = await getAppleHealthSyncProgress("user-1"); + + expect(progress.recordsAccepted).toBe(1245); + }); + + it("reports the oldest instant across both tables", async () => { + mockAggregates( + { count: 10, min: new Date("2020-01-05T00:00:00.000Z") }, + { count: 3, min: new Date("2018-11-20T07:15:00.000Z") }, + ); + + const progress = await getAppleHealthSyncProgress("user-1"); + + expect(progress.oldestMeasuredAt).toBe("2018-11-20T07:15:00.000Z"); + }); + + it("uses the measurement minimum when workouts have no rows", async () => { + mockAggregates( + { count: 8, min: new Date("2022-02-02T12:00:00.000Z") }, + { + count: 0, + min: null, + }, + ); + + const progress = await getAppleHealthSyncProgress("user-1"); + + expect(progress.recordsAccepted).toBe(8); + expect(progress.oldestMeasuredAt).toBe("2022-02-02T12:00:00.000Z"); + }); + + it("is honest about a source that has delivered nothing", async () => { + mockAggregates({ count: 0, min: null }, { count: 0, min: null }); + + const progress = await getAppleHealthSyncProgress("user-1"); + + expect(progress).toEqual({ recordsAccepted: 0, oldestMeasuredAt: null }); + }); + + it("scopes both reads to the user and the APPLE_HEALTH source", async () => { + mockAggregates({ count: 0, min: null }, { count: 0, min: null }); + + await getAppleHealthSyncProgress("user-9"); + + expect(prisma.measurement.aggregate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-9", deletedAt: null, source: "APPLE_HEALTH" }, + }), + ); + expect(prisma.workout.aggregate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-9", source: "APPLE_HEALTH" }, + }), + ); + }); +}); diff --git a/src/lib/integrations/apple-health-progress.ts b/src/lib/integrations/apple-health-progress.ts new file mode 100644 index 000000000..386f5be76 --- /dev/null +++ b/src/lib/integrations/apple-health-progress.ts @@ -0,0 +1,63 @@ +/** + * First-run Apple Health sync progress (issue #778). + * + * The iOS app drives the HealthKit backfill; the server only ever sees the + * batches that actually arrive. During a first run nothing on the web said + * how far that had come, so a backfill that was quietly working looked + * broken. This module computes the figures the server genuinely holds — how + * many rows carry the `APPLE_HEALTH` source and how far back in time they + * reach — and nothing more: the phone's throttle state, its remaining queue, + * and any ETA live on the device and are deliberately not invented here. + * + * Both the live batch ingest and the one-shot export.zip import write + * `source = APPLE_HEALTH`, so the count is "Apple Health records in + * HealthLog", not "records from one specific pipe" — which is the number a + * user watching a first run actually wants to see grow. + */ +import { prisma } from "@/lib/db"; + +export interface AppleHealthSyncProgress { + /** Measurement + workout rows carrying the `APPLE_HEALTH` source. */ + recordsAccepted: number; + /** + * Earliest instant reached (`measuredAt` for measurements, `startedAt` for + * workouts), as an ISO string; null when nothing has arrived. + */ + oldestMeasuredAt: string | null; +} + +/** + * Two grouped aggregates (count + MIN), mirroring the freshness reads in + * `./metric-freshness.ts` which cover the same two tables for the same + * reason: workouts are Apple Health data too, but never `Measurement` rows. + */ +export async function getAppleHealthSyncProgress( + userId: string, +): Promise { + const [measurements, workouts] = await Promise.all([ + prisma.measurement.aggregate({ + where: { userId, deletedAt: null, source: "APPLE_HEALTH" }, + _count: { _all: true }, + _min: { measuredAt: true }, + }), + // `Workout` rows are hard-deleted, so there is no tombstone predicate. + prisma.workout.aggregate({ + where: { userId, source: "APPLE_HEALTH" }, + _count: { _all: true }, + _min: { startedAt: true }, + }), + ]); + + const minima = [measurements._min.measuredAt, workouts._min.startedAt].filter( + (d): d is Date => d instanceof Date, + ); + const oldest = + minima.length > 0 + ? new Date(Math.min(...minima.map((d) => d.getTime()))) + : null; + + return { + recordsAccepted: measurements._count._all + workouts._count._all, + oldestMeasuredAt: oldest?.toISOString() ?? null, + }; +} diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 5318d9d57..f40e24eb2 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -85,6 +85,30 @@ const metricFreshnessEntry = z "Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row.", }); +// Issue #778 — the two backfill-progress figures the server genuinely holds. +// The iOS app drives the backfill; its queue, throttle state, and any ETA +// live on the device and are deliberately absent here. +const appleHealthSyncProgress = z + .object({ + recordsAccepted: z + .number() + .int() + .describe( + "Measurement + workout rows carrying the `APPLE_HEALTH` source (live batch ingest and the one-shot export import both write it). Soft-deleted measurement rows are excluded.", + ), + oldestMeasuredAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Earliest instant reached across those rows (`measuredAt` for measurements, `startedAt` for workouts); null when nothing has arrived. During a first-run backfill this walks backwards as history lands.", + ), + }) + .meta({ + id: "AppleHealthSyncProgress", + description: + "Server-side Apple Health backfill progress: how many rows have been accepted and how far back in time they reach. Only what the server actually knows — no ETA, no device-side queue state.", + }); + const healthKitConfigResponse = z .object({ entries: z.array(healthKitEntry), @@ -116,6 +140,12 @@ const healthKitConfigResponse = z .describe( "Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.", ), + syncProgress: appleHealthSyncProgress + .nullable() + .optional() + .describe( + "Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from the PATCH echo.", + ), }) .meta({ id: "HealthKitConfigResponse", From 651d37b494f90266924a01d5e0fd61def7a25429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:46:35 +0200 Subject: [PATCH 10/20] Let the dashboard hero show what matters most to its owner The hero card composition was fixed: score panel first, the worth-a-look rail beneath. A reminder-driven person asked for the reverse. The stored dashboard layout gains an optional hero field ('score' default, 'reminders' promotes the rail into the hero slot), validated as a closed enum, preserve-when-absent on write, omitted from the blob at the default so untouched accounts serialize byte-identically. The choice sits in Settings beside the other hero controls; the promoted rail degrades to the calm all-clear line when nothing is due. --- .../dashboard/widgets/__tests__/route.test.ts | 90 ++++++++++ src/app/api/dashboard/widgets/route.ts | 10 ++ src/app/page-client.tsx | 3 + .../daily/__tests__/today-hero.test.tsx | 63 +++++++ src/components/daily/today-hero.tsx | 163 +++++++++++------- .../settings/dashboard-layout-section.tsx | 42 ++++- src/lib/__tests__/dashboard-layout.test.ts | 64 +++++++ src/lib/dashboard-layout.ts | 42 +++++ src/lib/openapi/routes/dashboard.ts | 11 +- 9 files changed, 422 insertions(+), 66 deletions(-) diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 095282ea7..03fcb3375 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -967,3 +967,93 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => { expect(body.data.updatedAt).toBe(advanced.toISOString()); }); }); + +describe("dashboard widgets — hero primary content", () => { + const weightOnly = [ + { id: "weight" as const, visible: true, tileVisible: true, order: 0 }, + ]; + + it("accepts and persists hero: 'reminders'", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, hero: "reminders" }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { hero?: string } }; + }; + expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders"); + + const body = (await res.json()) as { data: { hero?: string } }; + expect(body.data.hero).toBe("reminders"); + }); + + it("rejects a hero value outside the closed enum with 422", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, hero: "banner" }), + ); + expect(res.status).toBe(422); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("keeps the stored hero choice when the client omits the field", async () => { + // A layout save from a client that predates the field (the native + // client, or a stale web tab) must not silently reset the choice. + const stored: DashboardLayout = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + hero: "reminders", + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut(makeReq({ version: 1, widgets: weightOnly })); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { hero?: string } }; + }; + expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders"); + }); + + it("honours an explicitly sent 'score' over the stored 'reminders'", async () => { + const stored: DashboardLayout = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + hero: "reminders", + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, hero: "score" }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { hero?: string } }; + }; + // "score" is the default and serializes as an omitted field. + expect(updateArg.data.dashboardWidgetsJson.hero).toBeUndefined(); + + const body = (await res.json()) as { data: { hero?: string } }; + expect(body.data.hero).toBeUndefined(); + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 4d8514d04..7cd00455e 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -31,6 +31,7 @@ import { MAX_SELECTED_SCORE_RINGS, HERO_RING_IDS, MAX_HERO_RING_ORDER, + HERO_PRIMARY_CONTENTS, layoutNeedsPreserveRead, mergePreservedLayoutFields, type DashboardLayout, @@ -158,6 +159,15 @@ const layoutSchema = z.object({ .array(z.enum(PRIORITY_ITEM_KINDS)) .max(PRIORITY_ITEM_KINDS.length) .optional(), + // Hero primary content — what the dashboard hero card leads with: + // "score" (default) or "reminders" (the worth-a-look rail promoted into + // the hero slot). This widens the stored `dashboardWidgetsJson` shape + // ADDITIVELY: the field is optional on the wire and omitted from storage + // at the default, so the native client (which mirrors this blob) can + // keep round-tripping layouts untouched until it learns the field. + // Same preserve-when-absent contract as `comparisonBaseline`; the + // resolver drops unknown stored values back to "score" on read. + hero: z.enum(HERO_PRIMARY_CONTENTS).optional(), // v1.32.16 (issue #581) — the optimistic-concurrency base token used to // live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper, // which strips it BEFORE this parse: the token is transport, not a layout diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 5287c60f8..408330c7e 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -967,6 +967,9 @@ export default function DashboardPageClient({ ) : !mounted || digestQuery.isLoading ? ( diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 5be773a7e..cfb1a57e2 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -321,4 +321,67 @@ describe("", () => { expect(html).toContain('style="width:168px;height:168px"'); expect(html).not.toContain('data-layout="compact-all-clear"'); }); + + // The hero primary-content preference (`hero` on the dashboard layout + // blob): "reminders" promotes the worth-a-look rail into the hero slot; + // the score composition stays the default. + it("promotes the rail into the hero slot when the preference is reminders", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="today-hero"'); + expect(html).toContain('data-layout="reminders"'); + expect(html).toContain('data-slot="today-hero-rail"'); + expect(html).toContain('data-kind="dose_window"'); + expect(html).toContain('data-kind="sync_issue"'); + // The score composition yields the slot entirely. + expect(html).not.toContain('data-slot="today-hero-score"'); + expect(html).not.toContain('data-slot="today-hero-lead"'); + expect(html).not.toContain('data-slot="today-hero-signal"'); + }); + + it("keeps the score composition when the preference is the default score", () => { + const html = render(); + expect(html).toContain('data-slot="today-hero-score"'); + expect(html).toContain('data-slot="today-hero-rail"'); + expect(html).not.toContain('data-layout="reminders"'); + }); + + it("shows the calm all-clear line when reminders mode has nothing to surface", () => { + const html = render( + , + ); + expect(html).toContain('data-layout="reminders"'); + expect(html).toContain('data-slot="today-hero-all-clear"'); + expect(html).not.toContain('data-slot="today-hero-rail"'); + expect(html).not.toContain('data-slot="today-hero-score"'); + }); + + it("keeps the honest sleep-pending note in reminders mode", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="today-hero-sleep-pending"'); + }); + + it("still degrades to nothing on a genuinely empty account in reminders mode", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); }); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 7a88dc02f..e7632e055 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -33,6 +33,14 @@ * sync.reconnect → /settings/integrations, checkup.view → /checkups), so * `PriorityCard` wires each tap as a `` to its existing destination * by construction; S2 invents no new backend action. + * + * Configurable primary content: the `hero` field on the dashboard layout + * blob (server-persisted, Settings → Dashboard) chooses what this card + * leads with. `"score"` keeps the composition above verbatim; + * `"reminders"` promotes the worth-a-look rail into the hero slot — the + * rail (or the calm all-clear line) becomes the card's content and the + * score composition yields entirely. The empty-account degrade and the + * freshness note are shared by both modes. */ import Link from "next/link"; import { Moon } from "lucide-react"; @@ -47,6 +55,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { DailyDigest } from "@/lib/daily/digest"; +import type { HeroPrimaryContent } from "@/lib/dashboard-layout"; import { COACH_CHECKIN_KEEP_INTENT, COACH_CHECKIN_LETGO_INTENT, @@ -88,9 +97,12 @@ function withoutRepeatedScore( export function TodayHero({ digest, renderFilteredAllClear = false, + primaryContent = "score", }: { digest: DailyDigest; renderFilteredAllClear?: boolean; + /** Server-persisted hero choice from the dashboard layout blob. */ + primaryContent?: HeroPrimaryContent; }) { const { t } = useTranslations(); // The hero is deliberately NOT mount-gated (v1.30.9: it is the LCP element @@ -182,20 +194,88 @@ export function TodayHero({ return null; } + // Shared fragments — identical markup in every composition, so the + // "score" and "reminders" modes cannot drift apart on either surface. + const sleepPendingNote = digest.sleepPending ? ( +
+

+

+
+ ) : null; + + /* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. */ + const rail = hasItems ? ( +
+

+ {t("daily.today.worthALook")} +

+
+ {digest.worthALook.map((item, i) => ( + dismissItem.mutate(itemKey) : undefined + } + actionsPending={ + item.kind === "coach_checkin" && + (keep.isPending || letGo.isPending) + } + /> + ))} +
+
+ ) : null; + + const heroShellClassName = cn( + // The tile strip's surface plus the ONE sanctioned Today atmosphere: + // `.today-hero-wash` leans a faint `--primary` mix over the theme + // `--card` toward the ring corner (the `.wellness-tile` color-mix + // pattern, softer than the insights hero) so the promoted day's read + // carries a quiet identity without a banner gradient or glow. + "bg-card today-hero-wash border-border relative isolate overflow-hidden rounded-xl border", + "p-4 md:p-6", + ); + + // Reminders-first composition — the rail IS the hero. The score + // composition yields the slot entirely (the ring keeps its home on + // /insights); an empty rail degrades to the same calm all-clear line + // the score mode uses, never an empty card. + if (primaryContent === "reminders") { + return ( +
+
+ {rail ?? ( +

+ {t("daily.today.allClear")} +

+ )} + {sleepPendingNote} +
+
+ ); + } + return (
{/* v1.29.1 — tightened after the v1.29.0 ring cluster was removed: the hero read as half-empty (short lead → gap → rail). Compact section @@ -252,17 +332,7 @@ export function TodayHero({ {/* In the compact fallback the quiet freshness tier belongs next to the smaller ring. That uses the otherwise-empty leading space and avoids adding a second row below the dial. */} - {compactAllClear && digest.sleepPending ? ( -
-

-

-
- ) : null} + {compactAllClear ? sleepPendingNote : null}
{/* Health score ring — `flat` (no sweep/bloom), server-computed @@ -309,53 +379,20 @@ export function TodayHero({ opacity modifier). Freshness note (plan §2.4) — provisional day, last night's sleep not yet folded in. Muted, non-blocking, refreshes in place when the morning job lands. */} - {!compactAllClear && digest.sleepPending ? ( -
-

-

-
- ) : null} + {!compactAllClear ? sleepPendingNote : null} {/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the digest is all-clear, a first-class muted line stands in for the rail (calm inversion of an alarm), never an empty card. */} - {hasItems ? ( -
-

- {t("daily.today.worthALook")} -

-
- {digest.worthALook.map((item, i) => ( - dismissItem.mutate(itemKey) - : undefined - } - actionsPending={ - item.kind === "coach_checkin" && - (keep.isPending || letGo.isPending) - } - /> - ))} -
-
- ) : !compactAllClear ? ( -

- {t("daily.today.allClear")} -

- ) : null} + {rail ?? + (!compactAllClear ? ( +

+ {t("daily.today.allClear")} +

+ ) : null)} ); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 7b475bb01..0ee6013a2 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -32,6 +32,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -207,6 +208,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { const dragHintId = useId(); const heroDescriptionId = useId(); const heroNotificationNoteId = useId(); + const heroContentSelectId = useId(); // v1.34 — the "Today highlights" fieldset below is gated on `layout`, // which can be truthy on the very first client render whenever a // returning visitor's browser already has a warm, offline-persisted @@ -393,6 +395,19 @@ export function DashboardLayoutSection({ id }: { id: string }) { }); } + /** + * Hero primary content — the server-persisted `hero` field on the layout + * blob. The value set is closed ("score" | "reminders"); anything else a + * DOM event could carry collapses to the default rather than a cast. + */ + function setHeroContent(value: string) { + if (!layout) return; + setDraft({ + ...layout, + hero: value === "reminders" ? "reminders" : "score", + }); + } + /** * v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()` * so the swap-and-renumber logic lives in exactly one place. The @@ -459,6 +474,30 @@ export function DashboardLayoutSection({ id }: { id: string }) {

{t("dashboard.heroItemsDescription")}

+ {/* Hero primary content — a single labelled select beside the + highlight toggles. "score" keeps the health-score read; + "reminders" promotes the highlight rail into the hero slot. */} +
+ + setHeroContent(event.target.value)} + disabled={saveMutation.isPending} + data-slot="hero-content-select" + className="sm:max-w-xs" + > + + + +
{PRIORITY_ITEM_KINDS.map((kind) => { const inputId = `hero-item-${kind}`; @@ -733,7 +772,8 @@ export function DashboardLayoutSection({ id }: { id: string }) { JSON.stringify(layout.widgets) === JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.widgets) && JSON.stringify(layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS) === - JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds) + JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds) && + (layout.hero ?? "score") === DEFAULT_DASHBOARD_LAYOUT.hero ? t("dashboard.layoutUsingDefaults") : t("dashboard.layoutCustomized")}

diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index b52d0cec9..79576b185 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -1060,6 +1060,7 @@ describe("layout field merge disposition", () => { selectedScoreRings: ["MED_COMPLIANCE"], heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"], enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], + hero: "reminders", }; it("assigns a disposition to every top-level layout field", () => { @@ -1083,6 +1084,7 @@ describe("layout field merge disposition", () => { "chartOverlayPrefs", "comparisonBaseline", "enabledHeroItemKinds", + "hero", "heroRingOrder", "selectedScoreRings", "widgets", @@ -1162,3 +1164,65 @@ describe("layout field merge disposition", () => { expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets); }); }); + +/** + * Configurable hero primary content. The `hero` field chooses what the + * dashboard hero card leads with: the health-score read ("score", the + * long-standing default) or the Today-highlight rail promoted into the + * hero slot ("reminders"). Closed two-value enum, preserve-when-absent + * like the other layout preferences, unknown values dropped on read. + */ +describe("resolveDashboardLayout() — hero primary content", () => { + const baseWidgets: DashboardLayout["widgets"] = [ + { id: "weight", visible: true, tileVisible: true, order: 0 }, + ]; + + it("defaults to 'score' for legacy layouts (no field saved)", () => { + const layout = resolveDashboardLayout({ version: 1, widgets: baseWidgets }); + expect(layout.hero).toBe("score"); + }); + + it("defaults to 'score' on the default layout", () => { + expect(DEFAULT_DASHBOARD_LAYOUT.hero).toBe("score"); + expect(resolveDashboardLayout(null).hero).toBe("score"); + }); + + it("respects an explicit 'reminders' choice", () => { + const layout = resolveDashboardLayout({ + version: 1, + widgets: baseWidgets, + hero: "reminders", + }); + expect(layout.hero).toBe("reminders"); + }); + + it("drops unknown hero values back to 'score'", () => { + for (const garbage of ["banner", 7, null, {}, ["reminders"]]) { + const layout = resolveDashboardLayout({ + version: 1, + widgets: baseWidgets, + hero: garbage, + }); + expect(layout.hero).toBe("score"); + } + }); + + it("omits the default 'score' from serialization", () => { + const serialized = serializeDashboardLayout({ + version: 1, + widgets: baseWidgets, + hero: "score", + }); + expect("hero" in serialized).toBe(false); + }); + + it("persists 'reminders' through serialize → resolve round-trip", () => { + const serialized = serializeDashboardLayout({ + version: 1, + widgets: baseWidgets, + hero: "reminders", + }); + expect(serialized.hero).toBe("reminders"); + expect(resolveDashboardLayout(serialized).hero).toBe("reminders"); + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 0b97c2902..9bd51187f 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -554,6 +554,29 @@ function coerceSelectedScoreRings(value: unknown): ScoreRingId[] { return out; } +/** + * Configurable hero primary content — what the dashboard hero card leads + * with. `"score"` is the long-standing composition (health-score ring + + * the day's read); `"reminders"` promotes the worth-a-look rail into the + * hero slot. Closed two-value set, deliberately no further options. + * + * The preference piggy-backs on the layout blob like `comparisonBaseline` + * (a UI affordance, no Prisma migration). The resolver drops unknown + * values back to the default `"score"`; the serializer omits the default + * so an untouched account's stored blob is unchanged. + */ +export const HERO_PRIMARY_CONTENTS = ["score", "reminders"] as const; +export type HeroPrimaryContent = (typeof HERO_PRIMARY_CONTENTS)[number]; + +export const DEFAULT_HERO_PRIMARY_CONTENT: HeroPrimaryContent = "score"; + +function coerceHeroPrimaryContent(value: unknown): HeroPrimaryContent { + return typeof value === "string" && + (HERO_PRIMARY_CONTENTS as readonly string[]).includes(value) + ? (value as HeroPrimaryContent) + : DEFAULT_HERO_PRIMARY_CONTENT; +} + /** * Resolve the Today hero's item-kind visibility against the current closed * catalogue. Legacy or malformed values enable every current kind. Arrays are @@ -594,6 +617,14 @@ export interface DashboardLayout { * every current kind; an explicit empty array keeps the rail off. */ enabledHeroItemKinds?: PriorityItemKind[]; + /** + * Primary hero-card content: `"score"` (default — health-score ring plus + * the day's read) or `"reminders"` (the worth-a-look rail promoted into + * the hero slot). Optional and additive so clients that predate the + * field round-trip the blob untouched; unknown values resolve to + * `"score"`. + */ + hero?: HeroPrimaryContent; } /** @@ -627,6 +658,7 @@ export const LAYOUT_FIELD_MERGE_DISPOSITION = { selectedScoreRings: "preserve", heroRingOrder: "preserve", enabledHeroItemKinds: "preserve", + hero: "preserve", } as const satisfies Record; /** @@ -693,6 +725,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { selectedScoreRings: [...DEFAULT_SELECTED_SCORE_RINGS], heroRingOrder: [...DEFAULT_HERO_RING_ORDER], enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], + hero: DEFAULT_HERO_PRIMARY_CONTENT, widgets: [ { id: "weight", visible: true, tileVisible: true, order: 0 }, { id: "bp", visible: true, tileVisible: true, order: 1 }, @@ -863,6 +896,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout { enabledHeroItemKinds: coerceEnabledHeroItemKinds( candidate.enabledHeroItemKinds, ), + // Hero primary content: unknown / missing values resolve to the + // default "score" so a stale client cannot swap the hero to a + // composition the renderer doesn't know how to draw. + hero: coerceHeroPrimaryContent(candidate.hero), }; } @@ -875,6 +912,7 @@ export function serializeDashboardLayout( const enabledHeroItemKinds = coerceEnabledHeroItemKinds( layout.enabledHeroItemKinds, ); + const hero = coerceHeroPrimaryContent(layout.hero); return { version: DASHBOARD_LAYOUT_VERSION, widgets: layout.widgets @@ -911,5 +949,9 @@ export function serializeDashboardLayout( ...(enabledHeroItemKinds.length === PRIORITY_ITEM_KINDS.length ? {} : { enabledHeroItemKinds }), + // The default "score" is omitted from storage (same forward-compatible + // contract as `enabledHeroItemKinds`), so an untouched account's blob + // is byte-identical to the pre-field shape. + ...(hero === DEFAULT_HERO_PRIMARY_CONTENT ? {} : { hero }), }; } diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index 1ea829b2b..34950a54d 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -22,6 +22,7 @@ import { CHART_OVERLAY_KEYS, SCORE_RING_IDS, HERO_RING_IDS, + HERO_PRIMARY_CONTENTS, } from "@/lib/dashboard-layout"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { @@ -69,11 +70,17 @@ const dashboardLayoutSchema = z .describe( "Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array disables all kinds.", ), + hero: z + .enum(HERO_PRIMARY_CONTENTS) + .optional() + .describe( + 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is omitted from responses.', + ), }) .meta({ id: "DashboardLayoutBody", description: - "Per-user dashboard widget layout. `widgets` is the ordered tile and chart list. `comparisonBaseline`, `chartOverlayPrefs`, `selectedScoreRings`, `heroRingOrder`, and `enabledHeroItemKinds` are additive dashboard preferences. Every field except `version` is optional on input and preserved when absent from PUT. Missing `enabledHeroItemKinds` resolves to every current kind, while an empty array hides every Today highlight. Widget ids use the closed dashboard catalogue; unknown ids are dropped before validation.", + "Per-user dashboard widget layout. `widgets` is the ordered tile and chart list. `comparisonBaseline`, `chartOverlayPrefs`, `selectedScoreRings`, `heroRingOrder`, `enabledHeroItemKinds`, and `hero` are additive dashboard preferences. Every field except `version` is optional on input and preserved when absent from PUT. Missing `enabledHeroItemKinds` resolves to every current kind, while an empty array hides every Today highlight. Widget ids use the closed dashboard catalogue; unknown ids are dropped before validation.", }); const dashboardLayoutPutBody = dashboardLayoutSchema @@ -153,7 +160,7 @@ export const dashboardWidgetPaths: NonNullable = { tags: ["Dashboard"], summary: "Reset the calling user's dashboard widget layout", description: - "Resets the tile layout and Today-highlight visibility. It preserves the stored comparison baseline, chart overlays, selected score rings, and hero ring order. Idempotent.", + "Resets the tile layout and Today-highlight visibility. It preserves the stored comparison baseline, chart overlays, selected score rings, hero ring order, and hero primary content. Idempotent.", responses: { "200": { description: From 90bad17cc849f03d2ab4f6d47a0434bdadbc9a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:46:48 +0200 Subject: [PATCH 11/20] Show the answers behind a screening score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs A3-8. The per-item PHQ-9 / GAD-7 / WHO-5 / SCI answers have been stored encrypted since the assessments shipped, and nothing ever read them back — the clinical detail (item 9 included) was invisible. A new by-id read decrypts them for whoever holds the mind read grant (the same grant that already serves the mood diary's prose and the item-9 flag), degrades to an honest items-unavailable state on any decrypt or shape problem, and the history rows gain an expandable breakdown rendering the official instrument texts the check-in flow already carries — nothing retranslated, no alarm styling on item 9 beyond what the house already does. Rides along: the guard inventories learn the branch's new routes (frozen counts moved with reasons), the reminder-engine test harnesses carry the ledger delegate, and the custom-metric restore toast keys sit in their right namespace in all six locales. --- docs/api/openapi.yaml | 134 +++++++++- messages/de.json | 17 +- messages/en.json | 17 +- messages/es.json | 17 +- messages/fr.json | 17 +- messages/it.json | 17 +- messages/pl.json | 17 +- src/__tests__/sharing-surface-guard.test.ts | 10 +- .../success-affordance-guard.test.ts | 4 +- src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- .../__tests__/soft-delete-restore.test.ts | 4 +- .../assessments/[id]/__tests__/route.test.ts | 241 ++++++++++++++++++ .../mental-health/assessments/[id]/route.ts | 175 +++++++++++++ .../api/mental-health/assessments/route.ts | 3 +- .../__tests__/vorsorge-section.test.tsx | 2 + .../assessment-item-breakdown.test.tsx | 118 +++++++++ .../mental-health/assessment-history.tsx | 43 +++- .../assessment-item-breakdown.tsx | 136 ++++++++++ src/components/mental-health/types.ts | 17 +- .../__tests__/measurement-reminder.test.ts | 12 + .../jobs/__tests__/reminder-satisfy.test.ts | 11 + src/lib/openapi/routes/mental-health.ts | 53 +++- src/lib/query-keys/mental-health.ts | 3 + 23 files changed, 1036 insertions(+), 34 deletions(-) create mode 100644 src/app/api/mental-health/assessments/[id]/__tests__/route.test.ts create mode 100644 src/app/api/mental-health/assessments/[id]/route.ts create mode 100644 src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx create mode 100644 src/components/mental-health/assessment-item-breakdown.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a3fbda2be..a7e3b30db 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12114,6 +12114,32 @@ paths: "403": *a6 "422": *a3 "429": *a4 + /api/mental-health/assessments/{id}: + get: + tags: + - Mental health + summary: Read one administration with its decrypted answers + description: "Returns a single screener administration including the decrypted per-item answers (and the PHQ-9's + unscored functional follow-up when answered) — the one surface that reads them back. When the stored blob cannot + be decrypted the read degrades to `items: null` + `itemsUnavailable: true` instead of failing; the total / band + / flag still answer. A soft-deleted or foreign row answers 404." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The administration with its answer breakdown. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 /api/allergies: get: tags: @@ -14558,7 +14584,7 @@ paths: - Dashboard summary: Reset the calling user's dashboard widget layout description: Resets the tile layout and Today-highlight visibility. It preserves the stored comparison baseline, chart - overlays, selected score rings, and hero ring order. Idempotent. + overlays, selected score rings, hero ring order, and hero primary content. Idempotent. responses: "200": description: Web-controlled layout settings reset; preserved dashboard preferences are returned with the defaults. @@ -18917,6 +18943,14 @@ components: - tension_window - same_time_baseline - upcoming_visit + hero: + description: 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the + Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is + omitted from responses.' + type: string + enum: + - score + - reminders baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -39796,6 +39830,96 @@ components: - id - contacts additionalProperties: false + GetMentalHealthAssessmentEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/GetMentalHealthAssessmentResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetMentalHealthAssessmentResponse: + type: object + properties: + assessment: + $ref: "#/components/schemas/MentalHealthAssessmentDetail" + required: + - assessment + additionalProperties: false + MentalHealthAssessmentDetail: + type: object + properties: + id: + type: string + instrument: + type: string + enum: + - PHQ9 + - GAD7 + - WHO5 + - SCI + locale: + type: string + version: + type: string + totalScore: + type: number + severityBand: + type: string + item9Flagged: + type: boolean + crisisShownAt: + anyOf: + - type: string + - type: "null" + takenAt: + type: string + createdAt: + type: string + items: + anyOf: + - type: array + items: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + functionalDifficulty: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + itemsUnavailable: + type: boolean + required: + - id + - instrument + - locale + - version + - totalScore + - severityBand + - item9Flagged + - crisisShownAt + - takenAt + - createdAt + - items + - functionalDifficulty + - itemsUnavailable + additionalProperties: false + description: "One administration with its decrypted per-item answers, in the order the instrument presents its items + (PHQ-9/GAD-7 answers 0–3, SCI 0–4, WHO-5 0–5). `functionalDifficulty` is the PHQ-9's unscored functional + follow-up when it was answered. When the stored blob cannot be decrypted the read degrades honestly: `items` is + null, `itemsUnavailable` is true, and the denormalised score fields still answer." ListAllergiesEnvelope: type: object properties: @@ -42696,6 +42820,14 @@ components: - tension_window - same_time_baseline - upcoming_visit + hero: + description: 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the + Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is + omitted from responses.' + type: string + enum: + - score + - reminders updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." diff --git a/messages/de.json b/messages/de.json index 2c836d87a..49ce66bee 100644 --- a/messages/de.json +++ b/messages/de.json @@ -516,6 +516,9 @@ "heroItemsTitle": "Highlights für heute", "heroItemsDescription": "Wähle aus, welche Eintragstypen in den Highlights für heute erscheinen.", "heroItemsNotificationNote": "Benachrichtigungseinstellungen werden separat verwaltet.", + "heroContentTitle": "Obere Karte zeigt", + "heroContentScore": "Gesundheitsscore und Tagesüberblick", + "heroContentReminders": "Highlights für heute", "heroItems": { "coach_checkin": "Coach-Check-ins", "dose_window": "Medikamentendosen", @@ -4529,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health hat keine Web-Verbindung — schließe das Onboarding hier ab und richte Apple Health danach in der HealthLog-App auf deinem iPhone ein." }, "import": "Bestehende Daten importieren" }, @@ -6388,6 +6392,13 @@ "importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.", "importAction": "Einmaligen Import öffnen", "staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen.", + "progress": { + "title": "Sync-Fortschritt", + "receivedLabel": "Empfangene Datensätze", + "oldestLabel": "Älteste erreichte Messung", + "flowing": "In der letzten Stunde sind Daten angekommen — die iPhone-App liefert weiter.", + "waiting": "Warten auf weitere Daten von der iPhone-App. Die Übertragung läuft weiter, wenn die App geöffnet ist oder iOS ihr Hintergrundzeit gewährt — eine ruhige Phase während des ersten großen Abgleichs ist normal, kein Fehler." + }, "delivery": { "title": "Übermittlung", "lastTriggerLabel": "Letzte Synchronisierung kam", @@ -9710,8 +9721,6 @@ "deleteConfirmTitle": "Diese eigene Metrik löschen?", "deleteConfirmDescription": "Die Metrik wird aus deiner Liste entfernt. Erfasste Werte bleiben erhalten.", "deletedToast": "Eigene Metrik gelöscht", - "restoredToast": "Wert wiederhergestellt", - "restoreError": "Wiederherstellen fehlgeschlagen", "deleteError": "Eigene Metrik konnte nicht gelöscht werden", "showAllValues": "Alle Werte anzeigen", "emptyDescription": "Definiere eigene Messwerte, um sie zu verfolgen und darzustellen.", @@ -9761,6 +9770,8 @@ "editedToast": "Wert aktualisiert", "editError": "Wert konnte nicht aktualisiert werden", "deletedToast": "Wert gelöscht", + "restoredToast": "Messwert wiederhergestellt", + "restoreError": "Wiederherstellen fehlgeschlagen", "deleteError": "Wert konnte nicht gelöscht werden" }, "chart": { diff --git a/messages/en.json b/messages/en.json index 3e30da5f2..cb3ea8113 100644 --- a/messages/en.json +++ b/messages/en.json @@ -516,6 +516,9 @@ "heroItemsTitle": "Today highlights", "heroItemsDescription": "Choose which item types can appear in Today highlights.", "heroItemsNotificationNote": "Notification settings are managed separately.", + "heroContentTitle": "Top card shows", + "heroContentScore": "Health score and daily summary", + "heroContentReminders": "Today highlights", "heroItems": { "coach_checkin": "Coach check-ins", "dose_window": "Medication doses", @@ -4529,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health has no web connection — finish onboarding here, then open the HealthLog app on your iPhone to complete Apple Health setup." }, "import": "Import existing history" }, @@ -6388,6 +6392,13 @@ "importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.", "importAction": "Open one-shot import", "staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.", + "progress": { + "title": "Sync progress", + "receivedLabel": "Records received", + "oldestLabel": "Oldest reading reached", + "flowing": "Data arrived within the last hour — the iPhone app is still delivering.", + "waiting": "Waiting for the iPhone app to send more data. Delivery continues when the app is open or when iOS grants it background time — a quiet stretch during the first big backfill is normal, not an error." + }, "delivery": { "title": "Delivery", "lastTriggerLabel": "Latest sync arrived", @@ -9710,8 +9721,6 @@ "deleteConfirmTitle": "Delete this custom metric?", "deleteConfirmDescription": "The metric is removed from your list. Its logged values are kept.", "deletedToast": "Custom metric deleted", - "restoredToast": "Value restored", - "restoreError": "Restore failed", "deleteError": "Could not delete the custom metric", "showAllValues": "Show all values", "emptyDescription": "Define your own measurements to track and chart them.", @@ -9761,6 +9770,8 @@ "editedToast": "Value updated", "editError": "Could not update the value", "deletedToast": "Value deleted", + "restoredToast": "Value restored", + "restoreError": "Restore failed", "deleteError": "Could not delete the value" }, "chart": { diff --git a/messages/es.json b/messages/es.json index 72134f305..851bac220 100644 --- a/messages/es.json +++ b/messages/es.json @@ -516,6 +516,9 @@ "heroItemsTitle": "Destacados de hoy", "heroItemsDescription": "Elige qué tipos de elementos pueden aparecer en los destacados de hoy.", "heroItemsNotificationNote": "Los ajustes de notificaciones se gestionan por separado.", + "heroContentTitle": "La tarjeta superior muestra", + "heroContentScore": "Puntuación de salud y resumen del día", + "heroContentReminders": "Destacados de hoy", "heroItems": { "coach_checkin": "Revisión con Coach", "dose_window": "Dosis de medicamentos", @@ -4529,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health no tiene conexión web — termina aquí la puesta en marcha y luego abre la app HealthLog en tu iPhone para completar la configuración de Apple Health." }, "import": "Importar historial existente" }, @@ -6388,6 +6392,13 @@ "importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.", "importAction": "Abrir importación puntual", "staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.", + "progress": { + "title": "Progreso de sincronización", + "receivedLabel": "Registros recibidos", + "oldestLabel": "Lectura más antigua alcanzada", + "flowing": "Han llegado datos en la última hora — la app del iPhone sigue enviando.", + "waiting": "Esperando a que la app del iPhone envíe más datos. La transferencia continúa cuando la app está abierta o cuando iOS le concede tiempo en segundo plano — una pausa durante la primera sincronización grande es normal, no un error." + }, "delivery": { "title": "Entrega", "lastTriggerLabel": "La última sincronización llegó", @@ -9710,8 +9721,6 @@ "deleteConfirmTitle": "¿Eliminar esta métrica personalizada?", "deleteConfirmDescription": "La métrica se quita de tu lista. Los valores registrados se conservan.", "deletedToast": "Métrica personalizada eliminada", - "restoredToast": "Valor restaurado", - "restoreError": "No se pudo restaurar", "deleteError": "No se pudo eliminar la métrica personalizada", "showAllValues": "Ver todos los valores", "emptyDescription": "Define tus propias mediciones para registrarlas y representarlas.", @@ -9761,6 +9770,8 @@ "editedToast": "Valor actualizado", "editError": "No se pudo actualizar el valor", "deletedToast": "Valor eliminado", + "restoredToast": "Valor restaurado", + "restoreError": "No se pudo restaurar", "deleteError": "No se pudo eliminar el valor" }, "chart": { diff --git a/messages/fr.json b/messages/fr.json index ce9f99f5e..0cfb46887 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -516,6 +516,9 @@ "heroItemsTitle": "Temps forts du jour", "heroItemsDescription": "Choisis les types d’éléments qui peuvent apparaître dans les temps forts du jour.", "heroItemsNotificationNote": "Les réglages des notifications sont gérés séparément.", + "heroContentTitle": "La carte du haut affiche", + "heroContentScore": "Score de santé et résumé du jour", + "heroContentReminders": "Temps forts du jour", "heroItems": { "coach_checkin": "Bilan avec Coach", "dose_window": "Doses de médicaments", @@ -4529,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health n'a pas de connexion web — terminez d'abord la configuration ici, puis ouvrez l'app HealthLog sur votre iPhone pour finaliser la configuration d'Apple Health." }, "import": "Importer un historique existant" }, @@ -6388,6 +6392,13 @@ "importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.", "importAction": "Ouvrir l’import ponctuel", "staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.", + "progress": { + "title": "Progression de la synchronisation", + "receivedLabel": "Enregistrements reçus", + "oldestLabel": "Mesure la plus ancienne atteinte", + "flowing": "Des données sont arrivées au cours de la dernière heure — l'app iPhone continue d'envoyer.", + "waiting": "En attente de nouvelles données de l'app iPhone. Le transfert reprend quand l'app est ouverte ou quand iOS lui accorde du temps en arrière-plan — une accalmie pendant le premier grand rattrapage est normale, pas une erreur." + }, "delivery": { "title": "Livraison", "lastTriggerLabel": "La dernière synchronisation est arrivée", @@ -9710,8 +9721,6 @@ "deleteConfirmTitle": "Supprimer cette mesure personnalisée ?", "deleteConfirmDescription": "La mesure est retirée de votre liste. Les valeurs enregistrées sont conservées.", "deletedToast": "Mesure personnalisée supprimée", - "restoredToast": "Valeur restaurée", - "restoreError": "Échec de la restauration", "deleteError": "Impossible de supprimer la mesure personnalisée", "showAllValues": "Afficher toutes les valeurs", "emptyDescription": "Définissez vos propres mesures pour les suivre et les représenter.", @@ -9761,6 +9770,8 @@ "editedToast": "Valeur mise à jour", "editError": "Impossible de mettre à jour la valeur", "deletedToast": "Valeur supprimée", + "restoredToast": "Valeur restaurée", + "restoreError": "Échec de la restauration", "deleteError": "Impossible de supprimer la valeur" }, "chart": { diff --git a/messages/it.json b/messages/it.json index f24b77f37..11e37762e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -516,6 +516,9 @@ "heroItemsTitle": "In evidenza oggi", "heroItemsDescription": "Scegli quali tipi di elementi possono apparire nella sezione In evidenza oggi.", "heroItemsNotificationNote": "Le impostazioni delle notifiche vengono gestite separatamente.", + "heroContentTitle": "La scheda in alto mostra", + "heroContentScore": "Punteggio di salute e riepilogo del giorno", + "heroContentReminders": "In evidenza oggi", "heroItems": { "coach_checkin": "Verifiche con Coach", "dose_window": "Dosi dei farmaci", @@ -4529,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health non ha una connessione web — completa qui la configurazione iniziale, poi apri l'app HealthLog sul tuo iPhone per completare la configurazione di Apple Health." }, "import": "Importa la cronologia esistente" }, @@ -6388,6 +6392,13 @@ "importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.", "importAction": "Apri importazione una tantum", "staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.", + "progress": { + "title": "Avanzamento della sincronizzazione", + "receivedLabel": "Record ricevuti", + "oldestLabel": "Lettura più vecchia raggiunta", + "flowing": "Nell'ultima ora sono arrivati dati — l'app per iPhone sta ancora inviando.", + "waiting": "In attesa che l'app per iPhone invii altri dati. Il trasferimento continua quando l'app è aperta o quando iOS le concede tempo in background — una pausa durante il primo grande recupero è normale, non un errore." + }, "delivery": { "title": "Consegna", "lastTriggerLabel": "L'ultima sincronizzazione è arrivata", @@ -9710,8 +9721,6 @@ "deleteConfirmTitle": "Eliminare questa metrica personalizzata?", "deleteConfirmDescription": "La metrica viene rimossa dal tuo elenco. I valori registrati vengono mantenuti.", "deletedToast": "Metrica personalizzata eliminata", - "restoredToast": "Valore ripristinato", - "restoreError": "Ripristino non riuscito", "deleteError": "Impossibile eliminare la metrica personalizzata", "showAllValues": "Mostra tutti i valori", "emptyDescription": "Definisci le tue misurazioni per monitorarle e rappresentarle.", @@ -9761,6 +9770,8 @@ "editedToast": "Valore aggiornato", "editError": "Impossibile aggiornare il valore", "deletedToast": "Valore eliminato", + "restoredToast": "Valore ripristinato", + "restoreError": "Ripristino non riuscito", "deleteError": "Impossibile eliminare il valore" }, "chart": { diff --git a/messages/pl.json b/messages/pl.json index cbc72c276..d1a425342 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -516,6 +516,9 @@ "heroItemsTitle": "Dzisiejsze wyróżnienia", "heroItemsDescription": "Wybierz typy elementów, które mogą pojawiać się w dzisiejszych wyróżnieniach.", "heroItemsNotificationNote": "Ustawieniami powiadomień zarządzasz osobno.", + "heroContentTitle": "Górna karta pokazuje", + "heroContentScore": "Wynik zdrowia i podsumowanie dnia", + "heroContentReminders": "Dzisiejsze wyróżnienia", "heroItems": { "coach_checkin": "Podsumowania z Coachem", "dose_window": "Dawki leków", @@ -4529,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health nie ma połączenia przez przeglądarkę — dokończ tu wstępną konfigurację, a następnie otwórz aplikację HealthLog na iPhonie, aby dokończyć konfigurację Apple Health." }, "import": "Importuj istniejącą historię" }, @@ -6388,6 +6392,13 @@ "importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.", "importAction": "Otwórz jednorazowy import", "staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.", + "progress": { + "title": "Postęp synchronizacji", + "receivedLabel": "Odebrane rekordy", + "oldestLabel": "Najstarszy osiągnięty pomiar", + "flowing": "W ciągu ostatniej godziny dotarły dane — aplikacja na iPhonie nadal je przesyła.", + "waiting": "Oczekiwanie na kolejne dane z aplikacji na iPhonie. Przesyłanie trwa dalej, gdy aplikacja jest otwarta lub gdy iOS przydzieli jej czas w tle — cisza podczas pierwszego dużego uzupełniania historii jest normalna, to nie błąd." + }, "delivery": { "title": "Dostarczanie", "lastTriggerLabel": "Ostatnia synchronizacja dotarła", @@ -9710,8 +9721,6 @@ "deleteConfirmTitle": "Usunąć tę metrykę własną?", "deleteConfirmDescription": "Metryka zostanie usunięta z listy. Zapisane wartości pozostaną.", "deletedToast": "Usunięto metrykę własną", - "restoredToast": "Przywrócono wartość", - "restoreError": "Nie udało się przywrócić", "deleteError": "Nie udało się usunąć metryki własnej", "showAllValues": "Pokaż wszystkie wartości", "emptyDescription": "Zdefiniuj własne pomiary, aby je śledzić i przedstawiać na wykresie.", @@ -9761,6 +9770,8 @@ "editedToast": "Zaktualizowano wartość", "editError": "Nie udało się zaktualizować wartości", "deletedToast": "Usunięto wartość", + "restoredToast": "Przywrócono wartość", + "restoreError": "Nie udało się przywrócić", "deleteError": "Nie udało się usunąć wartości" }, "chart": { diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index d37eee72c..09cd78e1b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -875,6 +875,10 @@ const DELEGABLE_ROUTES: Record = { domain: "mind", why: "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.", }, + "app/api/mental-health/assessments/[id]/route.ts": { + domain: "mind", + why: "One administration of the record, fetch-then-guard against the resolved user, WITH its decrypted per-item answers. Admitted at the same level as the list beside it deliberately: the mind read grant already serves the mood diary's decrypted prose and the list already publishes the item-9 flag, so the per-item values sit inside what the domain shows, not beyond it. The route comment carries the argument; the module gate resolves against the record like the sibling.", + }, "app/api/anamnesis/facts/route.ts": { domain: "profile", why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", @@ -1937,8 +1941,12 @@ const ACTOR_ROUTES: Record = { * v1.37.20 (#223 / iOS #68) -- Vorsorge skip/snooze adds five: skip, snooze * and the completion-ledger history read on the record list, plus skip and * snooze on the manage literal. 224 -> 229. + * + * The screener detail read adds one: the per-administration read that + * decrypts the stored item answers, on the record list at the same level as + * the screener history beside it. 229 -> 230. */ -const FROZEN_ENTRY_COUNT = 229; +const FROZEN_ENTRY_COUNT = 230; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 8d5ccc2ef..79b1b0a9e 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -140,7 +140,9 @@ const PINNED_AFFORDANCES: Record< "toast.success": 1, }, "src/components/custom-metrics/custom-metric-history-list.tsx": { - "toast.success": 2, + // v1.37.20 (A3-11) — the third success is the restore confirmation + // behind the delete toast's Undo action. + "toast.success": 3, }, "src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 }, // v1.36.x — two call sites, one decision: the intake toast routes through diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 97250c15b..34bfae2db 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(75); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts index 735060c17..8c0d14034 100644 --- a/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts +++ b/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts @@ -107,8 +107,8 @@ describe("DELETE /api/custom-metrics/[id]/entries/[entryId]", () => { ); expect(res.status).toBe(404); - const where = vi.mocked(prisma.customMetricEntry.findFirst).mock.calls[0][0] - .where; + const where = vi.mocked(prisma.customMetricEntry.findFirst).mock + .calls[0]![0]!.where!; expect(where.deletedAt).toBeNull(); expect(prisma.customMetricEntry.update).not.toHaveBeenCalled(); }); diff --git a/src/app/api/mental-health/assessments/[id]/__tests__/route.test.ts b/src/app/api/mental-health/assessments/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..be19cee4b --- /dev/null +++ b/src/app/api/mental-health/assessments/[id]/__tests__/route.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * `GET /api/mental-health/assessments/[id]` — the per-administration detail + * read that finally consumes `responsesEncrypted` (audit finding: the column + * was write-only; its single reader was key rotation). + * + * Pins the two halves of the contract: + * (a) the owner's detail read decrypts the stored blob and returns the + * per-item answers + the unscored functional follow-up, and + * (b) a decrypt failure (key gap / corruption / shape drift) degrades to + * `items: null` + `itemsUnavailable: true` while the denormalised + * total / band / flag still answer — never a 500 for the whole row + * (the records-DTO fail-soft precedent). + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + mentalHealthAssessment: { findFirst: vi.fn() }, + }, +})); + +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: vi.fn(), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callGet = GET as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) => Promise; + +function makeReq( + id: string, +): [NextRequest, { params: Promise<{ id: string }> }] { + return [ + new NextRequest( + new URL(`http://localhost/api/mental-health/assessments/${id}`), + { method: "GET" }, + ), + { params: Promise.resolve({ id }) }, + ]; +} + +/** A stored PHQ-9 administration as the select returns it. */ +function storedRow(overrides: Record = {}) { + return { + id: "mha_1", + userId: "user-1", + instrument: "PHQ9", + locale: "en", + version: "standard", + totalScore: 10, + severityBand: "moderate", + item9Flagged: true, + crisisShownAt: new Date("2026-06-28T00:00:00.000Z"), + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + responsesEncrypted: Buffer.from("ciphertext"), + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); +}); + +describe("GET /api/mental-health/assessments/[id]", () => { + it("returns the decrypted per-item answers + functional follow-up for the owner", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + storedRow() as never, + ); + vi.mocked(decryptFromBytes).mockReturnValue( + JSON.stringify({ + items: [1, 1, 1, 1, 1, 1, 1, 1, 2], + functionalDifficulty: 1, + schema: 1, + }), + ); + + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + assessment: { + id: string; + totalScore: number; + items: number[] | null; + functionalDifficulty: number | null; + itemsUnavailable: boolean; + }; + }; + }; + expect(body.data.assessment.id).toBe("mha_1"); + expect(body.data.assessment.items).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 2]); + expect(body.data.assessment.functionalDifficulty).toBe(1); + expect(body.data.assessment.itemsUnavailable).toBe(false); + // The denormalised score fields ride along unchanged. + expect(body.data.assessment.totalScore).toBe(10); + }); + + it("omits functionalDifficulty as null when the stored blob has none", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + storedRow({ + instrument: "GAD7", + totalScore: 6, + item9Flagged: false, + }) as never, + ); + vi.mocked(decryptFromBytes).mockReturnValue( + JSON.stringify({ items: [1, 2, 0, 1, 1, 1, 0], schema: 1 }), + ); + + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + assessment: { + items: number[] | null; + functionalDifficulty: number | null; + }; + }; + }; + expect(body.data.assessment.items).toEqual([1, 2, 0, 1, 1, 1, 0]); + expect(body.data.assessment.functionalDifficulty).toBeNull(); + }); + + it("degrades to items: null + itemsUnavailable on a decrypt failure — never a 500", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + storedRow() as never, + ); + vi.mocked(decryptFromBytes).mockImplementation(() => { + throw new Error("Unknown encryption key id: v9"); + }); + + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + assessment: { + totalScore: number; + items: number[] | null; + itemsUnavailable: boolean; + }; + }; + }; + expect(body.data.assessment.items).toBeNull(); + expect(body.data.assessment.itemsUnavailable).toBe(true); + // The score half of the row still answers. + expect(body.data.assessment.totalScore).toBe(10); + }); + + it("degrades on a malformed or shape-drifted plaintext (wrong item count)", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + storedRow() as never, + ); + // 3 answers on a stored PHQ-9 row: pairing them to the 9 official item + // texts would mislabel every answer, so the read refuses to guess. + vi.mocked(decryptFromBytes).mockReturnValue( + JSON.stringify({ items: [1, 2, 3], schema: 1 }), + ); + + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + assessment: { items: number[] | null; itemsUnavailable: boolean }; + }; + }; + expect(body.data.assessment.items).toBeNull(); + expect(body.data.assessment.itemsUnavailable).toBe(true); + }); + + it("404s another user's row without leaking its existence", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + storedRow({ userId: "user-2" }) as never, + ); + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(404); + // The item content never left the guard. + expect(decryptFromBytes).not.toHaveBeenCalled(); + }); + + it("404s a missing row", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + null as never, + ); + const res = await callGet(...makeReq("mha_missing")); + expect(res.status).toBe(404); + }); + + it("returns the module 403 when the mental-health module is off", async () => { + const { apiError } = await import("@/lib/api-response"); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError("disabled", 403, { errorCode: "module.disabled" }), + }); + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(403); + expect(prisma.mentalHealthAssessment.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mental-health/assessments/[id]/route.ts b/src/app/api/mental-health/assessments/[id]/route.ts new file mode 100644 index 000000000..3472b760a --- /dev/null +++ b/src/app/api/mental-health/assessments/[id]/route.ts @@ -0,0 +1,175 @@ +/** + * `GET /api/mental-health/assessments/[id]` — one screener administration WITH + * its decrypted per-item answers. + * + * The list route beside this one denormalises total + band + item-9 flag for + * cheap history reads and deliberately never touches `responsesEncrypted`. + * Until this route existed nothing read the blob back at all (its only + * consumer was key rotation), so the stored clinical detail — including the + * PHQ-9 item-9 answer — was invisible to the person it belongs to. This is + * the read side of that write. + * + * GATING — same declaration as the sibling list read (`read`, `mind`), on + * purpose: the mind domain already serves decrypted free text at this level + * (the mood entry's encrypted note on `GET /api/mood-entries/[id]`, the Coach + * reminder notes), and the list read already publishes `item9Flagged` — the + * fact that the self-harm item was answered non-zero. A delegate the owner + * trusted with a mind-scope read grant therefore learns nothing here that is + * categorically beyond what the domain already shows; splitting the per-item + * values onto a stricter gate than the mood diary's prose would be an + * inconsistency, not a protection. The module gate resolves against the + * RECORD, so the surface stays unreachable wherever the owner keeps the + * module off. + * + * DECRYPT FAILURES degrade honestly (the records-DTO fail-soft precedent): + * `items: null` + `itemsUnavailable: true`, with the denormalised score + * fields still answering — a key gap on one row never 500s the read. A + * plaintext whose item count no longer matches the instrument degrades the + * same way, because pairing answers to the wrong official item texts would be + * worse than showing none. + */ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { + INSTRUMENTS, + type InstrumentDefinition, + type InstrumentId, +} from "@/lib/mental-health/instruments"; + +type RouteParams = { params: Promise<{ id: string }> }; + +interface DecodedResponses { + /** Per-item answers in presentation order, or null when unreadable. */ + items: number[] | null; + /** The unscored PHQ-9 functional follow-up, when it was answered. */ + functionalDifficulty: number | null; + itemsUnavailable: boolean; +} + +const UNAVAILABLE: DecodedResponses = { + items: null, + functionalDifficulty: null, + itemsUnavailable: true, +}; + +/** + * Decrypt + decode the stored `{ items, functionalDifficulty?, schema: 1 }` + * blob, fail-soft. Every answer is re-checked against the instrument's own + * ceiling before it is served: the write path validated exactly these bounds, + * so anything outside them is corruption, not data. + */ +function decodeResponses( + buf: Uint8Array, + def: InstrumentDefinition, +): DecodedResponses { + let parsed: unknown; + try { + parsed = JSON.parse(decryptFromBytes(buf)); + } catch (err) { + // Undecryptable payload (key gap / corruption): fail soft but log it + // (F-CRYPTO-2) so a systemic key gap surfaces instead of reading blank. + getEvent()?.addWarning( + `assessment responses decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNAVAILABLE; + } + if (typeof parsed !== "object" || parsed === null) return UNAVAILABLE; + const { items, functionalDifficulty } = parsed as { + items?: unknown; + functionalDifficulty?: unknown; + }; + const valid = + Array.isArray(items) && + items.length === def.itemCount && + items.every( + (v) => Number.isInteger(v) && (v as number) >= 0 && v <= def.itemMax, + ); + if (!valid) { + getEvent()?.addWarning( + "assessment responses shape drifted from the instrument definition", + ); + return UNAVAILABLE; + } + return { + items: items as number[], + functionalDifficulty: + Number.isInteger(functionalDifficulty) && + (functionalDifficulty as number) >= 0 && + (functionalDifficulty as number) <= 3 + ? (functionalDifficulty as number) + : null, + itemsUnavailable: false, + }; +} + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("read", "mind"); + + // Opt-in module (default OFF) — the same record-resolved gate as the list. + const gate = await requireModuleEnabled(user.id, "mentalHealth"); + if (!gate.enabled) return gate.response; + + const { id } = await params; + + // Fetch-then-guard against the resolved user (the by-id sibling pattern); + // a soft-deleted row 404s like it does on the list. + const row = await prisma.mentalHealthAssessment.findFirst({ + where: { id, deletedAt: null }, + select: { + id: true, + userId: true, + instrument: true, + locale: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, + responsesEncrypted: true, + }, + }); + if (!row || row.userId !== user.id) { + return apiError("Assessment not found", 404); + } + + const def = INSTRUMENTS[row.instrument as InstrumentId]; + const decoded = decodeResponses(row.responsesEncrypted, def); + + annotate({ + action: { name: "mental-health.detail" }, + // The instrument + degrade flag only — item answers are NEVER logged. + meta: { + instrument: row.instrument, + items_unavailable: decoded.itemsUnavailable, + }, + }); + + return apiSuccess({ + assessment: { + id: row.id, + instrument: row.instrument, + locale: row.locale, + version: row.version, + totalScore: row.totalScore, + severityBand: row.severityBand, + item9Flagged: row.item9Flagged, + crisisShownAt: row.crisisShownAt?.toISOString() ?? null, + takenAt: row.takenAt.toISOString(), + createdAt: row.createdAt.toISOString(), + items: decoded.items, + functionalDifficulty: decoded.functionalDifficulty, + itemsUnavailable: decoded.itemsUnavailable, + }, + }); + }, +); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index c89f61540..a37b223b3 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -13,7 +13,8 @@ * interpretation, no AI ingestion of item content, no third-party alerting. * * GET lists the caller's assessments (totals + bands + flags only — item-level - * answers are excluded from this surface by construction). + * answers are excluded from this surface by construction; the by-id sibling + * route is the one read that decrypts them back). */ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index 5e6a9f1df..869e18532 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -28,6 +28,8 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({ update: { mutate: vi.fn(), isPending: false }, remove: { mutate: vi.fn(), isPending: false }, satisfy: { mutate: vi.fn(), isPending: false }, + skip: { mutate: vi.fn(), isPending: false }, + snooze: { mutate: vi.fn(), isPending: false }, }), })); diff --git a/src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx b/src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx new file mode 100644 index 000000000..3ec89a015 --- /dev/null +++ b/src/components/mental-health/__tests__/assessment-item-breakdown.test.tsx @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import en from "../../../../messages/en.json"; + +/** + * The expandable per-item answer breakdown (the read side of + * `responsesEncrypted`). SSR-only convention (`renderToStaticMarkup`) — the + * detail query is seeded into the cache, the fetch never runs. + * + * Pins: the official item texts render from the SAME `mentalHealth.items.*` + * keys the check-in wizard uses (validated instrument copy, never re-worded), + * each answer shows its option label + value, PHQ-9 item 9 gets NO alarm + * styling, and the server's decrypt-failure degrade renders as the honest + * unavailable line rather than a blank. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiPost: () => new Promise(() => {}), +})); + +import { AssessmentItemBreakdown } from "../assessment-item-breakdown"; +import type { AssessmentDetail } from "../types"; + +const mh = en.mentalHealth; + +function detailRow(overrides: Partial): AssessmentDetail { + return { + id: "mha_1", + instrument: "PHQ9", + locale: "en", + totalScore: 10, + severityBand: "moderate", + item9Flagged: true, + crisisShownAt: null, + takenAt: "2026-06-28T00:00:00.000Z", + items: [1, 1, 1, 1, 1, 1, 1, 1, 2], + functionalDifficulty: null, + itemsUnavailable: false, + ...overrides, + }; +} + +function render(detail: AssessmentDetail): string { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.mentalHealthAssessmentDetail(detail.id), { + assessment: detail, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("AssessmentItemBreakdown", () => { + it("renders every official PHQ-9 item text with its answer label + value", () => { + const html = render(detailRow({})); + // All nine validated item texts, from the wizard's own keys. + for (let n = 1; n <= 9; n++) { + expect(html).toContain( + mh.items.phq9[String(n) as keyof typeof mh.items.phq9], + ); + } + // Answer labels are the shared PHQ/GAD anchors. + expect(html).toContain(mh.options["1"]); + expect(html).toContain(mh.options["2"]); + }); + + it("gives PHQ-9 item 9 no alarm styling — it renders like every other item", () => { + const html = render(detailRow({})); + expect(html).toContain(mh.items.phq9["9"]); + expect(html).not.toContain("text-destructive"); + }); + + it("renders the unscored functional follow-up when it was answered", () => { + const html = render(detailRow({ functionalDifficulty: 2 })); + expect(html).toContain(mh.functionalTitle); + expect(html).toContain(mh.functional["2"]); + }); + + it("omits the functional follow-up row when it was not answered", () => { + const html = render(detailRow({ functionalDifficulty: null })); + expect(html).not.toContain(mh.functionalTitle); + }); + + it("paints the WHO-5 recall stem once above its run of items", () => { + const html = render( + detailRow({ + instrument: "WHO5", + items: [5, 4, 3, 2, 1], + item9Flagged: false, + }), + ); + // The shared stem appears exactly once, not per item. + const stem = mh.stems.who5.period; + expect(html.split(stem).length - 1).toBe(1); + expect(html).toContain(mh.items.who5["1"]); + expect(html).toContain(mh.who5Options["5"]); + }); + + it("renders the honest unavailable line on the server's decrypt degrade", () => { + const html = render(detailRow({ items: null, itemsUnavailable: true })); + expect(html).toContain(mh.history.answersUnavailable); + expect(html).not.toContain(mh.items.phq9["1"]); + }); +}); diff --git a/src/components/mental-health/assessment-history.tsx b/src/components/mental-health/assessment-history.tsx index bc59c1402..42ffa7abd 100644 --- a/src/components/mental-health/assessment-history.tsx +++ b/src/components/mental-health/assessment-history.tsx @@ -15,6 +15,11 @@ * the row's stored locale (`crisisResourcesForLocale`) — it NEVER decrypts or * reveals the item-9 answer, and surfacing it triggers no third-party alert. * No new data leaves the safety boundary. + * + * Each row also carries an opt-in answer breakdown: expanding it fetches the + * per-administration detail read, where the server decrypts the stored + * per-item answers (`AssessmentItemBreakdown`). Item 9 renders there like any + * other item — the flagged marker above stays the one crisis affordance. */ import { useMemo, useState } from "react"; import dynamic from "next/dynamic"; @@ -28,8 +33,10 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { importWithRetry } from "@/lib/retry-import"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources"; -import { Activity } from "lucide-react"; +import { Activity, ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { AssessmentItemBreakdown } from "./assessment-item-breakdown"; import { CrisisCard } from "./crisis-card"; import type { AssessmentRow, CrisisSet, InstrumentId } from "./types"; @@ -80,6 +87,11 @@ export function AssessmentHistory({ const { date: formatDate } = useFormatters(); // Which flagged row's crisis card is currently expanded (re-surfaced). const [openCrisisRowId, setOpenCrisisRowId] = useState(null); + // Which row's per-item answer breakdown is expanded (single-open, like the + // crisis card — the detail fetch runs only when someone asks). + const [openBreakdownRowId, setOpenBreakdownRowId] = useState( + null, + ); const forInstrument = useMemo( () => @@ -116,6 +128,7 @@ export function AssessmentHistory({ {forInstrument.map((row) => { const flagged = row.item9Flagged; const open = openCrisisRowId === row.id; + const breakdownOpen = openBreakdownRowId === row.id; return (
  • @@ -146,9 +159,37 @@ export function AssessmentHistory({ {row.totalScore} +
  • {flagged && open && } + {breakdownOpen && ( +
    + +
    + )} ); })} diff --git a/src/components/mental-health/assessment-item-breakdown.tsx b/src/components/mental-health/assessment-item-breakdown.tsx new file mode 100644 index 000000000..95d20d729 --- /dev/null +++ b/src/components/mental-health/assessment-item-breakdown.tsx @@ -0,0 +1,136 @@ +"use client"; + +/** + * Per-item answer breakdown for one recorded administration, expanded from a + * history row. Fetches the detail read (`GET …/assessments/[id]`), which is + * where the server decrypts `responsesEncrypted` — the answers never ride the + * list payload. + * + * Grammar: the detail surface's label/value rows — official item text muted + * (validated instrument copy via the SAME `mentalHealth.items.*` keys the + * check-in wizard renders; never re-worded here), the chosen answer label + + * its value in foreground. The WHO-5 / SCI recall stems paint once above each + * run of items, as on the source forms. PHQ-9 item 9 renders like every other + * item — no alarm colour; the row's existing "support shown" marker is the + * crisis affordance and is not duplicated here. + */ +import { useQuery } from "@tanstack/react-query"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { + INSTRUMENTS, + optionLabelKey, + stemKey, +} from "@/lib/mental-health/instruments"; + +import type { AssessmentDetail, InstrumentId } from "./types"; + +export function AssessmentItemBreakdown({ + assessmentId, + instrument, +}: { + assessmentId: string; + instrument: InstrumentId; +}) { + const { t } = useTranslations(); + const def = INSTRUMENTS[instrument]; + + const { data, isPending, isError } = useQuery({ + queryKey: queryKeys.mentalHealthAssessmentDetail(assessmentId), + queryFn: () => + apiGet<{ assessment: AssessmentDetail }>( + `/api/mental-health/assessments/${assessmentId}`, + ), + }); + + if (isPending) { + return ( +
    + {Array.from({ length: def.itemCount }, (_, i) => ( + + ))} +
    + ); + } + + if (isError || !data) { + return ( +

    + {t("mentalHealth.error")} +

    + ); + } + + const detail = data.assessment; + if (detail.items === null) { + // The server's honest degrade: the blob exists but cannot be decrypted + // (key gap / corruption). The score above this breakdown still answers. + return ( +

    + {t("mentalHealth.history.answersUnavailable")} +

    + ); + } + + return ( +
    + {detail.items.map((value, index) => { + // Stem captions group runs of items exactly as the wizard presents + // them: paint the stem only where it differs from the previous item's. + const stem = stemKey(instrument, index); + const caption = + stem !== (index > 0 ? stemKey(instrument, index - 1) : null) + ? stem + : null; + return ( +
    + {caption && ( +

    + {t(`mentalHealth.stems.${caption}`)} +

    + )} +
    + + {t(`mentalHealth.items.${def.i18nKey}.${index + 1}`)} + + + {t(optionLabelKey(instrument, index, value))} + {" · "} + {value} + +
    +
    + ); + })} + {detail.functionalDifficulty !== null && ( +
    + + {t("mentalHealth.functionalTitle")} + + {/* Unscored follow-up: the label alone, no value — it never counts + into the total and a number would imply it does. */} + + {t(`mentalHealth.functional.${detail.functionalDifficulty}`)} + +
    + )} +
    + ); +} diff --git a/src/components/mental-health/types.ts b/src/components/mental-health/types.ts index 7b66ca1ff..26d47f766 100644 --- a/src/components/mental-health/types.ts +++ b/src/components/mental-health/types.ts @@ -4,8 +4,9 @@ * The 387-line monolith split into a folder of focused components; these are * the seams they plug into. The wire shapes mirror the assessment route's * `shapeRow` / create response (`src/app/api/mental-health/assessments/route.ts`) - * — totals + bands + flags only, NEVER item content (the item answers stay - * encrypted at rest and never ride this surface). + * — totals + bands + flags only on the list. The per-item answers ride ONLY + * the per-administration detail read (`…/assessments/[id]`), which decrypts + * them for the history's expandable answer breakdown. */ import type { InstrumentId } from "@/lib/mental-health/instruments"; @@ -25,6 +26,18 @@ export interface AssessmentRow { takenAt: string; } +/** + * One administration with its decrypted answers (`GET …/assessments/[id]`). + * `items: null` + `itemsUnavailable` is the server's honest degrade for a row + * whose blob cannot be decrypted — the score fields still answer. + */ +export interface AssessmentDetail extends AssessmentRow { + items: number[] | null; + /** The unscored PHQ-9 functional follow-up, when it was answered. */ + functionalDifficulty: number | null; + itemsUnavailable: boolean; +} + export interface CrisisSet { emergencyNumber: string; resources: { id: string; contacts: string[] }[]; diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 5ba9cb414..34826a27c 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -124,9 +124,11 @@ describe("evaluateMeasurementReminderDue — window boundary", () => { interface FakeReminder { id: string; + userId: string; /** Absent on a checkup fixture; `"ENCOUNTER"` on an appointment's row. */ origin?: string; measurementType: string | null; + lastSkippedAt: Date | null; intervalDays: number | null; rrule: string | null; anchorDate: Date | null; @@ -195,6 +197,14 @@ function makePrisma(opts: { labResult: { findFirst: vi.fn(async () => opts.labMatch ?? null), }, + // v1.37.20 (#223 / iOS #68) — the satisfy primitive appends a ledger + // row per applied satisfy. + measurementReminderEvent: { + create: vi.fn(async ({ data }: { data: Record }) => ({ + id: "evt", + ...data, + })), + }, }; return { prisma, updates }; } @@ -202,6 +212,7 @@ function makePrisma(opts: { function reminder(overrides: Partial): FakeReminder { return { id: "r1", + userId: "u1", measurementType: "BLOOD_PRESSURE_SYS", intervalDays: 7, rrule: null, @@ -210,6 +221,7 @@ function reminder(overrides: Partial): FakeReminder { location: null, nextDueAt: new Date("2026-06-14T07:00:00Z"), // past at NINE_LOCAL lastSatisfiedAt: null, + lastSkippedAt: null, enabled: true, createdAt: new Date("2026-06-01T00:00:00Z"), user: { diff --git a/src/lib/jobs/__tests__/reminder-satisfy.test.ts b/src/lib/jobs/__tests__/reminder-satisfy.test.ts index b67ac2e74..dfc04c2b1 100644 --- a/src/lib/jobs/__tests__/reminder-satisfy.test.ts +++ b/src/lib/jobs/__tests__/reminder-satisfy.test.ts @@ -96,6 +96,14 @@ function makePrisma(opts: { }, ), }, + // v1.37.20 (#223 / iOS #68) — the primitive appends a ledger row per + // applied satisfy; the harness records them so tests can assert one. + measurementReminderEvent: { + create: vi.fn(async ({ data }: { data: Record }) => ({ + id: "evt", + ...data, + })), + }, measurement: { findFirst: vi.fn(async () => opts.measurement ?? null) }, labResult: { findFirst: vi.fn(async () => opts.lab ?? null) }, }; @@ -105,12 +113,15 @@ function makePrisma(opts: { function reminderRow(overrides: Record = {}) { return { id: "r1", + userId: "u1", measurementType: "WEIGHT", intervalDays: 7, rrule: null, anchorDate: null, notifyHour: 9, + nextDueAt: null, lastSatisfiedAt: null, + lastSkippedAt: null, createdAt: new Date("2026-06-01T00:00:00Z"), ...overrides, }; diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 9f60e092c..565cf19f7 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -4,11 +4,12 @@ * * Part of the OpenAPI route table; aggregated in `./index.ts`. The request body * reuses the runtime Zod schema from `@/lib/validations/mental-health` so the - * wire contract stays single-source. The response deliberately excludes the - * raw item answers — only the server-authoritative total / band / safety flag - * are exposed. The iOS client mirrors this DTO and MUST surface its own - * crisis-resource card on a positive item-9 flag (never rely on server text - * alone). + * wire contract stays single-source. The list / create responses deliberately + * exclude the raw item answers — only the server-authoritative total / band / + * safety flag are exposed there; the per-administration detail read is the one + * surface that returns the decrypted answers. The iOS client mirrors these + * DTOs and MUST surface its own crisis-resource card on a positive item-9 + * flag (never rely on server text alone). */ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; @@ -48,7 +49,19 @@ const assessmentRow = z .meta({ id: "MentalHealthAssessment", description: - "One completed screener administration. `severityBand` is a descriptive label for the screen (never a diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent.", + "One completed screener administration. `severityBand` is a descriptive label for the screen (never a diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read returns them decrypted.", + }); + +const assessmentDetail = assessmentRow + .extend({ + items: z.array(z.number().int()).nullable(), + functionalDifficulty: z.number().int().nullable(), + itemsUnavailable: z.boolean(), + }) + .meta({ + id: "MentalHealthAssessmentDetail", + description: + "One administration with its decrypted per-item answers, in the order the instrument presents its items (PHQ-9/GAD-7 answers 0–3, SCI 0–4, WHO-5 0–5). `functionalDifficulty` is the PHQ-9's unscored functional follow-up when it was answered. When the stored blob cannot be decrypted the read degrades honestly: `items` is null, `itemsUnavailable` is true, and the denormalised score fields still answer.", }); const crisisResource = z @@ -81,6 +94,10 @@ const listResponse = z .object({ assessments: z.array(assessmentRow) }) .meta({ id: "ListMentalHealthAssessmentsResponse" }); +const detailResponse = z + .object({ assessment: assessmentDetail }) + .meta({ id: "GetMentalHealthAssessmentResponse" }); + export const mentalHealthPaths: NonNullable = { "/api/mental-health/assessments": { get: { @@ -132,4 +149,28 @@ export const mentalHealthPaths: NonNullable = { }, }, }, + "/api/mental-health/assessments/{id}": { + get: { + tags: ["Mental health"], + summary: "Read one administration with its decrypted answers", + description: + "Returns a single screener administration including the decrypted per-item answers (and the PHQ-9's unscored functional follow-up when answered) — the one surface that reads them back. When the stored blob cannot be decrypted the read degrades to `items: null` + `itemsUnavailable: true` instead of failing; the total / band / flag still answer. A soft-deleted or foreign row answers 404.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + ...recordRefusal(), + "200": { + description: "The administration with its answer breakdown.", + content: { + "application/json": { + schema: dataEnvelope( + detailResponse, + "GetMentalHealthAssessmentEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/query-keys/mental-health.ts b/src/lib/query-keys/mental-health.ts index c147fff3c..fe7382d7a 100644 --- a/src/lib/query-keys/mental-health.ts +++ b/src/lib/query-keys/mental-health.ts @@ -6,4 +6,7 @@ export const mentalHealthKeys = { /** The caller's screener history, optionally filtered by instrument. */ mentalHealthAssessments: (instrument?: string) => ["mental-health-assessments", instrument ?? null] as const, + /** One administration with its decrypted per-item answers. */ + mentalHealthAssessmentDetail: (id: string) => + ["mental-health-assessment-detail", id] as const, }; From 9d64eb6aa20d487ccc45d181ca5745c01b0efb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:51:40 +0200 Subject: [PATCH 12/20] Show what a backup contains before the operator types RESTORE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore dialog asked for its typed confirmation on nothing but a filename and a date; the counts only surfaced in the audit row after the rows were already replaced. A read-only summary endpoint now decrypts and schema-validates the stored file through the same helpers the restore itself runs and answers the summarizeBackup counts, and the dialog shows them — per section, with an other-records line so a future summary key can never silently vanish from the preview. A failed preview states its own absence and never blocks the restore. --- messages/de.json | 17 +++ messages/en.json | 17 +++ messages/es.json | 17 +++ messages/fr.json | 17 +++ messages/it.json | 17 +++ messages/pl.json | 17 +++ .../[id]/summary/__tests__/route.test.ts | 95 ++++++++++++++ .../api/admin/backups/[id]/summary/route.ts | 79 +++++++++++ src/components/admin/backups-section.tsx | 124 ++++++++++++++++++ src/lib/query-keys/admin.ts | 3 + 10 files changed, 403 insertions(+) create mode 100644 src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts create mode 100644 src/app/api/admin/backups/[id]/summary/route.ts diff --git a/messages/de.json b/messages/de.json index 49ce66bee..779554b1b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Aus Backup wiederherstellen", "restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.", "restorePromptLabel": "Tippe RESTORE zum Fortfahren", + "previewTitle": "Inhalt der Sicherung", + "previewLoading": "Inhalt wird gelesen…", + "previewUnavailable": "Inhalt konnte nicht gelesen werden — die Wiederherstellung bleibt möglich.", + "previewEmpty": "Die Datei enthält keine Datensätze.", + "previewOther": "Weitere Datensätze", + "previewMeasurements": "Messwerte", + "previewMedications": "Medikamente und Einnahmen", + "previewMood": "Stimmungseinträge", + "previewLabs": "Laborwerte", + "previewDocuments": "Dokumente", + "previewWorkouts": "Workouts", + "previewCycles": "Zyklusdaten", + "previewVisits": "Arztbesuche", + "previewVaccinations": "Impfungen", + "previewReminders": "Vorsorge-Erinnerungen", + "previewCustomMetrics": "Eigene Messgrößen", + "previewHealthScores": "Score-Verlauf", "restoreConfirm": "Jetzt wiederherstellen", "restoreInProgress": "Wiederherstellung läuft…", "restoreSuccess": "Wiederherstellung abgeschlossen", diff --git a/messages/en.json b/messages/en.json index cb3ea8113..79fb592b3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Restore from backup", "restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.", "restorePromptLabel": "Type RESTORE to continue", + "previewTitle": "What this backup contains", + "previewLoading": "Reading contents…", + "previewUnavailable": "Contents could not be read — restoring is still possible.", + "previewEmpty": "The file contains no records.", + "previewOther": "Other records", + "previewMeasurements": "Measurements", + "previewMedications": "Medications and intakes", + "previewMood": "Mood entries", + "previewLabs": "Lab results", + "previewDocuments": "Documents", + "previewWorkouts": "Workouts", + "previewCycles": "Cycle data", + "previewVisits": "Doctor visits", + "previewVaccinations": "Vaccinations", + "previewReminders": "Preventive-care reminders", + "previewCustomMetrics": "Custom metrics", + "previewHealthScores": "Score history", "restoreConfirm": "Restore now", "restoreInProgress": "Restoring…", "restoreSuccess": "Restore complete", diff --git a/messages/es.json b/messages/es.json index 851bac220..4a9d2fc69 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Restaurar desde copia de seguridad", "restoreDescription": "Esto reemplazará TODOS los datos actuales de {username} con la instantánea del {when}. Se eliminarán las mediciones, los medicamentos, los eventos de toma, las entradas de ánimo, los canales de notificación, las suscripciones push y las eliminaciones de Telegram en cola existentes.", "restorePromptLabel": "Escribe RESTORE para continuar", + "previewTitle": "Contenido de la copia", + "previewLoading": "Leyendo el contenido…", + "previewUnavailable": "No se pudo leer el contenido — restaurar sigue siendo posible.", + "previewEmpty": "El archivo no contiene registros.", + "previewOther": "Otros registros", + "previewMeasurements": "Mediciones", + "previewMedications": "Medicamentos y tomas", + "previewMood": "Registros de ánimo", + "previewLabs": "Resultados de laboratorio", + "previewDocuments": "Documentos", + "previewWorkouts": "Entrenamientos", + "previewCycles": "Datos del ciclo", + "previewVisits": "Visitas médicas", + "previewVaccinations": "Vacunas", + "previewReminders": "Recordatorios preventivos", + "previewCustomMetrics": "Métricas propias", + "previewHealthScores": "Historial de puntuación", "restoreConfirm": "Restaurar ahora", "restoreInProgress": "Restaurando...", "restoreSuccess": "Restauración completada", diff --git a/messages/fr.json b/messages/fr.json index 0cfb46887..04f0b98e3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Restaurer à partir d'une sauvegarde", "restoreDescription": "Cette action remplacera TOUTES les données actuelles de {username} par l'instantané du {when}. Les mesures, médicaments, événements de prise, entrées d'humeur, canaux de notification, abonnements push et suppressions Telegram en file d'attente existants seront supprimés.", "restorePromptLabel": "Tapez RESTORE pour continuer", + "previewTitle": "Contenu de la sauvegarde", + "previewLoading": "Lecture du contenu…", + "previewUnavailable": "Le contenu n'a pas pu être lu — la restauration reste possible.", + "previewEmpty": "Le fichier ne contient aucun enregistrement.", + "previewOther": "Autres enregistrements", + "previewMeasurements": "Mesures", + "previewMedications": "Médicaments et prises", + "previewMood": "Entrées d'humeur", + "previewLabs": "Résultats de laboratoire", + "previewDocuments": "Documents", + "previewWorkouts": "Entraînements", + "previewCycles": "Données de cycle", + "previewVisits": "Visites médicales", + "previewVaccinations": "Vaccinations", + "previewReminders": "Rappels de prévention", + "previewCustomMetrics": "Mesures personnalisées", + "previewHealthScores": "Historique du score", "restoreConfirm": "Restaurer maintenant", "restoreInProgress": "Restauration en cours...", "restoreSuccess": "Restauration terminée", diff --git a/messages/it.json b/messages/it.json index 11e37762e..8a506f1bb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Ripristina dal backup", "restoreDescription": "Questa operazione sostituirà TUTTI i dati attuali di {username} con l'istantanea del {when}. Misurazioni, farmaci, eventi di assunzione, voci dell'umore, canali di notifica, sottoscrizioni push ed eliminazioni Telegram in coda esistenti verranno eliminati.", "restorePromptLabel": "Digita RESTORE per continuare", + "previewTitle": "Contenuto del backup", + "previewLoading": "Lettura del contenuto…", + "previewUnavailable": "Impossibile leggere il contenuto — il ripristino resta possibile.", + "previewEmpty": "Il file non contiene record.", + "previewOther": "Altri record", + "previewMeasurements": "Misurazioni", + "previewMedications": "Farmaci e assunzioni", + "previewMood": "Voci dell'umore", + "previewLabs": "Risultati di laboratorio", + "previewDocuments": "Documenti", + "previewWorkouts": "Allenamenti", + "previewCycles": "Dati del ciclo", + "previewVisits": "Visite mediche", + "previewVaccinations": "Vaccinazioni", + "previewReminders": "Promemoria di prevenzione", + "previewCustomMetrics": "Metriche personalizzate", + "previewHealthScores": "Storico del punteggio", "restoreConfirm": "Ripristina ora", "restoreInProgress": "Ripristino in corso...", "restoreSuccess": "Ripristino completato", diff --git a/messages/pl.json b/messages/pl.json index d1a425342..00fac59c1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6903,6 +6903,23 @@ "restoreTitle": "Przywróć z kopii zapasowej", "restoreDescription": "Spowoduje to zastąpienie WSZYSTKICH bieżących danych {username} migawką z {when}. Istniejące pomiary, leki, zdarzenia przyjęcia, wpisy nastroju, kanały powiadomień, subskrypcje push i zakolejkowane usunięcia Telegram zostaną usunięte.", "restorePromptLabel": "Wpisz RESTORE, aby kontynuować", + "previewTitle": "Zawartość kopii", + "previewLoading": "Odczytywanie zawartości…", + "previewUnavailable": "Nie udało się odczytać zawartości — przywracanie jest nadal możliwe.", + "previewEmpty": "Plik nie zawiera rekordów.", + "previewOther": "Inne rekordy", + "previewMeasurements": "Pomiary", + "previewMedications": "Leki i dawki", + "previewMood": "Wpisy nastroju", + "previewLabs": "Wyniki badań", + "previewDocuments": "Dokumenty", + "previewWorkouts": "Treningi", + "previewCycles": "Dane cyklu", + "previewVisits": "Wizyty lekarskie", + "previewVaccinations": "Szczepienia", + "previewReminders": "Przypomnienia profilaktyczne", + "previewCustomMetrics": "Własne metryki", + "previewHealthScores": "Historia wyniku", "restoreConfirm": "Przywróć teraz", "restoreInProgress": "Przywracanie...", "restoreSuccess": "Przywracanie zakończone", diff --git a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts new file mode 100644 index 000000000..d06486115 --- /dev/null +++ b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts @@ -0,0 +1,95 @@ +/** + * v1.37.20 — restore preview: the summary read decrypts and validates the + * stored file through the SAME helpers the restore uses, and only ever + * reads. Watched red: written against the route before its annotate landed; + * the not-found and decrypt-failure arms were asserted first. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })), + HttpError: class HttpError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +const findUniqueMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) }, + }, +})); + +const decryptMock = vi.fn(); +vi.mock("@/lib/crypto", () => ({ + decrypt: (...a: unknown[]) => decryptMock(...a), +})); + +const parseMock = vi.fn(); +const summarizeMock = vi.fn(); +vi.mock("@/lib/validations/backup", () => ({ + parseBackupPayload: (...a: unknown[]) => parseMock(...a), + summarizeBackup: (...a: unknown[]) => summarizeMock(...a), + isCompatibleSchemaVersion: vi.fn(() => true), +})); + +import { GET } from "../route"; + +const request = new NextRequest( + "http://localhost/api/admin/backups/b1/summary", +); +const params = { params: Promise.resolve({ id: "b1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + findUniqueMock.mockResolvedValue({ + id: "b1", + userId: "u1", + data: "ciphertext", + createdAt: new Date("2026-08-01T00:00:00Z"), + user: { id: "u1", username: "self-hoster" }, + }); + decryptMock.mockReturnValue("{}"); + parseMock.mockReturnValue({ schemaVersion: 3 }); + summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 }); +}); + +describe("GET /api/admin/backups/[id]/summary", () => { + it("answers the file's counts without writing anything", async () => { + const res = await GET(request, params); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 }); + expect(body.data.owner).toBe("self-hoster"); + }); + + it("404s an unknown backup id", async () => { + findUniqueMock.mockResolvedValue(null); + await expect(GET(request, params)).rejects.toMatchObject({ status: 404 }); + }); + + it("degrades to an error response when the file cannot be decrypted", async () => { + decryptMock.mockImplementation(() => { + throw new Error("bad key"); + }); + const res = await GET(request, params); + expect(res.status).toBe(500); + }); + + it("422s a file that fails schema validation", async () => { + parseMock.mockImplementation(() => { + throw new Error("nope"); + }); + const res = await GET(request, params); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/admin/backups/[id]/summary/route.ts b/src/app/api/admin/backups/[id]/summary/route.ts new file mode 100644 index 000000000..fe32c81e7 --- /dev/null +++ b/src/app/api/admin/backups/[id]/summary/route.ts @@ -0,0 +1,79 @@ +/** + * v1.37.20 — restore preview: what a backup file contains, BEFORE the + * operator confirms a restore. + * + * The restore dialog used to ask for the typed confirmation on nothing but + * a filename and a date; the counts only appeared in the audit row after + * the rows were already replaced. This read decrypts and schema-validates + * the stored file exactly the way the restore itself does (same decrypt, + * same `parseBackupPayload`, same compatibility gate) and answers with the + * `summarizeBackup` counts — so what the operator confirms is what the + * restore will write, derived from the same code path. Read-only; nothing + * is touched. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import { annotate } from "@/lib/logging/context"; +import { + isCompatibleSchemaVersion, + parseBackupPayload, + summarizeBackup, +} from "@/lib/validations/backup"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user: admin } = await requireAdmin(); + void admin; + const { id } = await params; + + const backup = await prisma.dataBackup.findUnique({ + where: { id }, + include: { user: { select: { id: true, username: true } } }, + }); + if (!backup) { + throw new HttpError(404, "Backup not found"); + } + + let plaintext: string; + try { + plaintext = decrypt(backup.data); + } catch { + return apiError("Failed to decrypt backup payload", 500); + } + + let payload; + try { + payload = parseBackupPayload(plaintext); + } catch { + return apiError("Backup payload failed schema validation", 422); + } + + if (!isCompatibleSchemaVersion(payload.schemaVersion)) { + return apiError( + `Backup schema version ${payload.schemaVersion} is not restorable by this release`, + 422, + ); + } + + const summary = summarizeBackup(payload); + + annotate({ + action: { name: "admin.backups.summary" }, + meta: { backupId: id, ownerId: backup.userId }, + }); + + return apiSuccess({ + summary, + owner: backup.user?.username ?? null, + createdAt: backup.createdAt.toISOString(), + }); + }, +); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 7950bc323..abcf78d0e 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -68,6 +68,46 @@ import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; * Mirrors the wipe dialog's pattern but adds the typed gate because the * blast radius is bigger (re-creates rows, not just deletes). */ +/** + * v1.37.20 — the sections a restore preview names, in render order. Each + * entry sums one or more `BackupSummary` counters under one human label; + * every counter NOT named here still reaches the operator through the + * trailing "other records" line, so a future summary key can be forgotten + * here without silently vanishing from the preview. + */ +const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{ + labelKey: string; + keys: readonly string[]; +}> = [ + { labelKey: "previewMeasurements", keys: ["measurements"] }, + { + labelKey: "previewMedications", + keys: ["medications", "intakeEvents", "medicationSideEffects"], + }, + { labelKey: "previewMood", keys: ["moodEntries"] }, + { labelKey: "previewLabs", keys: ["labResults", "biomarkers"] }, + { labelKey: "previewDocuments", keys: ["documents"] }, + { labelKey: "previewWorkouts", keys: ["workouts"] }, + { labelKey: "previewCycles", keys: ["cycles", "cycleDayLogs"] }, + { + labelKey: "previewVisits", + keys: ["encounters", "practitioners", "encounterLinks"], + }, + { + labelKey: "previewVaccinations", + keys: ["vaccinations", "vaccinationLinks"], + }, + { + labelKey: "previewReminders", + keys: ["measurementReminders", "measurementReminderEvents"], + }, + { + labelKey: "previewCustomMetrics", + keys: ["customMetrics", "customMetricEntries"], + }, + { labelKey: "previewHealthScores", keys: ["healthScoreRecords"] }, +]; + function RestoreRowDialog({ row, pending, @@ -83,6 +123,39 @@ function RestoreRowDialog({ const [typed, setTyped] = useState(""); const matched = typed.trim() === "RESTORE"; + // v1.37.20 — restore preview: fetch what the file contains the moment the + // dialog opens, so the typed confirmation is an informed one. Derived from + // the same decrypt + schema path the restore itself runs. A failed preview + // never blocks the restore — it states its own absence instead. + const preview = useQuery({ + queryKey: queryKeys.adminBackupSummary(row.id), + queryFn: () => + apiGet<{ summary: Record }>( + `/api/admin/backups/${row.id}/summary`, + ), + enabled: open, + staleTime: 60_000, + }); + const numericSummary: Record = {}; + if (preview.data?.summary) { + for (const [key, value] of Object.entries(preview.data.summary)) { + if (typeof value === "number") numericSummary[key] = value; + } + } + const namedKeys = new Set( + RESTORE_PREVIEW_SECTIONS.flatMap((section) => section.keys), + ); + const previewRows = RESTORE_PREVIEW_SECTIONS.map((section) => ({ + labelKey: section.labelKey, + count: section.keys.reduce( + (sum, key) => sum + (numericSummary[key] ?? 0), + 0, + ), + })).filter((entry) => entry.count > 0); + const otherCount = Object.entries(numericSummary) + .filter(([key]) => !namedKeys.has(key)) + .reduce((sum, [, value]) => sum + value, 0); + return ( + {/* v1.37.20 — what the file contains, before the typed gate. */} +
    +

    + {t("admin.section.backups.previewTitle")} +

    + {preview.isLoading && ( +

    + {t("admin.section.backups.previewLoading")} +

    + )} + {preview.isError && ( +

    + {t("admin.section.backups.previewUnavailable")} +

    + )} + {preview.isSuccess && ( +
      + {previewRows.map((entry) => ( +
    • + + {t(`admin.section.backups.${entry.labelKey}`)} + + + {fmt.integer(entry.count)} + +
    • + ))} + {otherCount > 0 && ( +
    • + + {t("admin.section.backups.previewOther")} + + + {fmt.integer(otherCount)} + +
    • + )} + {previewRows.length === 0 && otherCount === 0 && ( +
    • + {t("admin.section.backups.previewEmpty")} +
    • + )} +
    + )} +