diff --git a/CHANGELOG.md b/CHANGELOG.md index 2823094c1..ef1d17b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +## [1.27.5] — 2026-07-05 — Insights polish and honest states + +### Fixed + +- The cycle wheel no longer counts into implausible territory when the last logged period lies far back. Beyond a grace window past your typical cycle length it pauses the day count and says plainly that the period is later than usual, with a shortcut to log it. +- Injection medications no longer show the overdue notice twice on one card; the line under the streak row is gone, the escalated next-intake line stays. +- The preventive-care measure button keeps one constant look; a due checkup no longer tints it green. Due-ness still reads from the next-due line. +- Editing a measurement now runs the same per-type plausibility bands as every other entry path, and readings owned by a connected device or the server can no longer have their value edited by hand. Signed metrics accept negative values on edit again. +- The daily briefing card opens straight on the day's signals: the recall/outlook paragraph above the list is gone, the section titles read in the regular text colour, and the card's Ask-the-Coach entry is removed. +- Assessments and the briefing overview are asked to write short paragraphs instead of one connected block — takes effect as texts regenerate. +- Dimmed low-contrast text is gone from informational lines (integration cards, notifications, sleep chart, module rows, and more); your own mood notes now read in the regular text colour and size. +- Sleep-phase and voice copy no longer promise features "coming in v1.x" — those texts now describe the actual state. + +### Added + +- The recovery score's detail view shows how the score is composed — the same ranked factor rows the readiness and sleep scores already have (shown for scores computed from your vitals; watch-native scores stay as delivered). + ## [1.27.4] — 2026-07-05 — Google Health sync fixed against the live API ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d5a8533ea..a7fa4aa1c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.4 + version: 1.27.5 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 28604c9b1..732b16ed2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -517,6 +517,7 @@ "formReset": "Formular zurücksetzen", "saveError": "Fehler beim Speichern", "duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.", + "serverOwnedSource": "Werte aus einer verbundenen Quelle lassen sich nicht bearbeiten – Zeitpunkt und Notiz bleiben änderbar.", "deleteError": "Fehler beim Löschen", "deletedToast": "Messwert gelöscht", "restoredToast": "Messwert wiederhergestellt", @@ -870,7 +871,6 @@ "dayStreak": "Tage Serie", "dayStreakOne": "Tag Serie", "cycleDueToday": "Heute fällig", - "cycleOverdue": "Überfällig", "cycleNoClosedCycles": "Noch keine abgeschlossenen Dosiszyklen", "taken": "Genommen", "skipped": "Übersprungen", @@ -2194,7 +2194,6 @@ "summaryAdoptedNone": "nichts Neues in die Selbstauskunft übernommen", "summaryLink": "Selbstauskunft ansehen" }, - "voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.", "threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.", "historyTitle": "Unterhaltungen", "historySearchPlaceholder": "Unterhaltungen durchsuchen…", @@ -2697,11 +2696,11 @@ "asleep": "Schlafend", "awake": "Wach", "inBed": "Im Bett", - "unavailable": "Noch keine Phasen-Daten – Apple-Health-Sync für Schlafphasen folgt mit v1.5." + "unavailable": "Noch keine Phasen-Daten – Schlafphasen erscheinen hier, sobald eine verbundene Quelle sie liefert." }, "empty": { "title": "Noch keine Schlafdaten", - "description": "Apple-Health-Sync folgt mit v1.5 und füllt diese Ansicht automatisch.", + "description": "Verbinde eine Quelle wie Apple Health oder ein Wearable – oder erfasse Schlaf manuell – und diese Ansicht füllt sich automatisch.", "action": "Geräte verwalten" }, "debt": { @@ -6748,7 +6747,9 @@ "ariaPhase": "Zyklustag {day}, {phase}-Phase", "ariaUnknown": "Noch kein aktiver Zyklus", "caption": "deines Zyklus", - "firstPeriodCta": "Erste Periode erfassen" + "firstPeriodCta": "Erste Periode erfassen", + "periodOverdue": "Deine Periode ist später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.", + "logPeriodCta": "Periode erfassen" }, "calendar": { "legendPeriod": "Erfasste Periode", diff --git a/messages/en.json b/messages/en.json index df45fbd25..dcb9a1849 100644 --- a/messages/en.json +++ b/messages/en.json @@ -517,6 +517,7 @@ "formReset": "Reset form", "saveError": "Error saving", "duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.", + "serverOwnedSource": "Values from a connected source can't be edited — the timestamp and note stay editable.", "deleteError": "Error deleting", "deletedToast": "Measurement deleted", "restoredToast": "Measurement restored", @@ -870,7 +871,6 @@ "dayStreak": "day streak", "dayStreakOne": "day streak", "cycleDueToday": "Due today", - "cycleOverdue": "Overdue", "cycleNoClosedCycles": "No closed dose cycles yet", "taken": "Taken", "skipped": "Skipped", @@ -2194,7 +2194,6 @@ "summaryAdoptedNone": "nothing new added to your self-context", "summaryLink": "Review your self-context" }, - "voiceComingSoon": "Voice input arrives with the iOS app in v1.5.", "threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.", "historyTitle": "Conversations", "historySearchPlaceholder": "Search conversations…", @@ -2697,11 +2696,11 @@ "asleep": "Asleep", "awake": "Awake", "inBed": "In bed", - "unavailable": "No stage data yet — Apple Health stage syncing arrives in v1.5." + "unavailable": "No stage data yet — sleep stages appear here as soon as a connected source delivers them." }, "empty": { "title": "No sleep data yet", - "description": "Apple Health sync arrives in v1.5 and will fill this in automatically.", + "description": "Connect a source like Apple Health or a wearable — or log sleep manually — and this view fills in automatically.", "action": "Manage devices" }, "debt": { @@ -6748,7 +6747,9 @@ "ariaPhase": "Cycle day {day}, {phase} phase", "ariaUnknown": "No active cycle yet", "caption": "of your cycle", - "firstPeriodCta": "Log my first period" + "firstPeriodCta": "Log my first period", + "periodOverdue": "Your period is later than usual — the day count pauses until the next period is logged.", + "logPeriodCta": "Log period" }, "calendar": { "legendPeriod": "Logged period", diff --git a/messages/es.json b/messages/es.json index 35f95049a..57186f2e1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -517,6 +517,7 @@ "formReset": "Restablecer formulario", "saveError": "Error al guardar", "duplicateTimestamp": "Ya existe una medición de este tipo en la marca de tiempo seleccionada.", + "serverOwnedSource": "Los valores de una fuente conectada no se pueden editar — la fecha y la nota siguen siendo editables.", "deleteError": "Error al eliminar", "deletedToast": "Medición eliminada", "restoredToast": "Medición restaurada", @@ -870,7 +871,6 @@ "dayStreak": "días de racha", "dayStreakOne": "día de racha", "cycleDueToday": "Pendiente hoy", - "cycleOverdue": "Atrasada", "cycleNoClosedCycles": "Aún no hay ciclos de dosis completados", "taken": "Tomada", "skipped": "Omitida", @@ -2194,7 +2194,6 @@ "summaryAdoptedNone": "no se añadió nada nuevo a tu información personal", "summaryLink": "Ver tu información personal" }, - "voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.", "threadEmpty": "Pregúntame algo sobre tus tendencias, medicamentos o mediciones para empezar.", "historyTitle": "Conversaciones", "historySearchPlaceholder": "Buscar en las conversaciones…", @@ -2697,11 +2696,11 @@ "asleep": "Dormido", "awake": "Despierto", "inBed": "En la cama", - "unavailable": "Aún no hay datos de fases — la sincronización de fases vía Apple Health llega con la v1.5." + "unavailable": "Aún no hay datos de fases — las fases de sueño aparecerán aquí en cuanto una fuente conectada las proporcione." }, "empty": { "title": "Aún no hay datos de sueño", - "description": "La sincronización con Apple Health llega con la v1.5 y llenará esta vista automáticamente.", + "description": "Conecta una fuente como Apple Health o un wearable — o registra el sueño manualmente — y esta vista se llenará automáticamente.", "action": "Gestionar dispositivos" }, "debt": { @@ -6748,7 +6747,9 @@ "ariaPhase": "Día {day} del ciclo, fase {phase}", "ariaUnknown": "Aún no hay ciclo activo", "caption": "de tu ciclo", - "firstPeriodCta": "Registrar mi primer periodo" + "firstPeriodCta": "Registrar mi primer periodo", + "periodOverdue": "Tu período llega más tarde de lo habitual — el conteo de días se pausa hasta que registres el próximo período.", + "logPeriodCta": "Registrar período" }, "calendar": { "legendPeriod": "Periodo registrado", diff --git a/messages/fr.json b/messages/fr.json index 92682f7a4..bdb838530 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -517,6 +517,7 @@ "formReset": "Réinitialiser le formulaire", "saveError": "Erreur lors de l’enregistrement", "duplicateTimestamp": "Une mesure de ce type existe déjà à cet horodatage.", + "serverOwnedSource": "Les valeurs provenant d’une source connectée ne peuvent pas être modifiées — l’horodatage et la note restent modifiables.", "deleteError": "Erreur lors de la suppression", "deletedToast": "Mesure supprimée", "restoredToast": "Mesure restaurée", @@ -870,7 +871,6 @@ "dayStreak": "jours de série", "dayStreakOne": "jour de série", "cycleDueToday": "À prendre aujourd'hui", - "cycleOverdue": "En retard", "cycleNoClosedCycles": "Aucun cycle de dose terminé pour l'instant", "taken": "Pris", "skipped": "Sauté", @@ -2194,7 +2194,6 @@ "summaryAdoptedNone": "rien de nouveau ajouté à vos informations personnelles", "summaryLink": "Voir vos informations personnelles" }, - "voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.", "threadEmpty": "Pose une question sur tes tendances, médicaments ou mesures pour démarrer.", "historyTitle": "Conversations", "historySearchPlaceholder": "Rechercher dans les conversations…", @@ -2697,11 +2696,11 @@ "asleep": "Endormi", "awake": "Éveillé", "inBed": "Au lit", - "unavailable": "Pas encore de données de phases — la synchro des phases via Apple Health arrive avec la v1.5." + "unavailable": "Pas encore de données de phases — les phases de sommeil apparaîtront ici dès qu’une source connectée les fournira." }, "empty": { "title": "Pas encore de données de sommeil", - "description": "La synchro Apple Health arrive avec la v1.5 et remplira automatiquement cette vue.", + "description": "Connecte une source comme Apple Health ou un objet connecté — ou enregistre ton sommeil manuellement — et cette vue se remplira automatiquement.", "action": "Gérer les appareils" }, "debt": { @@ -6748,7 +6747,9 @@ "ariaPhase": "Jour {day} du cycle, phase {phase}", "ariaUnknown": "Pas encore de cycle actif", "caption": "de ton cycle", - "firstPeriodCta": "Enregistrer mes premières règles" + "firstPeriodCta": "Enregistrer mes premières règles", + "periodOverdue": "Tes règles arrivent plus tard que d’habitude — le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.", + "logPeriodCta": "Enregistrer les règles" }, "calendar": { "legendPeriod": "Règles enregistrées", diff --git a/messages/it.json b/messages/it.json index 6a20cc358..cbc9dd973 100644 --- a/messages/it.json +++ b/messages/it.json @@ -517,6 +517,7 @@ "formReset": "Reimposta modulo", "saveError": "Errore durante il salvataggio", "duplicateTimestamp": "Esiste già una misurazione di questo tipo a questa marca temporale.", + "serverOwnedSource": "I valori provenienti da una fonte collegata non possono essere modificati — data e nota restano modificabili.", "deleteError": "Errore durante l’eliminazione", "deletedToast": "Misurazione eliminata", "restoredToast": "Misurazione ripristinata", @@ -870,7 +871,6 @@ "dayStreak": "giorni di serie", "dayStreakOne": "giorno di serie", "cycleDueToday": "In scadenza oggi", - "cycleOverdue": "In ritardo", "cycleNoClosedCycles": "Ancora nessun ciclo di dose completato", "taken": "Assunta", "skipped": "Saltata", @@ -2194,7 +2194,6 @@ "summaryAdoptedNone": "nulla di nuovo aggiunto alle tue informazioni personali", "summaryLink": "Vedi le tue informazioni personali" }, - "voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.", "threadEmpty": "Chiedimi qualcosa sui tuoi trend, farmaci o misurazioni per iniziare.", "historyTitle": "Conversazioni", "historySearchPlaceholder": "Cerca nelle conversazioni…", @@ -2697,11 +2696,11 @@ "asleep": "Addormentato", "awake": "Sveglio", "inBed": "A letto", - "unavailable": "Ancora nessun dato sulle fasi — la sync delle fasi via Apple Health arriva con v1.5." + "unavailable": "Ancora nessun dato sulle fasi — le fasi del sonno appariranno qui non appena una fonte collegata le fornirà." }, "empty": { "title": "Ancora nessun dato sul sonno", - "description": "La sync con Apple Health arriva con v1.5 e riempirà questa vista in automatico.", + "description": "Collega una fonte come Apple Health o un wearable — oppure registra il sonno manualmente — e questa vista si riempirà in automatico.", "action": "Gestisci dispositivi" }, "debt": { @@ -6748,7 +6747,9 @@ "ariaPhase": "Giorno {day} del ciclo, fase {phase}", "ariaUnknown": "Nessun ciclo attivo ancora", "caption": "del tuo ciclo", - "firstPeriodCta": "Registra il mio primo ciclo" + "firstPeriodCta": "Registra il mio primo ciclo", + "periodOverdue": "Il ciclo è in ritardo rispetto al solito — il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.", + "logPeriodCta": "Registra mestruazione" }, "calendar": { "legendPeriod": "Ciclo registrato", diff --git a/messages/pl.json b/messages/pl.json index 179663f05..1b5a953fb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -517,6 +517,7 @@ "formReset": "Resetuj formularz", "saveError": "Błąd zapisu", "duplicateTimestamp": "Pomiar tego typu już istnieje dla wybranej daty.", + "serverOwnedSource": "Wartości z połączonego źródła nie można edytować — znacznik czasu i notatka pozostają edytowalne.", "deleteError": "Błąd usuwania", "deletedToast": "Pomiar usunięty", "restoredToast": "Pomiar przywrócony", @@ -870,7 +871,6 @@ "dayStreak": "dni serii", "dayStreakOne": "dzień serii", "cycleDueToday": "Termin dzisiaj", - "cycleOverdue": "Zaległa", "cycleNoClosedCycles": "Brak zakończonych cykli dawkowania", "taken": "Przyjęte", "skipped": "Pominięte", @@ -2194,7 +2194,6 @@ "summaryAdoptedNone": "nie dodano nic nowego do informacji o sobie", "summaryLink": "Zobacz informacje o sobie" }, - "voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.", "threadEmpty": "Zapytaj o swoje trendy, leki lub pomiary, aby zacząć.", "historyTitle": "Rozmowy", "historySearchPlaceholder": "Szukaj w rozmowach…", @@ -2697,11 +2696,11 @@ "asleep": "Śpi", "awake": "Czuwa", "inBed": "W łóżku", - "unavailable": "Brak danych o fazach — synchronizacja faz przez Apple Health pojawi się w v1.5." + "unavailable": "Brak danych o fazach — fazy snu pojawią się tutaj, gdy tylko dostarczy je połączone źródło." }, "empty": { "title": "Brak danych o śnie", - "description": "Synchronizacja Apple Health pojawi się w v1.5 i wypełni ten widok automatycznie.", + "description": "Połącz źródło takie jak Apple Health lub urządzenie noszone — albo zapisuj sen ręcznie — a ten widok wypełni się automatycznie.", "action": "Zarządzaj urządzeniami" }, "debt": { @@ -6748,7 +6747,9 @@ "ariaPhase": "Dzień {day} cyklu, faza {phase}", "ariaUnknown": "Brak aktywnego cyklu", "caption": "twojego cyklu", - "firstPeriodCta": "Zapisz pierwszą miesiączkę" + "firstPeriodCta": "Zapisz pierwszą miesiączkę", + "periodOverdue": "Okres jest późniejszy niż zwykle — licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.", + "logPeriodCta": "Zapisz okres" }, "calendar": { "legendPeriod": "Zapisana miesiączka", diff --git a/package.json b/package.json index 28be8581e..df222bd3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.4", + "version": "1.27.5", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 34566d841..9e6c7497a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.27.4"; + /* @sw-version-fallback */ "v1.27.5"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/measurements/[id]/__tests__/route.test.ts b/src/app/api/measurements/[id]/__tests__/route.test.ts index 7d7f96a26..6029e657b 100644 --- a/src/app/api/measurements/[id]/__tests__/route.test.ts +++ b/src/app/api/measurements/[id]/__tests__/route.test.ts @@ -28,6 +28,9 @@ vi.mock("@/lib/cache/invalidate", () => ({ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), +})); vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -40,6 +43,7 @@ vi.mock("next/headers", () => ({ import { PUT } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -56,21 +60,40 @@ function putReq(body: unknown): NextRequest { const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; +const EXISTING = { + id: "m1", + userId: "user-1", + type: "WEIGHT", + source: "MANUAL", + value: 80, + measuredAt: new Date("2026-06-01T08:00:00Z"), + notes: null, + notesEncrypted: null, +}; + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); - vi.mocked(prisma.measurement.findFirst).mockResolvedValue({ - id: "m1", - userId: "user-1", - } as never); + // resetAllMocks clears the factory implementation; the route chains + // `.catch()` on this fire-and-forget call, so it must resolve again. + vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue( + undefined as never, + ); + vi.mocked(prisma.measurement.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.measurement.update).mockImplementation((async (args: { + data: Record; + }) => ({ + ...EXISTING, + ...args.data, + })) as never); }); describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => { it("surfaces TWO simultaneous validation errors", async () => { - // `value=-1` (below min 0) + `measuredAt` not a valid ISO. + // `value` not a number + `measuredAt` not a valid ISO. const res = await PUT( - putReq({ value: -1, measuredAt: "not-iso" }), + putReq({ value: "junk", measuredAt: "not-iso" }), ROUTE_CTX, ); expect(res.status).toBe(422); @@ -107,7 +130,10 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => { }); it("writes a measurements.update.validation-failed audit row", async () => { - const res = await PUT(putReq({ value: -1, measuredAt: "junk" }), ROUTE_CTX); + const res = await PUT( + putReq({ value: "junk", measuredAt: "junk" }), + ROUTE_CTX, + ); expect(res.status).toBe(422); await new Promise((r) => setTimeout(r, 5)); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); @@ -122,7 +148,84 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => { vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( new Error("db down"), ); - const res = await PUT(putReq({ value: -1, measuredAt: "junk" }), ROUTE_CTX); + const res = await PUT( + putReq({ value: "junk", measuredAt: "junk" }), + ROUTE_CTX, + ); expect(res.status).toBe(422); }); }); + +describe("PUT /api/measurements/[id] — value guards (di-001)", () => { + it("422s an edit outside the row type's plausibility band", async () => { + // Existing row is WEIGHT (band 1..500 kg) — 9999 must not pass just + // because the edit body carries no type. + const res = await PUT(putReq({ value: 9999 }), ROUTE_CTX); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { issues: Array<{ path: string; message: string }> }; + }; + expect(body.error).toBe("Validation failed"); + expect(body.details.issues).toHaveLength(1); + expect(body.details.issues[0].path).toContain("value"); + expect(prisma.measurement.update).not.toHaveBeenCalled(); + }); + + it("accepts a negative value on a signed type", async () => { + // ANS_CHARGE runs -100..100; the former generic min(0) rejected every + // legitimate negative edit. + vi.mocked(prisma.measurement.findFirst).mockResolvedValue({ + ...EXISTING, + type: "ANS_CHARGE", + value: 12, + } as never); + const res = await PUT(putReq({ value: -40 }), ROUTE_CTX); + expect(res.status).toBe(200); + const call = vi.mocked(prisma.measurement.update).mock.calls[0]?.[0] as { + data: { value?: number }; + }; + expect(call.data.value).toBe(-40); + }); + + it("409s a value edit on a server-owned source with a stable errorCode", async () => { + vi.mocked(prisma.measurement.findFirst).mockResolvedValue({ + ...EXISTING, + source: "WITHINGS", + } as never); + const res = await PUT(putReq({ value: 81 }), ROUTE_CTX); + expect(res.status).toBe(409); + const body = (await res.json()) as { + data: null; + error: string; + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("measurement.update.server_owned_source"); + expect(prisma.measurement.update).not.toHaveBeenCalled(); + }); + + it("still allows a timestamp-only edit on a server-owned source", async () => { + vi.mocked(prisma.measurement.findFirst).mockResolvedValue({ + ...EXISTING, + source: "WITHINGS", + } as never); + const res = await PUT( + putReq({ measuredAt: "2026-06-01T09:00:00.000Z" }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(prisma.measurement.update).toHaveBeenCalledTimes(1); + }); + + it("happy path: an in-band edit on a client-owned row persists", async () => { + const res = await PUT(putReq({ value: 82.5 }), ROUTE_CTX); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { value: number } }; + expect(body.data.value).toBe(82.5); + const call = vi.mocked(prisma.measurement.update).mock.calls[0]?.[0] as { + data: { value?: number }; + }; + expect(call.data.value).toBe(82.5); + }); +}); diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 4354eb3c1..2537dc58a 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -10,13 +10,18 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; -import { updateMeasurementSchema } from "@/lib/validations/measurement"; +import { + updateMeasurementSchema, + validateMeasurementRange, + WRITABLE_MEASUREMENT_SOURCES, +} from "@/lib/validations/measurement"; import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { Prisma } from "@/generated/prisma/client"; import { NextRequest } from "next/server"; +import { z } from "zod"; type RouteParams = { params: Promise<{ id: string }> }; @@ -104,6 +109,61 @@ export const PUT = apiHandler( } const data = parsed.data; + + // v1.27.5 (di-001) — the edit path was the ONE write surface that skipped + // the per-type plausibility bands: every other producer (POST, batch, + // CSV import, Apple export, Telegram, MCP) enforces `VALUE_RANGES`, so an + // implausible edited value flowed unchecked into rollups, the health + // score, the BP gates and the Coach snapshot. + if (data.value !== undefined && data.value !== existing.value) { + // Server-owned rows first: a value attributed to a connector / import / + // computed engine is the provider's reading — editing the number would + // forge a source-attributed row the server never received. Mirrors the + // write-side classification (`WRITABLE_MEASUREMENT_SOURCES`): only + // MANUAL and APPLE_HEALTH rows are client-owned. Timestamp and note + // edits stay allowed — annotating a Withings reading is legitimate. + if ( + !(WRITABLE_MEASUREMENT_SOURCES as readonly string[]).includes( + existing.source, + ) + ) { + annotate({ + action: { name: "measurements.update.server-owned-source" }, + meta: { measurement_id: id, source: existing.source }, + }); + return apiError( + "Values from a connected source cannot be edited", + 409, + { errorCode: "measurement.update.server_owned_source" }, + ); + } + + // Range check against the row's OWN type (the edit body carries no + // type). Returned through the standard multi-issue 422 envelope so the + // edit sheet renders it like any other field error. + const rangeCheck = z + .object({ + value: z.number().superRefine((value, ctx) => { + const rangeError = validateMeasurementRange(existing.type, value); + if (rangeError) { + ctx.addIssue({ code: "custom", message: rangeError }); + } + }), + }) + .safeParse({ value: data.value }); + if (!rangeCheck.success) { + annotate({ + action: { name: "measurements.update.validation-failed" }, + meta: { + issue_count: rangeCheck.error.issues.length, + measurement_id: id, + reason: "value_out_of_range", + }, + }); + return returnAllZodIssues(rangeCheck.error, 422); + } + } + let measurement; try { measurement = await prisma.measurement.update({ diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index b59cf7ee8..66ef264ed 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -303,19 +303,18 @@ export default function InsightsPage() { const analyticsQuery = useAnalyticsQuery(); const analytics = analyticsQuery.data as AnalyticsData | undefined; - // v1.21.2 (A4 / A5 / A6) — the dashboard snapshot is the single server source - // for the ambient narrative the hero + briefing surface: the Tension Verdict - // and return-to-baseline ride its `healthScore`, the briefing recall + - // forward-look rides `briefingMemory`. The hero score number itself stays on - // the analytics payload (unchanged); only these three additive, server- - // resolved DTO fields are lifted from the snapshot. Reads the same shared + // v1.21.2 (A5 / A6) — the dashboard snapshot is the single server source + // for the ambient narrative the hero surfaces: the Tension Verdict and + // return-to-baseline ride its `healthScore`. The hero score number itself + // stays on the analytics payload (unchanged). Reads the same shared // `queryKeys.dashboardSnapshot()` cell the dashboard warms, so the insights // visit pays at most one extra round-trip and usually a warm cache hit. + // (The A4 recall/forward-look block left the briefing card — the card now + // opens straight on the day's signals.) const snapshotQuery = useDashboardSnapshot(isAuthenticated); const snapshotHealthScore = snapshotQuery.data?.healthScore ?? null; const heroTension = snapshotHealthScore?.tension ?? null; const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null; - const briefingMemory = snapshotQuery.data?.briefingMemory ?? null; // v1.12.6 — the page owns the ONE overview derived batch. The wellness // strip (above the briefing) and the vitals grid (below it) both read this @@ -483,10 +482,6 @@ export default function InsightsPage() { // v1.25.3 — failure class points the empty-state hint at the right // lever (raise the response timeout vs re-check the provider). generationFailureClass={advisor.generationFailureClass} - // v1.21.2 (A4) — server-resolved recall + forward-look off the dashboard - // snapshot. Both strings are already localised; null leaves the card's - // memory block unrendered. - memory={briefingMemory} /> ) : null, vitals: , diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 6100eea70..4078419e7 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -205,17 +205,12 @@ export default function NotificationsPage() { key={ch.id} className="px-4 py-3 text-center text-xs font-medium tracking-wider uppercase" > - - {ch.label} - + {/* Disabled state reads through the note + the disabled + switches below — never through sub-AA opacity dimming + on the label or the note itself. */} + {ch.label} {!ch.enabled && ( - + {t("notifications.channelDisabled")} )} @@ -296,11 +291,11 @@ export default function NotificationsPage() { >