Merge pull request #406 from MBombeck/release/v1.27.5

Release v1.27.5 — insights polish and honest states
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-05 15:25:32 +02:00
commit a6045a7a88
47 files changed
+554 -210

No files matched your search

+17
View File
@@ -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
+1 -1
View File
@@ -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.
+6 -5
View File
@@ -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",
+6 -5
View File
@@ -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",
+6 -5
View File
@@ -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",
+6 -5
View File
@@ -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",
+6 -5
View File
@@ -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",
+6 -5
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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<string, unknown>;
}) => ({
...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);
});
});
+61 -1
View File
@@ -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({
+6 -11
View File
@@ -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: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
+7 -12
View File
@@ -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"
>
<span
className={
ch.enabled
? "text-muted-foreground"
: "text-muted-foreground/70"
}
>
{ch.label}
</span>
{/* Disabled state reads through the note + the disabled
switches below — never through sub-AA opacity dimming
on the label or the note itself. */}
<span className="text-muted-foreground">{ch.label}</span>
{!ch.enabled && (
<span className="text-muted-foreground/70 block text-[10px] normal-case">
<span className="text-muted-foreground block text-xs normal-case">
{t("notifications.channelDisabled")}
</span>
)}
@@ -296,11 +291,11 @@ export default function NotificationsPage() {
>
<Label
htmlFor={switchId}
className={`text-sm ${disabled ? "text-muted-foreground/70" : ""}`}
className={`text-sm ${disabled ? "text-muted-foreground" : ""}`}
>
{ch.label}
{disabled && (
<span className="text-muted-foreground/70 ml-1 text-xs">
<span className="text-muted-foreground ml-1 text-xs">
({t("notifications.channelDisabled")})
</span>
)}
@@ -325,7 +325,7 @@ export function InviteTokensSection() {
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.invites.description")}
</p>
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 pl-7 text-xs">
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 pl-7 text-xs">
<ShieldCheck className="size-3.5 shrink-0" aria-hidden="true" />
{t("admin.invites.adminOnlyHint")}
</p>
@@ -118,6 +118,52 @@ describe("deriveWheelState", () => {
expect(w.cycleLength).toBe(28);
expect(w.phase).toBe("MENSTRUAL");
expect(w.dayOfCycle).toBe(1);
expect(w.periodOverdue).toBe(false);
});
// v1.27.5 — a months-old open cycle whose predicted next start moved into
// the future labels every gap day LUTEAL; the wheel must not read that back
// as "day 90 of your cycle".
describe("overdue ceiling", () => {
/** A run of 5 MENSTRUAL days + (n−5) LUTEAL days ending at `today`. */
function longRun(n: number): { days: CalendarDay[]; today: string } {
const start = Date.UTC(2026, 0, 1);
const days: CalendarDay[] = [];
for (let i = 0; i < n; i++) {
const date = new Date(start + i * 86_400_000)
.toISOString()
.slice(0, 10);
days.push(day(date, i < 5 ? "MENSTRUAL" : "LUTEAL"));
}
return { days, today: days[n - 1].date };
}
it("stops asserting a day count beyond typical length + grace", () => {
const { days, today } = longRun(90);
const w = deriveWheelState(days, today, { typicalCycleLength: 28 });
expect(w.periodOverdue).toBe(true);
expect(w.dayOfCycle).toBeNull();
expect(w.phase).toBeNull();
// The ring still draws the canonical four-phase dial.
expect(w.spans).toHaveLength(4);
expect(w.cycleLength).toBe(28);
});
it("keeps counting inside the grace window", () => {
// Typical 28 + 14 grace = 42; day 42 is still an honest count.
const { days, today } = longRun(42);
const w = deriveWheelState(days, today, { typicalCycleLength: 28 });
expect(w.periodOverdue).toBe(false);
expect(w.dayOfCycle).toBe(42);
});
it("respects a long typical cycle length from the profile", () => {
// A 60-day typical cycle keeps day 49 honest.
const { days, today } = longRun(49);
const w = deriveWheelState(days, today, { typicalCycleLength: 60 });
expect(w.periodOverdue).toBe(false);
expect(w.dayOfCycle).toBe(49);
});
});
});
+12 -4
View File
@@ -198,18 +198,26 @@ export function CycleView() {
/>
)}
{!calendarError ? (
<p className="text-muted-foreground text-xs">
{t("cycle.ring.caption")}
<p className="text-muted-foreground max-w-56 text-center text-xs">
{/* v1.27.5 — beyond typical length + grace the wheel stops
asserting a day count; this caption says why instead of
the "of your cycle" tagline under an empty ring. */}
{wheel.periodOverdue
? t("cycle.ring.periodOverdue")
: t("cycle.ring.caption")}
</p>
) : null}
{/* First-period CTA — only when no cycle is active yet. */}
{/* Log CTA — first period when no cycle exists yet, next period
when the count paused on an overdue cycle. */}
{!loading && !calendarError && wheel.dayOfCycle == null ? (
<Button
variant="outline"
className="mt-1 w-full"
onClick={() => openSheet(today)}
>
{t("cycle.ring.firstPeriodCta")}
{wheel.periodOverdue
? t("cycle.ring.logPeriodCta")
: t("cycle.ring.firstPeriodCta")}
</Button>
) : null}
</div>
+58 -2
View File
@@ -22,6 +22,15 @@ export interface WheelState {
cycleLength: number | null;
phase: CyclePhase | null;
spans: { phase: CyclePhase; fraction: number }[];
/**
* v1.27.5 — true when the current run overshoots the profile's typical
* cycle length by more than the overdue grace window. The wheel then stops
* asserting a day-of-cycle / phase (both null) and the view renders the
* honest "period overdue" caption instead: a day count like "90" inside
* the ring is a population-frame artefact of the open cycle window, not an
* observed fact (see `OVERDUE_GRACE_DAYS`).
*/
periodOverdue: boolean;
}
/**
@@ -46,6 +55,20 @@ export interface CycleProfileLengths {
* observed-share path takes over from there. */
const SPARSE_RUN_THRESHOLD = 7;
/**
* Days PAST the profile's typical cycle length before the wheel stops
* counting up. The engine keeps the open cycle's phase window alive until
* the predicted next start, and a confirmed/anchored ovulation signal (BBT
* trend, symptothermal double-check, LH surge) or a long estimated length
* can push that prediction into the future while the last logged period is
* months old — every day of the gap then carries a (population-framed)
* LUTEAL label and the day-of-cycle walks up without bound ("day 90").
* Beyond typical + grace the count is no longer an observed fact, so the
* wheel degrades to the honest overdue state instead. Two weeks of grace
* covers ordinary cycle-to-cycle variation without hiding a real delay.
*/
const OVERDUE_GRACE_DAYS = 14;
/**
* The canonical four-phase span set for a low-data tracker, derived from the
* profile's typical lengths (textbook defaults when unset): a 28-day cycle
@@ -136,20 +159,51 @@ export function deriveWheelState(
const byDate = new Map(days.map((d) => [d.date, d]));
const todayDay = byDate.get(today);
if (!todayDay || todayDay.phase == null) {
return { dayOfCycle: null, cycleLength: null, phase: null, spans: [] };
return {
dayOfCycle: null,
cycleLength: null,
phase: null,
spans: [],
periodOverdue: false,
};
}
// Sort dates ascending and find today's index.
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
const todayIdx = sorted.findIndex((d) => d.date === today);
if (todayIdx < 0) {
return { dayOfCycle: null, cycleLength: null, phase: null, spans: [] };
return {
dayOfCycle: null,
cycleLength: null,
phase: null,
spans: [],
periodOverdue: false,
};
}
const startIdx = findCycleStartIndex(sorted, todayIdx);
const run = sorted.slice(startIdx, todayIdx + 1);
const dayOfCycle = run.length;
// Honest ceiling: once the run overshoots the typical length + grace, the
// count reflects the engine's open-ended cycle window, not an observed
// cycle day. Degrade to the idealized ring with no day / phase claim; the
// view pairs it with the "period overdue" caption + log CTA.
const typicalLength = Math.max(
Math.round(profile?.typicalCycleLength ?? 28) || 28,
7,
);
if (dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS) {
const ideal = idealizedSpans(profile);
return {
dayOfCycle: null,
cycleLength: ideal.cycleLength,
phase: null,
spans: ideal.spans,
periodOverdue: true,
};
}
// Tally each phase's day-count across the run + the forward predicted run
// (so the ring shows the whole cycle, not just elapsed days).
const counts: Record<CyclePhase, number> = {
@@ -185,6 +239,7 @@ export function deriveWheelState(
cycleLength: ideal.cycleLength,
phase: todayDay.phase,
spans: ideal.spans,
periodOverdue: false,
};
}
@@ -199,5 +254,6 @@ export function deriveWheelState(
cycleLength: fullRun.length,
phase: todayDay.phase,
spans,
periodOverdue: false,
};
}
+1 -3
View File
@@ -289,9 +289,7 @@ function EpisodeGroup({
aria-hidden="true"
/>
{title}
<span className="text-muted-foreground/70 normal-case">
({parents.length})
</span>
<span className="normal-case">({parents.length})</span>
</button>
{open ? grid : null}
</section>
@@ -97,7 +97,7 @@ export function GuidedQuestionsCard({
disabled={disabled}
onClick={onDismissAll}
className={cn(
"text-muted-foreground/70 hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
@@ -714,7 +714,7 @@ function BubbleTimestamp({
aria-label={t("insights.coach.messageTimeLabel", { time: label })}
onClick={() => setOpen((o) => !o)}
onBlur={() => setOpen(false)}
className="peer text-muted-foreground/70 hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex size-11 items-center justify-center rounded outline-none focus-visible:ring-2 sm:size-8"
className="peer text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex size-11 items-center justify-center rounded outline-none focus-visible:ring-2 sm:size-8"
>
<Clock className="size-3" aria-hidden="true" />
</button>
@@ -29,7 +29,7 @@ export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) {
return (
<p
data-slot="coach-token-footer"
className="text-muted-foreground/80 text-[11px] tabular-nums"
className="text-muted-foreground text-[11px] tabular-nums"
>
{label}
</p>
+7 -51
View File
@@ -25,7 +25,6 @@ import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
@@ -140,20 +139,6 @@ interface DailyBriefingProps {
* comparison toggle migrates here from the hero in commit 5.
*/
metaSlot?: React.ReactNode;
/**
* v1.21.2 (A4) — server-resolved callback + forward-look. The briefing
* recalls the prior period and points ahead ("as I noted Monday, your sleep
* was short; it's recovered since — worth watching tonight"). Both strings
* are already localised and figure-anchored by the resolver; the card only
* renders them. One callback per surface — absent (the common case) when no
* prior narrative recall is on file.
*/
memory?: {
/** The recall of the prior-period read, anchored to a figure. */
recall: string;
/** The forward-look pointing ahead. */
forward: string;
} | null;
}
const METRIC_ICON: Record<
@@ -368,7 +353,6 @@ export function DailyBriefing({
generationFailed = false,
generationFailureClass = null,
metaSlot,
memory = null,
}: DailyBriefingProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -424,33 +408,11 @@ export function DailyBriefing({
// looser `space-y-4`, which read as taller than its neighbours on
// the overview. One token, no new spacing scale.
<div className="space-y-3">
{/* v1.21.2 (A4) — callback + forward-look. Recalls the prior
period and points ahead, both server-resolved and
figure-anchored. Leads the card so the briefing opens by
closing the loop on what it noted last, then looks forward. */}
{memory && (
<div
data-slot="daily-briefing-memory"
className="border-border/60 bg-card/40 space-y-1 rounded-md border px-3 py-2"
>
<p
data-slot="daily-briefing-memory-recall"
className="text-muted-foreground text-xs leading-snug"
>
{t("insights.briefing.memory.recall", {
text: memory.recall,
})}
</p>
<p
data-slot="daily-briefing-memory-forward"
className="text-foreground/80 text-xs leading-snug"
>
{t("insights.briefing.memory.forward", {
text: memory.forward,
})}
</p>
</div>
)}
{/* The v1.21.2 recall/forward-look block is gone: the card
opens straight on "signals of the day". The narrative
memory read as a second briefing paragraph above the
structured list and pulled the card down; the server
still resolves `briefingMemory` for other consumers. */}
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
The hero strip subtitle on `/insights` already renders
the same `briefing.paragraph` text directly above this
@@ -465,7 +427,7 @@ export function DailyBriefing({
<div className="space-y-2">
<p
data-slot="daily-briefing-signals-title"
className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"
className="text-foreground text-xs font-semibold tracking-wide uppercase"
>
{t("insights.dailyBriefing.signalsTitle")}
</p>
@@ -483,7 +445,7 @@ export function DailyBriefing({
<div className="space-y-2">
<p
data-slot="daily-briefing-findings-title"
className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"
className="text-foreground text-xs font-semibold tracking-wide uppercase"
>
{t("insights.dailyBriefing.keyFindingsTitle")}
</p>
@@ -565,12 +527,6 @@ export function DailyBriefing({
)}
</div>
)}
{/* v1.21.0 (C4 H2) — hand off to the Coach for the whole
picture. The briefing spans every metric, so no scope: the
default all-source snapshot reads best here. */}
<div className="flex justify-end">
<AskCoachAction question={t("insights.coach.seed.briefing")} />
</div>
</div>
) : noProvider ? (
// v1.15.20 — no provider configured anywhere: a regenerate CTA
@@ -185,10 +185,25 @@ export function CompositeScoreAnatomy({
}))
.sort(rankByImpact);
} else {
// Persisted nightly score — no sub-decomposition; ring + provenance.
// Persisted nightly score — ring + provenance. RECOVERY additionally
// carries the readiness-blend factor decomposition (v1.27.5, resolved
// server-side by the same engine that mints the nightly score), so it
// renders the same ranked contributor rows as the composites. The
// factor set IS the readiness component set, so the labels reuse the
// READINESS component keys verbatim. STRESS / STRAIN stay row-less.
const v = data.value as WellnessScoreValue;
score = v.score;
caption = t(`insights.derived.scoreRing.band.${v.band}`);
if (metric === "RECOVERY_SCORE" && v.components) {
contributors = v.components
.map((c) => ({
key: c.key,
label: t(`insights.derived.composite.READINESS.component.${c.key}`),
value: c.value,
weight: c.weight,
}))
.sort(rankByImpact);
}
}
}
@@ -223,7 +223,7 @@ export function GlucoseClinicalPanel() {
the data. */}
{clinical.isSpotEstimate ? (
<p
className="text-muted-foreground/80 text-xs"
className="text-muted-foreground text-xs"
data-slot="glucose-spot-caveat"
>
{t("insights.bloodGlucose.clinical.spotCaveat")}
+2 -2
View File
@@ -264,7 +264,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
{session.source ? (
<span
data-slot="sleep-hypnogram-source"
className="text-muted-foreground/70 text-xs"
className="text-muted-foreground text-xs"
>
{t("insights.sleep.hypnogram.source", {
source: session.source,
@@ -278,7 +278,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
{session.reconstructed === true && hasBreakdown ? (
<span
data-slot="sleep-hypnogram-estimate-note"
className="text-muted-foreground/70 text-xs"
className="text-muted-foreground text-xs"
>
{t("insights.sleep.hypnogram.estimateNote")}
</span>
+1 -1
View File
@@ -406,7 +406,7 @@ export function SidebarNav() {
<button
onClick={toggleCollapsed}
className={cn(
"text-muted-foreground/80 hover:text-foreground hover:bg-accent z-20 rounded-md p-1 transition-colors",
"text-muted-foreground hover:text-foreground hover:bg-accent z-20 rounded-md p-1 transition-colors",
className,
)}
aria-label={t("nav.collapseSidebar")}
@@ -784,9 +784,10 @@ function VorsorgeCard({
: "";
const isCoach = reminder.origin === "COACH";
const isLinked = reminder.measurementType != null;
// Due now / overdue ⇒ the action button takes the green "do it now" tone.
// The CARD stays neutral (no tint) per the project rule — only the action
// button goes green; the surface follows the medication-card grammar.
// v1.27.5 — due-ness never changes the ACTION BUTTON either: the button
// keeps one constant look in every state (same rule as the medication
// card's constant surface). Due / overdue reads only through the discreet
// coloured next-due text + the adherence meta line below.
const isDue = due.key === "nextDue.today" || due.key === "overdueByDays";
const progress = intervalProgress(reminder, now);
@@ -873,18 +874,15 @@ function VorsorgeCard({
</DropdownMenu>
);
// v1.18.6 (MOD-06) — the green "Jetzt messen" / mark-done action. Green only
// on the action button (never the card surface). When not due it stays the
// calm default tone so the green reads as "now is the time". `bg-success`
// pairs with the theme-aware `text-success-foreground` (dark glyph in dark,
// white in light) so the label clears WCAG AA in both themes.
// The measure / mark-done action. One constant appearance in every state —
// no due-driven tint, no label swap (v1.27.5 retired the former green
// "do it now" wash; a button that changes colour by schedule state reads as
// a different control). Due-ness is communicated by the discreet coloured
// next-due text above.
const primaryButton = (
<Button
type="button"
className={cn(
"min-h-11 w-full",
isDue && "bg-success text-success-foreground hover:bg-success/90",
)}
className="min-h-11 w-full"
onClick={onPrimaryAction}
disabled={busy}
>
@@ -949,11 +947,7 @@ function VorsorgeCard({
<Button
type="button"
size="sm"
className={cn(
"min-h-11 sm:min-h-9",
isDue &&
"bg-success text-success-foreground hover:bg-success/90",
)}
className="min-h-11 sm:min-h-9"
onClick={onPrimaryAction}
disabled={busy}
>
@@ -1019,9 +1013,9 @@ function VorsorgeCard({
return (
<>
{/* NEUTRAL card — no status-driven colour. Due state reads only through
the discreet badge + the green action button below (MOD-06). Shares
the medication card shell + tokens. */}
{/* NEUTRAL card — no status-driven colour anywhere on the surface or
the action button. Due state reads only through the discreet
coloured next-due text. Shares the medication card shell + tokens. */}
<Card className="h-full gap-3 md:gap-3">
<MedicationCardHeader
name={resolveReminderLabel(reminder, t)}
@@ -654,6 +654,15 @@ export function MeasurementList({
setEditError(t("measurements.duplicateTimestamp"));
return;
}
// v1.27.5 (di-001) — value edits on server-owned sources
// (connector / import / computed rows) are refused with a 409.
if (
err instanceof ApiError &&
err.meta?.errorCode === "measurement.update.server_owned_source"
) {
setEditError(t("measurements.serverOwnedSource"));
return;
}
setEditError(
err instanceof Error ? err.message : t("measurements.saveError"),
);
@@ -288,7 +288,10 @@ describe("medication cycle status — open-cycle line", () => {
expect(html).toContain("lucide-calendar-clock");
});
it("missed reads the destructive overdue line", () => {
it("missed renders nothing — the overdue escalation already reads on the next-intake line", () => {
// v1.27.5 — the card's next-intake value line carries the destructive
// overdue escalation; a second overdue line below the streak row doubled
// the signal and is retired.
const html = render(
<MedicationCycleStatus
cycle={{
@@ -299,9 +302,7 @@ describe("medication cycle status — open-cycle line", () => {
}}
/>,
);
expect(html).toContain("Overdue");
expect(html).toContain("text-destructive");
expect(html).toContain("lucide-triangle-alert");
expect(html).toBe("");
});
});
@@ -1,4 +1,4 @@
import { AlertTriangle, CalendarClock, CircleDashed } from "lucide-react";
import { CalendarClock, CircleDashed } from "lucide-react";
import type { CurrentCycle } from "@/lib/analytics/compliance";
import { useTranslations } from "@/lib/i18n/context";
@@ -20,13 +20,15 @@ interface MedicationCycleStatusProps {
*
* The calm `on_track` "next dose in N days" phrasing is intentionally NOT
* rendered here: the upcoming-dose timing already reads on the card's
* next-intake slot at the top, so repeating it would be a duplicate. Only the
* actionable due / overdue tiers (and the neutral no-closed-cycles note) earn
* this line.
* next-intake slot at the top, so repeating it would be a duplicate. The same
* reasoning retired the `missed` tier in v1.27.5 — an overdue dose already
* escalates on the next-intake value line (the destructive "Überfällig" /
* "Stark überfällig" row), so a second overdue line below the streak row
* doubled the signal. Only the `due` tier (and the neutral no-closed-cycles
* note) earns this line.
*
* Driven by `currentCycle.state` (`on_track` / `due` / `missed` / `none`) plus
* `hasClosedCycles`. Colour follows the same semantic ramp the status pill
* uses (warning / destructive) and pairs every tier with a Lucide glyph so
* `hasClosedCycles`. The due tier uses the warning tone + a Lucide glyph so
* colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color). It
* complements the compliance number; it does not replace it.
*/
@@ -49,31 +51,17 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
);
}
// on_track — the next dose simply hasn't come round yet. The upcoming-dose
// timing already reads on the card's next-intake slot at the top, so a second
// "next dose in N days" line here is a duplicate. Render nothing for the
// on-track case; only the actionable due / overdue tiers earn this line.
if (cycle.state !== "missed" && cycle.state !== "due") return null;
let tone: string;
let icon: React.ReactNode;
let label: string;
if (cycle.state === "missed") {
tone = "text-destructive";
icon = <AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />;
label = t("medications.cycleOverdue");
} else {
// due
tone = "text-warning";
icon = <CalendarClock className="size-3.5 shrink-0" aria-hidden="true" />;
label = t("medications.cycleDueToday");
}
// on_track — the next dose simply hasn't come round yet; the upcoming-dose
// timing already reads on the card's next-intake slot at the top. missed —
// the overdue escalation already reads on that same next-intake value line
// (destructive tone + glyph), so a second overdue line here doubled it.
// Both render nothing; only the due tier earns this line.
if (cycle.state !== "due") return null;
return (
<p className={`flex items-center gap-1.5 text-xs font-medium ${tone}`}>
{icon}
{label}
<p className="text-warning flex items-center gap-1.5 text-xs font-medium">
<CalendarClock className="size-3.5 shrink-0" aria-hidden="true" />
{t("medications.cycleDueToday")}
</p>
);
}
@@ -538,7 +538,7 @@ function WizardDialogShell({
knows what the Next tap leads to (or, on the final
slot, that Next saves). */}
<p
className="text-muted-foreground/80 flex items-center gap-0.5 pt-0.5 text-xs"
className="text-muted-foreground flex items-center gap-0.5 pt-0.5 text-xs"
data-slot="wizard-next-hint"
>
<ChevronRight
+5 -2
View File
@@ -1081,9 +1081,12 @@ function MoodNoteText({
ref={paragraphRef}
data-testid="mood-note-text"
className={
// User-authored content renders in foreground — user data is never
// muted (the row's timestamp stays muted); italic keeps the note
// visually distinct from app copy.
expanded
? "text-muted-foreground/80 text-xs break-words whitespace-pre-wrap italic"
: "text-muted-foreground/80 line-clamp-2 text-xs italic"
? "text-foreground text-sm break-words whitespace-pre-wrap italic"
: "text-foreground line-clamp-2 text-sm italic"
}
>
{note}
@@ -328,7 +328,7 @@ export function FitbitCard({
/>
</div>
</div>
<p className="text-muted-foreground/80 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
@@ -359,7 +359,7 @@ export function GoogleHealthCard({
/>
</div>
</div>
<p className="text-muted-foreground/80 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
@@ -282,7 +282,7 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
autoCapitalize="none"
enterKeyHint="done"
/>
<p className="text-muted-foreground/80 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.nightscoutTokenHelp")}
</p>
</div>
@@ -292,7 +292,7 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
<Label htmlFor="nightscout-private" className="text-sm">
{t("settings.nightscoutPrivateHost")}
</Label>
<p className="text-muted-foreground/80 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.nightscoutPrivateHostHelp")}
</p>
</div>
@@ -322,7 +322,7 @@ export function OAuthProviderCard({
/>
</div>
</div>
<p className="text-muted-foreground/80 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
@@ -357,7 +357,7 @@ export function OAuthProviderCard({
{serverUnavailable && (
<p
className="text-muted-foreground/80 text-xs"
className="text-muted-foreground text-xs"
data-testid={`${provider}-unavailable`}
>
{t(`${i18nPrefix}Unavailable`)}
@@ -54,7 +54,7 @@ export function IntegrationCardDescription({
return (
<>
<p className="text-foreground">{t(`${i18nPrefix}Description`)}</p>
<p className="text-muted-foreground/80">
<p className="text-muted-foreground">
{t(`${i18nPrefix}DescriptionSecondary`)}{" "}
<a
href={integrationDocsHref(provider)}
@@ -309,7 +309,7 @@ export function WhoopCard({
/>
</div>
</div>
<p className="text-muted-foreground/80 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
@@ -353,7 +353,7 @@ export function WithingsCard({
/>
</div>
</div>
<p className="text-muted-foreground/80 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
@@ -106,13 +106,13 @@ export function ModuleToggleRow({
)}
<p className="text-muted-foreground text-xs">{description}</p>
{locked && lockedNote ? (
<p className="text-muted-foreground/80 text-xs">{lockedNote}</p>
<p className="text-muted-foreground text-xs">{lockedNote}</p>
) : null}
</div>
</div>
{operatorDisabled ? (
// Operator kill-switch: read-only note, no toggle.
<span className="text-muted-foreground/80 mt-0.5 shrink-0 text-xs">
<span className="text-muted-foreground mt-0.5 shrink-0 text-xs">
{operatorNote}
</span>
) : managed ? (
+1 -1
View File
@@ -83,7 +83,7 @@ export function LearningGate({
{caveat ? (
<p
data-slot="learning-gate-caveat"
className="text-muted-foreground/80 text-xs leading-snug"
className="text-muted-foreground text-xs leading-snug"
>
{caveat}
</p>
+2 -2
View File
@@ -219,9 +219,9 @@ Synthese statt Aufzählung: die Geschichte dessen, was die Daten bedeuten, zähl
},
{
id: "output",
en: `OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment as English flowing prose (2-4 sentences):
en: `OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment in English: 1-3 short paragraphs of 1-3 sentences each, separated by a blank line written as \\n\\n INSIDE the JSON string. A steady one-liner stays a single paragraph:
{ "summary": "..." }`,
de: `AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. Das Feld "summary" enthält die komplette Einschätzung als deutscher Fließtext (2-4 Sätze):
de: `AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. Das Feld "summary" enthält die komplette Einschätzung auf Deutsch: 1-3 kurze Absätze mit je 1-3 Sätzen, getrennt durch eine Leerzeile als \\n\\n INNERHALB des JSON-Strings. Ein stabiler Einzeiler bleibt EIN Absatz:
{ "summary": "..." }`,
},
];
+8 -4
View File
@@ -259,8 +259,10 @@ gib die obige Verweigerung zurück.`,
warm, and motivating. Lead with NOW; draw on history for context,
but the user opens this to learn what is happening TODAY and what
one thing they can do about it. Fields:
- paragraph: a connected 70-160 word read of TODAY the user sees
at the top of /insights. SENTENCE 1 is the day's VERDICT in plain
- paragraph: a 70-160 word read of TODAY the user sees at the top
of /insights, written as 2-3 SHORT PARAGRAPHS separated by a
blank line (\\n\\n inside the JSON string) — never one unbroken
block. SENTENCE 1 is the day's VERDICT in plain
words — the overall picture, NOT a number ("Today reads like a
recovery day", "A steady day, nothing demanding your attention").
Then weave the 2-3 most salient signals into ONE story: lead from
@@ -452,8 +454,10 @@ gib die obige Verweigerung zurück.`,
JETZT; nutze die Historie als Kontext, aber der Nutzer öffnet das, um
zu erfahren, was HEUTE passiert und welche eine Sache er dagegen tun
kann. Felder:
- paragraph: ein zusammenhängender 70-160 Wörter langer Tages-Read,
den der Nutzer oben auf /insights liest. SATZ 1 ist das URTEIL des
- paragraph: ein 70-160 Wörter langer Tages-Read, den der Nutzer
oben auf /insights liest — geschrieben als 2-3 KURZE ABSÄTZE,
getrennt durch eine Leerzeile (\\n\\n innerhalb des JSON-Strings),
nie als ein durchgehender Block. SATZ 1 ist das URTEIL des
Tages in klaren Worten — das Gesamtbild, KEINE Zahl ("Heute liest
sich wie ein Erholungstag", "Ein ruhiger Tag, nichts, das deine
Aufmerksamkeit braucht"). Dann verwebe die 2-3 wichtigsten Signale
@@ -8,7 +8,14 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.27.5 — the RECOVERY read attaches the readiness-blend components; the
// blend engine itself is covered by its own suite, so it is mocked here.
vi.mock("../readiness", () => ({
computeReadiness: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { computeReadiness } from "../readiness";
import {
computeWellnessScore,
bandWellnessScore,
@@ -22,6 +29,7 @@ const cacheFindUnique = prisma.strainTrimpCache.findUnique as ReturnType<
typeof vi.fn
>;
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const readinessMock = vi.mocked(computeReadiness);
beforeEach(() => {
findMany.mockReset();
@@ -31,6 +39,10 @@ beforeEach(() => {
// loads the zone when none is passed. Default the cohort to Europe/Berlin.
userFindUnique.mockReset();
userFindUnique.mockResolvedValue({ timezone: "Europe/Berlin" });
// Default: the blend gates (below the min-component floor) — the recovery
// read must survive that and simply omit the breakdown.
readinessMock.mockReset();
readinessMock.mockResolvedValue({ status: "insufficient" } as never);
});
describe("bandWellnessScore", () => {
@@ -142,6 +154,41 @@ describe("computeWellnessScore", () => {
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect((r.value as WellnessScoreValue).anchor).toBeNull();
// Gated blend (default mock) → no breakdown, read still ok.
expect((r.value as WellnessScoreValue).components).toBeNull();
}
});
it("RECOVERY attaches the readiness-blend components for a COMPUTED row", async () => {
findMany.mockResolvedValue([
{
value: 72,
measuredAt: new Date("2026-06-01T12:00:00Z"),
source: "COMPUTED",
},
]);
const components = [
{ key: "rhr", value: 90, weight: 0.25 },
{ key: "hrv", value: 60, weight: 0.25 },
{ key: "sleep", value: 70, weight: 0.25 },
{ key: "respiratory", value: null, weight: 0 },
{ key: "mood", value: 80, weight: 0.25 },
];
readinessMock.mockResolvedValue({
status: "ok",
value: { score: 74, band: "green", components },
} as never);
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
now: NOW,
});
// Same engine, same user, the resolved profile timezone threaded through.
expect(readinessMock).toHaveBeenCalledWith("u1", PROFILE, {
now: NOW,
tz: "Europe/Berlin",
});
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect((r.value as WellnessScoreValue).components).toEqual(components);
}
});
@@ -174,7 +221,11 @@ describe("computeWellnessScore", () => {
expect((r.value as WellnessScoreValue).band).toBe("green");
// One night, one canonical row — the off-by-one collapsed.
expect((r.value as WellnessScoreValue).daysInWindow).toBe(1);
// A WHOOP-native percentage is not our blend — no decomposition, and
// the blend engine is never invoked for it.
expect((r.value as WellnessScoreValue).components).toBeNull();
}
expect(readinessMock).not.toHaveBeenCalled();
});
it("STRESS still hard-filters to the COMPUTED source", async () => {
+35 -1
View File
@@ -20,6 +20,7 @@ import type {
import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage";
import type { BaselineProfile } from "./baseline";
import type { StrainAnchor } from "@/lib/insights/strain-score";
import { computeReadiness, type ReadinessComponent } from "./readiness";
import { resolveCanonicalRecovery } from "./recovery-resolve";
import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
@@ -53,6 +54,19 @@ export interface WellnessScoreValue {
* non-breaking).
*/
anchor?: StrainAnchor | null;
/**
* v1.27.5 — RECOVERY only: the factor decomposition behind the score, so
* the detail page renders the same ranked contributor bars the READINESS /
* SLEEP_SCORE composites carry. The persisted row stores only the number;
* the components come from a server-side run of the SAME readiness blend
* that mints the nightly score (`computeRecoveryScore` delegates to
* `computeReadiness` verbatim), so weights and per-factor values can never
* drift from the engine. Only attached when the canonical latest row is
* the COMPUTED proxy — a WHOOP-native percentage is not our blend, so it
* carries no decomposition. `null` / absent otherwise (additive — iOS
* non-breaking).
*/
components?: ReadinessComponent[] | null;
}
/** The three persisted score types this engine serves. */
@@ -108,7 +122,7 @@ export interface WellnessScoreOpts {
export async function computeWellnessScore(
type: WellnessScoreType,
userId: string,
_profile: BaselineProfile,
profile: BaselineProfile,
opts: WellnessScoreOpts = {},
): Promise<Derived<WellnessScoreValue>> {
const now = opts.now ?? new Date();
@@ -203,6 +217,25 @@ export async function computeWellnessScore(
}
}
// v1.27.5 — RECOVERY factor decomposition. The persisted row stores only
// the number, but the COMPUTED proxy IS the readiness blend
// (`computeRecoveryScore` delegates to `computeReadiness` verbatim), so the
// blend's per-factor sub-scores are the honest breakdown to show under the
// ring — same engine, same weights, resolved server-side on the same read.
// A WHOOP-native canonical row is not our blend and carries none. Never
// fails the read: a gated blend (below the min-component floor) simply
// leaves the breakdown off.
let components: ReadinessComponent[] | null = null;
if (isRecovery && latest.source === "COMPUTED") {
const readiness = await computeReadiness(userId, profile, {
now,
tz: timezone ?? undefined,
});
if (readiness.status === "ok" && readiness.value) {
components = readiness.value.components;
}
}
return buildOk<WellnessScoreValue>({
value: {
score,
@@ -216,6 +249,7 @@ export async function computeWellnessScore(
.map((r) => r.value)
.reverse(),
anchor,
components,
},
coverage: {
requiredInputs: 1,
+6 -1
View File
@@ -662,7 +662,12 @@ export const createMeasurementSchema = z
);
export const updateMeasurementSchema = z.object({
value: z.number().min(0).max(500000).optional(),
// No generic magnitude bound here: the route validates the new value
// against the row's OWN type through `validateMeasurementRange` after the
// lookup (the schema cannot know the type — the edit body doesn't carry
// it). The former `min(0)` also blocked legitimate edits of the signed
// types (ANS_CHARGE, BODY_TEMPERATURE_DEVIATION).
value: z.number().optional(),
// v1.17 W1b — same plausibility bound on the edit path; an edit cannot
// forward-date a reading into the future nor before 1900.
measuredAt: validateEntryInstant(