mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #406 from MBombeck/release/v1.27.5
Release v1.27.5 — insights polish and honest states
This commit is contained in:
47 files changed
+554
-210
No files matched your search
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": "..." }`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user