From 605d9b1fb4349e0072bf16de1664f1a03152b6bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 18:12:13 +0200 Subject: [PATCH 01/31] Stop rewarding a doubled or a skipped dose The 'took it twice' and 'skipped a dose' badges rewarded over- and under-dosing. Both definitions, their metrics and their copy in every locale are gone. An unlock row already persisted for either id stays in the database and in backups, but no longer renders: the evaluator only walks the shipped catalogue. --- messages/de.json | 8 -- messages/en.json | 8 -- messages/es.json | 8 -- messages/fr.json | 8 -- messages/it.json | 8 -- messages/ko.json | 8 -- messages/pl.json | 8 -- src/app/achievements/__tests__/page.test.tsx | 2 - .../__tests__/achievements-no-insults.test.ts | 17 +-- .../__tests__/achievements.test.ts | 34 ++++- src/lib/gamification/achievements-result.ts | 135 ------------------ src/lib/gamification/achievements.ts | 28 ---- 12 files changed, 35 insertions(+), 237 deletions(-) diff --git a/messages/de.json b/messages/de.json index ee6731cd2..4f5a17b6b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8047,14 +8047,6 @@ "title": "Einnahme-Legende", "description": "Dokumentiere 300 eingenommene Medikamente." }, - "overIntake1": { - "title": "Doppelt genommen", - "description": "Nimm mindestens einmal mehr ein als vorgesehen." - }, - "skippedIntake1": { - "title": "Pause eingelegt", - "description": "Überspringe mindestens eine geplante Einnahme." - }, "passkeyCreated1": { "title": "Passkey gesetzt", "description": "Erstelle deinen ersten Passkey." diff --git a/messages/en.json b/messages/en.json index 06a2e9b36..6603bcbc7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8047,14 +8047,6 @@ "title": "Intake legend", "description": "Record 300 taken medication events." }, - "overIntake1": { - "title": "Double take", - "description": "Take medication at least once more than planned." - }, - "skippedIntake1": { - "title": "Stepped back", - "description": "Skip at least one planned intake." - }, "passkeyCreated1": { "title": "Passkey set up", "description": "Create your first passkey." diff --git a/messages/es.json b/messages/es.json index ddf55c374..0c3c15219 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8047,14 +8047,6 @@ "title": "Leyenda de las tomas", "description": "Registra 300 tomas de medicación." }, - "overIntake1": { - "title": "Dosis doble", - "description": "Toma la medicación al menos una vez más de lo previsto." - }, - "skippedIntake1": { - "title": "Un paso atrás", - "description": "Omite al menos una toma planificada." - }, "passkeyCreated1": { "title": "Clave de acceso creada", "description": "Crea tu primera clave de acceso." diff --git a/messages/fr.json b/messages/fr.json index c1ff33246..eeeeb1e25 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8047,14 +8047,6 @@ "title": "Légende des prises", "description": "Enregistrez 300 prises de médicaments." }, - "overIntake1": { - "title": "Double dose", - "description": "Prenez votre médicament au moins une fois de plus que prévu." - }, - "skippedIntake1": { - "title": "Pause prise", - "description": "Sautez au moins une prise prévue." - }, "passkeyCreated1": { "title": "Clé d’accès configurée", "description": "Créez votre première clé d’accès." diff --git a/messages/it.json b/messages/it.json index d6cd9c4f4..162c4ed1f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8047,14 +8047,6 @@ "title": "Leggenda delle assunzioni", "description": "Registra 300 assunzioni di farmaci." }, - "overIntake1": { - "title": "Doppia dose", - "description": "Assumi il farmaco almeno una volta in più del previsto." - }, - "skippedIntake1": { - "title": "Pausa presa", - "description": "Salta almeno un’assunzione pianificata." - }, "passkeyCreated1": { "title": "Passkey configurata", "description": "Crea la tua prima passkey." diff --git a/messages/ko.json b/messages/ko.json index 56aec5aa9..8aa72d743 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8047,14 +8047,6 @@ "title": "복용 전설", "description": "복용 기록 300회를 남겨요." }, - "overIntake1": { - "title": "한 번 더", - "description": "계획보다 한 번 이상 더 복용해요." - }, - "skippedIntake1": { - "title": "한 걸음 물러서기", - "description": "계획된 복용을 한 번 이상 건너뛰어요." - }, "passkeyCreated1": { "title": "패스키 등록", "description": "첫 패스키를 만들어요." diff --git a/messages/pl.json b/messages/pl.json index f0a04ad32..6f50fcf3a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8047,14 +8047,6 @@ "title": "Legenda przyjęć", "description": "Zarejestruj 300 przyjęć leków." }, - "overIntake1": { - "title": "Podwójna dawka", - "description": "Przyjmij lek co najmniej raz więcej niż zaplanowano." - }, - "skippedIntake1": { - "title": "Krok wstecz", - "description": "Pomiń co najmniej jedno zaplanowane przyjęcie." - }, "passkeyCreated1": { "title": "Klucz dostępu ustawiony", "description": "Utwórz swój pierwszy klucz dostępu." diff --git a/src/app/achievements/__tests__/page.test.tsx b/src/app/achievements/__tests__/page.test.tsx index e29878045..5f11c7a5b 100644 --- a/src/app/achievements/__tests__/page.test.tsx +++ b/src/app/achievements/__tests__/page.test.tsx @@ -132,8 +132,6 @@ const mockData = { ] satisfies AchievementProgress[], metrics: { totalTakenIntakes: 1, - overIntakeCount: 0, - skippedIntakeCount: 0, bmiGreenStreak: 0, bpGreenStreak: 0, pulseGreenStreak: 0, diff --git a/src/lib/__tests__/achievements-no-insults.test.ts b/src/lib/__tests__/achievements-no-insults.test.ts index 9c6e30423..38c8e4419 100644 --- a/src/lib/__tests__/achievements-no-insults.test.ts +++ b/src/lib/__tests__/achievements-no-insults.test.ts @@ -41,17 +41,14 @@ describe("achievement titles", () => { expect(offending).toEqual([]); }); + // The two badges those titles belonged to rewarded a doubled and a + // skipped dose. They are gone; their keys must not drift back in. test.each([ ["en", enMessages as unknown as MessagesShape], ["de", deMessages as unknown as MessagesShape], - ] as const)( - "%s overIntake1/skippedIntake1 are translated, not the literal English shipped before", - (_locale, bundle) => { - const badges = bundle.achievements?.badges ?? {}; - expect(badges.overIntake1?.title).toBeDefined(); - expect(badges.skippedIntake1?.title).toBeDefined(); - expect(badges.overIntake1?.title).not.toBe("Idiot"); - expect(badges.skippedIntake1?.title).not.toBe("Lazy Boy"); - }, - ); + ] as const)("%s ships no dosing badge copy", (_locale, bundle) => { + const badges = bundle.achievements?.badges ?? {}; + expect(badges).not.toHaveProperty("overIntake1"); + expect(badges).not.toHaveProperty("skippedIntake1"); + }); }); diff --git a/src/lib/gamification/__tests__/achievements.test.ts b/src/lib/gamification/__tests__/achievements.test.ts index 215f22bac..b42dcfdd0 100644 --- a/src/lib/gamification/__tests__/achievements.test.ts +++ b/src/lib/gamification/__tests__/achievements.test.ts @@ -13,8 +13,6 @@ import { const FULL_METRICS: AchievementMetrics = { totalTakenIntakes: 1000, - overIntakeCount: 1000, - skippedIntakeCount: 1000, bmiGreenStreak: 1000, bpGreenStreak: 1000, pulseGreenStreak: 1000, @@ -79,8 +77,8 @@ describe("gamification achievements", () => { ).toBe(3); }); - it("ships the expanded definition list (63 total — v1.4.18 + v1.16.1 care routine)", () => { - expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(63); + it("ships the expanded definition list (61 total — v1.4.18 + v1.16.1 care routine, minus the two dosing badges)", () => { + expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(61); }); it("includes mood and hidden categories in the render order", () => { @@ -91,8 +89,8 @@ describe("gamification achievements", () => { it("evaluates unlocked achievements and points when all metrics maxed", () => { const result = evaluateAchievementsWithCompletionDates(FULL_METRICS, {}); - expect(result.summary.unlockedCount).toBe(63); - expect(result.summary.totalCount).toBe(63); + expect(result.summary.unlockedCount).toBe(61); + expect(result.summary.totalCount).toBe(61); expect(result.summary.nextAchievement).toBeNull(); expect(result.summary.earnedPoints).toBe(result.summary.totalPoints); }); @@ -227,3 +225,27 @@ describe("bridgeFrozenStreakGaps (Rest Mode streak-freeze)", () => { expect(calculateLongestStreak(bridged)).toBe(1); }); }); + +describe("dosing is never rewarded", () => { + // A badge for a dose taken twice or a dose skipped rewards over- or + // under-dosing. Neither behaviour is an achievement. + it("ships no badge for a doubled or a skipped dose", () => { + const ids = ACHIEVEMENT_DEFINITIONS.map((d) => d.id); + expect(ids).not.toContain("over-intake-1"); + expect(ids).not.toContain("skipped-intake-1"); + const metrics = ACHIEVEMENT_DEFINITIONS.map((d) => String(d.metric)); + expect(metrics).not.toContain("overIntakeCount"); + expect(metrics).not.toContain("skippedIntakeCount"); + }); + + it("ignores an unlock row persisted for a retired badge", () => { + const result = evaluateAchievementsWithCompletionDates(ZERO_METRICS, { + "over-intake-1": new Date("2026-03-01T08:00:00Z"), + "skipped-intake-1": new Date("2026-03-02T08:00:00Z"), + }); + const ids = result.achievements.map((a) => a.id); + expect(ids).not.toContain("over-intake-1"); + expect(ids).not.toContain("skipped-intake-1"); + expect(result.summary.unlockedCount).toBe(0); + }); +}); diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index be9578b71..1685916c1 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -370,115 +370,6 @@ function getOnTimePerfectDaySeries( return toDaySeries(perfectDays); } -function parseDaysOfWeek(daysOfWeek: string | null): Set | null { - if (!daysOfWeek) return null; - - const values = daysOfWeek - .split(",") - .map((value) => Number(value.trim())) - .filter((value) => Number.isInteger(value) && value >= 0 && value <= 6); - - return values.length > 0 ? new Set(values) : null; -} - -function getExpectedIntakesForDay( - schedules: MedicationScheduleRecord[], - dayKey: string, -): number { - if (schedules.length === 0) return 0; - if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey)) return 0; - - const day = dayKeyToDate(dayKey).getUTCDay(); - let expected = 0; - - for (const schedule of schedules) { - const allowedDays = parseDaysOfWeek(schedule.daysOfWeek); - if (!allowedDays || allowedDays.has(day)) { - expected += 1; - } - } - - return expected; -} - -function getIntakeIssueMetrics( - intakeEvents: IntakeEventRecord[], - schedulesByMedicationId: Map, - tz: string, -) { - const skippedIntakeDates = intakeEvents - .filter((event) => event.skipped) - .map((event) => event.scheduledFor) - .sort((a, b) => a.getTime() - b.getTime()); - - const eventsByMedicationDay = new Map< - string, - Map - >(); - - for (const event of intakeEvents) { - if (!event.medicationId || Number.isNaN(event.scheduledFor.getTime())) { - continue; - } - - const dayKey = userDayKey(event.scheduledFor, tz); - const byDay = eventsByMedicationDay.get(event.medicationId) ?? new Map(); - const list = byDay.get(dayKey) ?? []; - list.push(event); - byDay.set(dayKey, list); - eventsByMedicationDay.set(event.medicationId, byDay); - } - - let overIntakeCount = 0; - const overIntakeDates: Date[] = []; - - for (const [medicationId, dayMap] of eventsByMedicationDay.entries()) { - const schedules = schedulesByMedicationId.get(medicationId) ?? []; - - for (const [dayKey, events] of dayMap.entries()) { - const expectedCount = getExpectedIntakesForDay(schedules, dayKey); - - if (expectedCount <= 0) { - continue; - } - - const takenEvents = events - .filter((event) => !event.skipped && event.takenAt !== null) - .sort((a, b) => { - const aTime = (a.takenAt ?? a.scheduledFor).getTime(); - const bTime = (b.takenAt ?? b.scheduledFor).getTime(); - return aTime - bTime; - }); - - const excessCount = Math.max(0, takenEvents.length - expectedCount); - if (excessCount === 0) { - continue; - } - - overIntakeCount += excessCount; - - for ( - let index = takenEvents.length - excessCount; - index < takenEvents.length; - index++ - ) { - const event = takenEvents[index]; - if (!event) continue; - overIntakeDates.push(event.takenAt ?? event.scheduledFor); - } - } - } - - overIntakeDates.sort((a, b) => a.getTime() - b.getTime()); - - return { - skippedIntakeCount: skippedIntakeDates.length, - skippedIntakeDates, - overIntakeCount, - overIntakeDates, - }; -} - function getCompliance80DaySeries( intakeEvents: IntakeEventRecord[], startDate: Date, @@ -748,12 +639,6 @@ export async function buildAchievementsResult( const schedulesByMedicationId = new Map( medications.map((med) => [med.id, med.schedules]), ); - const intakeIssueMetrics = getIntakeIssueMetrics( - intakeEvents, - schedulesByMedicationId, - tz, - ); - const takenIntakeDates = intakeEvents .filter((event) => !event.skipped && event.takenAt !== null) .map((event) => event.takenAt as Date); @@ -850,8 +735,6 @@ export async function buildAchievementsResult( const metrics = { totalTakenIntakes: takenIntakeDates.length, - overIntakeCount: intakeIssueMetrics.overIntakeCount, - skippedIntakeCount: intakeIssueMetrics.skippedIntakeCount, // v1.18.1 P4 — every day-streak runs through `freeze(...)` so an active // or past illness episode bridges (does not break) the run across the // days the user was unwell. @@ -917,24 +800,6 @@ export async function buildAchievementsResult( continue; } - if (definition.metric === "overIntakeCount") { - const completedAt = findCountCompletionDate( - intakeIssueMetrics.overIntakeDates, - definition.target, - ); - if (completedAt) completionDates[definition.id] = completedAt; - continue; - } - - if (definition.metric === "skippedIntakeCount") { - const completedAt = findCountCompletionDate( - intakeIssueMetrics.skippedIntakeDates, - definition.target, - ); - if (completedAt) completionDates[definition.id] = completedAt; - continue; - } - if (definition.metric === "loginDayStreak") { const completedAt = findStreakCompletionDate( loginDaySeries.dayKeys, diff --git a/src/lib/gamification/achievements.ts b/src/lib/gamification/achievements.ts index 1e2862333..a526fa146 100644 --- a/src/lib/gamification/achievements.ts +++ b/src/lib/gamification/achievements.ts @@ -2,8 +2,6 @@ export const GAMIFICATION_ROLLOUT_AT = new Date("2026-02-20T00:00:00.000Z"); export type AchievementMetricKey = | "totalTakenIntakes" - | "overIntakeCount" - | "skippedIntakeCount" | "bmiGreenStreak" | "bpGreenStreak" | "pulseGreenStreak" @@ -117,8 +115,6 @@ export interface EarnabilityFlags { function categoryForMetric(metric: AchievementMetricKey): AchievementCategory { switch (metric) { case "totalTakenIntakes": - case "overIntakeCount": - case "skippedIntakeCount": case "onTimePerfectDayStreak": case "compliance80DayStreak": case "missFreeDayStreak": @@ -172,8 +168,6 @@ export const ACHIEVEMENT_CATEGORY_ORDER: readonly AchievementCategory[] = [ export interface AchievementMetrics { totalTakenIntakes: number; - overIntakeCount: number; - skippedIntakeCount: number; bmiGreenStreak: number; bpGreenStreak: number; pulseGreenStreak: number; @@ -319,26 +313,6 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [ tDescription: "achievements.badges.intakeTotal300.description", format: "count", }), - define({ - id: "over-intake-1", - metric: "overIntakeCount", - target: 1, - points: 0, - icon: "AlertTriangle", - tTitle: "achievements.badges.overIntake1.title", - tDescription: "achievements.badges.overIntake1.description", - format: "count", - }), - define({ - id: "skipped-intake-1", - metric: "skippedIntakeCount", - target: 1, - points: 0, - icon: "SkipForward", - tTitle: "achievements.badges.skippedIntake1.title", - tDescription: "achievements.badges.skippedIntake1.description", - format: "count", - }), define({ id: "passkey-created-1", metric: "passkeyCreatedCount", @@ -897,8 +871,6 @@ function isEarnable( ): boolean { switch (metric) { case "totalTakenIntakes": - case "overIntakeCount": - case "skippedIntakeCount": case "onTimePerfectDayStreak": case "compliance80DayStreak": case "missFreeDayStreak": From ac482c508d16b079a86a0623b8f1e8e80c0d1660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 18:17:20 +0200 Subject: [PATCH 02/31] Serve the device's day strain on the strain page when no proxy exists The recovery page charts the band's own DAY_STRAIN while the strain score page read only the computed proxy, so one account showed weeks of strain on one page and 'not enough data' on the other. Without a proxy row in the window the derived strain read now falls back to DAY_STRAIN: the score is the reading as a share of the device scale, an additive 'device' block carries the native value, and the course below the card charts that same series. --- docs/api/openapi.yaml | 18 ++++ messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- .../derived/composite-score-anatomy.tsx | 28 +++++- .../insights/derived/score-history.tsx | 5 +- .../derived/__tests__/wellness-scores.test.ts | 73 +++++++++++++++ src/lib/insights/derived/wellness-scores.ts | 88 +++++++++++++++++++ src/lib/openapi/routes/insights/schemas.ts | 14 +++ 13 files changed, 241 insertions(+), 13 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b66a81c6c..e9dfaa516 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -83586,6 +83586,24 @@ components: - weight additionalProperties: false - type: "null" + device: + description: STRAIN only — present when no computed strain score exists in the window and the score is served from the + device's own DAY_STRAIN instead. `score` is then `value` as a 0–100 share of `scaleMax`. Absent when the + computed proxy is the source. + anyOf: + - type: object + properties: + value: + type: number + description: The device's latest day strain on its own scale. + scaleMax: + type: number + description: Top of the device scale (21 for WHOOP day strain). + required: + - value + - scaleMax + additionalProperties: false + - type: "null" required: - score - band diff --git a/messages/de.json b/messages/de.json index 4f5a17b6b..3f66e1bbd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4340,7 +4340,9 @@ "method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.", "anchorPersonal": "An deine eigene jüngste Trainingstagslast angelehnt — bewertet relativ zu deiner typischen Anstrengung, sodass ein für dich wirklich harter Tag hoch ausfällt.", "anchorPopulation": "Gegen einen allgemeinen Referenzwert bewertet, während du noch Trainingsverlauf aufbaust; sobald genug vorhanden ist, wechselt die Bewertung auf deine eigene typische Anstrengung.", - "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung." + "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung.", + "deviceCaption": "Tagesbelastung deines Geräts: {value} von {max}", + "deviceSource": "Bis HealthLog nachts einen eigenen Wert berechnet hat, zeigt die Karte die Tagesbelastung deines Geräts, umgerechnet auf 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Deine typische nächtliche Handgelenkstemperatur ist der Median deiner nächtlichen Messwerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Sie folgt DEINEN Werten — letzte Nacht wird als Abweichung von deiner eigenen Baseline gezeigt, nicht von einer Bevölkerungsnorm.", diff --git a/messages/en.json b/messages/en.json index 6603bcbc7..a391ae472 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4340,7 +4340,9 @@ "method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.", "anchorPersonal": "Anchored to your own recent training-day load — scored relative to your typical effort, so a genuinely hard day for you reads high.", "anchorPopulation": "Scored against a general reference while you build training history; once you have enough it switches to your own typical effort.", - "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort." + "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort.", + "deviceCaption": "Your device's day strain: {value} of {max}", + "deviceSource": "Until HealthLog has computed its own value overnight, the card shows your device's day strain, rescaled to 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Your typical overnight wrist temperature is the median of your nightly readings over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings — last night is shown as a deviation from your own baseline, not a population norm.", diff --git a/messages/es.json b/messages/es.json index 0c3c15219..17a5b9d4f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4340,7 +4340,9 @@ "method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.", "anchorPersonal": "Anclado a tu propia carga reciente de días de entrenamiento: se evalúa en relación con tu esfuerzo habitual, de modo que un día realmente duro para ti aparece alto.", "anchorPopulation": "Se evalúa frente a una referencia general mientras acumulas historial de entrenamiento; cuando tengas suficiente, pasa a tu propio esfuerzo habitual.", - "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual." + "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual.", + "deviceCaption": "Esfuerzo diario de tu dispositivo: {value} de {max}", + "deviceSource": "Hasta que HealthLog calcule su propio valor durante la noche, la tarjeta muestra el esfuerzo diario de tu dispositivo, convertido a 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Tu temperatura típica de muñeca durante la noche es la mediana de tus lecturas nocturnas en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). Sigue TUS lecturas: la noche pasada se muestra como una desviación respecto a tu propia base, no a una norma poblacional.", diff --git a/messages/fr.json b/messages/fr.json index eeeeb1e25..04eda7899 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4340,7 +4340,9 @@ "method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.", "anchorPersonal": "Calé sur votre propre charge récente des jours d’entraînement — évalué par rapport à votre effort habituel, de sorte qu’une journée vraiment difficile pour vous ressort élevée.", "anchorPopulation": "Évalué par rapport à une référence générale pendant que vous constituez votre historique d’entraînement ; dès que vous en avez assez, le calcul passe à votre propre effort habituel.", - "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel." + "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel.", + "deviceCaption": "Effort du jour de votre appareil : {value} sur {max}", + "deviceSource": "Tant que HealthLog n’a pas calculé sa propre valeur pendant la nuit, la carte affiche l’effort du jour de votre appareil, ramené sur 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Votre température nocturne typique au poignet est la médiane de vos relevés nocturnes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS relevés — la nuit dernière est affichée comme un écart par rapport à votre propre référence, pas à une norme de population.", diff --git a/messages/it.json b/messages/it.json index 162c4ed1f..0919825b9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4340,7 +4340,9 @@ "method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.", "anchorPersonal": "Ancorato al tuo carico recente nei giorni di allenamento — valutato rispetto al tuo sforzo abituale, così che una giornata davvero dura per te risulti alta.", "anchorPopulation": "Valutato rispetto a un riferimento generale mentre costruisci lo storico di allenamento; quando ne hai abbastanza passa al tuo sforzo abituale.", - "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale." + "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale.", + "deviceCaption": "Sforzo giornaliero del tuo dispositivo: {value} su {max}", + "deviceSource": "Finché HealthLog non ha calcolato un proprio valore durante la notte, la scheda mostra lo sforzo giornaliero del tuo dispositivo, riportato su 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "La tua tipica temperatura notturna al polso è la mediana delle tue letture notturne nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture — la notte scorsa è mostrata come scostamento dalla tua baseline, non da una norma di popolazione.", diff --git a/messages/ko.json b/messages/ko.json index 8aa72d743..7dc463063 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4340,7 +4340,9 @@ "method": "심박수로 가중한 활동 부하(TRIMP 방식 훈련 자극 모델)로 매일 밤 내는 0–100 부하 지표예요. 높을수록 힘든 하루였다는 뜻이에요.", "anchorPersonal": "최근 운동한 날의 부하를 기준으로 삼아요 — 평소 강도와 비교해 점수를 매기니 나에게 정말 힘든 날이 높게 나와요.", "anchorPopulation": "운동 기록이 쌓이는 동안에는 일반 기준으로 점수를 매기고, 충분히 모이면 내 평소 강도 기준으로 바뀌어요.", - "description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요." + "description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요.", + "deviceCaption": "기기의 일일 부하: {max} 중 {value}", + "deviceSource": "HealthLog가 밤사이 자체 값을 계산하기 전까지는 기기의 일일 부하를 0–100으로 환산해 보여 줘요." }, "WRIST_TEMPERATURE_BASELINE": { "method": "밤사이 손목 온도의 일반 범위는 해당 기간 야간 측정값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 내 측정값을 따라가기 때문에, 지난밤은 인구 평균이 아니라 내 기준값에서 얼마나 벗어났는지로 보여줘요.", diff --git a/messages/pl.json b/messages/pl.json index 6f50fcf3a..c3c2b4f76 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4340,7 +4340,9 @@ "method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.", "anchorPersonal": "Zakotwiczony w Twoim własnym niedawnym obciążeniu z dni treningowych — oceniany względem Twojego typowego wysiłku, więc naprawdę ciężki dzień dla Ciebie wypada wysoko.", "anchorPopulation": "Oceniany względem ogólnego punktu odniesienia, gdy dopiero budujesz historię treningów; gdy zbierzesz jej wystarczająco, przełącza się na Twój własny typowy wysiłek.", - "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku." + "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku.", + "deviceCaption": "Dzienne obciążenie z Twojego urządzenia: {value} z {max}", + "deviceSource": "Dopóki HealthLog nie obliczy w nocy własnej wartości, karta pokazuje dzienne obciążenie z Twojego urządzenia, przeliczone na 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Twoja typowa nocna temperatura nadgarstka to mediana Twoich nocnych odczytów w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty — ostatnia noc jest pokazana jako odchylenie od Twojej własnej bazy, a nie od normy populacyjnej.", diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index 7f6bee80d..17d6637ce 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -3,6 +3,7 @@ import { Sparkles } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import { resolveIntlLocale } from "@/lib/format-locale"; import { useDerivedMetric } from "./use-derived-metric"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorRow } from "@/components/ui/query-error-row"; @@ -109,7 +110,7 @@ export function CompositeScoreAnatomy({ metric, className, }: CompositeScoreAnatomyProps) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const query = useDerivedMetric< SleepScoreValue | ReadinessValue | WellnessScoreValue @@ -135,7 +136,15 @@ export function CompositeScoreAnatomy({ // the displayed framing honest per-score. `null` (RECOVERY / STRESS, or no // cache row yet) renders nothing. let anchorLine: string | null = null; - if (metric === "STRAIN_SCORE" && data?.status === "ok" && data.value) { + // Strain served from the device's own day strain (no computed proxy in the + // window): the card says so, and the course below charts that same series. + const deviceStrain = + metric === "STRAIN_SCORE" && data?.status === "ok" && data.value + ? ((data.value as WellnessScoreValue).device ?? null) + : null; + if (deviceStrain) { + anchorLine = t("insights.derived.composite.STRAIN_SCORE.deviceSource"); + } else if (metric === "STRAIN_SCORE" && data?.status === "ok" && data.value) { const anchor = (data.value as WellnessScoreValue).anchor; if (anchor === "personal") { anchorLine = t("insights.derived.composite.STRAIN_SCORE.anchorPersonal"); @@ -238,7 +247,15 @@ export function CompositeScoreAnatomy({ // 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}`); + caption = v.device + ? t("insights.derived.composite.STRAIN_SCORE.deviceCaption", { + value: v.device.value.toLocaleString(resolveIntlLocale(locale), { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }), + max: v.device.scaleMax, + }) + : t(`insights.derived.scoreRing.band.${v.band}`); if (metric === "RECOVERY_SCORE" && v.components) { contributors = v.components .map((c) => ({ @@ -275,7 +292,10 @@ export function CompositeScoreAnatomy({ data.status !== "ok" ? null : metric === "RECOVERY_SCORE" || metric === "STRESS_SCORE" || metric === "STRAIN_SCORE" ? ( - + ) : series && series.length >= 2 ? ( { expect(where.source).toBe("COMPUTED"); }); }); + +describe("computeWellnessScore — strain reads the device's day strain", () => { + // `/insights/recovery` charts the band's own DAY_STRAIN. The strain page + // must not call the same account "not enough data": with no computed proxy + // in the window it serves the device's day strain, on the device's scale. + function byType(rows: Record) { + findMany.mockImplementation( + async (args: { where: { type: string } }) => rows[args.where.type] ?? [], + ); + } + + it("falls back to DAY_STRAIN when no computed strain score exists", async () => { + byType({ + STRAIN_SCORE: [], + DAY_STRAIN: [ + { + value: 10.5, + measuredAt: new Date("2026-06-02T04:00:00Z"), + source: "WHOOP", + }, + { + value: 14.7, + measuredAt: new Date("2026-06-01T04:00:00Z"), + source: "WHOOP", + }, + ], + }); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("ok"); + if (r.status !== "ok") return; + const v = r.value as WellnessScoreValue; + expect(v.device).toEqual({ value: 10.5, scaleMax: 21 }); + expect(v.score).toBe(50); + expect(v.daysInWindow).toBe(2); + expect(r.provenance.inputs).toEqual(["DAY_STRAIN"]); + }); + + it("keeps the computed proxy when one exists", async () => { + byType({ + STRAIN_SCORE: [ + { + value: 64, + measuredAt: new Date("2026-06-01T12:00:00Z"), + source: "COMPUTED", + }, + ], + DAY_STRAIN: [ + { + value: 10.5, + measuredAt: new Date("2026-06-02T04:00:00Z"), + source: "WHOOP", + }, + ], + }); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("ok"); + if (r.status !== "ok") return; + expect(r.value.score).toBe(64); + expect(r.value.device ?? null).toBeNull(); + }); + + it("stays insufficient with neither", async () => { + byType({}); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("insufficient"); + }); +}); diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts index 4e0830d4b..c60148976 100644 --- a/src/lib/insights/derived/wellness-scores.ts +++ b/src/lib/insights/derived/wellness-scores.ts @@ -68,8 +68,24 @@ export interface WellnessScoreValue { * non-breaking). */ components?: ReadinessComponent[] | null; + /** + * STRAIN only — set when the score is the device's own day strain rather + * than the server's computed proxy: no proxy row exists in the window, but + * the band delivered `DAY_STRAIN` (WHOOP's 0–21 scale). `value` is the + * device's latest reading on its own scale; `score` is that reading as a + * share of `scaleMax`, so the ring and the band keep their 0–100 contract. + * Absent whenever the computed proxy is the source. + */ + device?: { value: number; scaleMax: number } | null; } +/** + * The device-native day-strain scale. WHOOP reports cycle strain on 0–21; + * the ingest validation (`DAY_STRAIN: { min: 0, max: 21 }`) pins the same + * bound, so a stored reading can never exceed it. + */ +export const DEVICE_STRAIN_SCALE_MAX = 21; + /** The three persisted score types this engine serves. */ export const WELLNESS_SCORE_TYPES = { RECOVERY_SCORE: "RECOVERY_SCORE", @@ -176,6 +192,26 @@ export async function computeWellnessScore( ) : rawRows; + // STRAIN has a second, device-native source: the band's own DAY_STRAIN, + // which `/insights/recovery` charts. Without a computed proxy in the window + // the strain page reads that same series instead of reporting "not enough + // data" for an account the recovery page shows weeks of strain for. + if (rows.length === 0 && type === "STRAIN_SCORE") { + const deviceRows = await prisma.measurement.findMany({ + where: { + userId, + type: "DAY_STRAIN", + deletedAt: null, + measuredAt: { gte: cutoff, lte: now }, + }, + select: { value: true, measuredAt: true, source: true }, + orderBy: { measuredAt: "desc" }, + }); + if (deviceRows.length > 0) { + return buildDeviceStrain(deviceRows, windowDays, computedAt); + } + } + if (rows.length === 0) { return buildInsufficient({ coverage: { @@ -270,3 +306,55 @@ export async function computeWellnessScore( }, }); } + +/** A device day-strain reading as a 0–100 share of the device scale. */ +function deviceStrainShare(value: number): number { + const clamped = Math.min(Math.max(value, 0), DEVICE_STRAIN_SCALE_MAX); + return Math.round((clamped / DEVICE_STRAIN_SCALE_MAX) * 100); +} + +function buildDeviceStrain( + rows: { value: number; measuredAt: Date }[], + windowDays: number, + computedAt: string, +): Derived { + const latest = rows[0]; + const score = deviceStrainShare(latest.value); + const prior = rows.slice(1).map((r) => deviceStrainShare(r.value)); + const trendDelta = + prior.length > 0 + ? Math.round(score - prior.reduce((s, v) => s + v, 0) / prior.length) + : null; + return buildOk({ + value: { + score, + band: bandWellnessScore("STRAIN_SCORE", score), + trendDelta, + daysInWindow: rows.length, + asOf: latest.measuredAt.toISOString(), + series: rows + .slice(0, SPARKLINE_MAX_POINTS) + .map((r) => deviceStrainShare(r.value)) + .reverse(), + anchor: null, + components: null, + device: { + value: Math.round(latest.value * 10) / 10, + scaleMax: DEVICE_STRAIN_SCALE_MAX, + }, + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: rows.length, + missing: [], + }, + confidence: { score: 90, band: "high" }, + provenance: { + inputs: ["DAY_STRAIN"], + source: "DAY", + windowDays, + computedAt, + }, + }); +} diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 3ba65922d..b35e7febb 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -254,6 +254,20 @@ export const wellnessScoreValue = z .describe( "RECOVERY only, and only when the canonical value is the server's computed proxy — a device-native recovery percentage is not our blend and carries no decomposition.", ), + device: z + .object({ + value: z + .number() + .describe("The device's latest day strain on its own scale."), + scaleMax: z + .number() + .describe("Top of the device scale (21 for WHOOP day strain)."), + }) + .nullable() + .optional() + .describe( + "STRAIN only — present when no computed strain score exists in the window and the score is served from the device's own DAY_STRAIN instead. `score` is then `value` as a 0–100 share of `scaleMax`. Absent when the computed proxy is the source.", + ), }) .meta({ id: "WellnessScoreValue" }); From f7b6ea5dadc691f0e1e899d26389ec9abe096d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 18:18:46 +0200 Subject: [PATCH 03/31] Read Apple Health liveness off the data, not only the batch stamp The status verdict looked only at the user's last-batch column, which moves on a live batch alone. Rows that reached the account another way, such as the export.zip import, left it null, and the card said 'waiting for first data' beside a four-digit record count. The verdict now takes the newest of that stamp, the per-type arrival ledger and the newest stored sample. --- .../healthkit/__tests__/route.test.ts | 34 +++++++++++++++++++ src/app/api/integrations/healthkit/route.ts | 33 ++++++++++-------- src/lib/integrations/healthkit-type-sync.ts | 26 ++++++++++++++ 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index fd6b34138..f029d6a7f 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -287,6 +287,40 @@ describe("GET /api/integrations/healthkit — sync health", () => { expect((await read()).syncHealth.verdict).toBe("pending_first_sync"); }); + // The column only moves on a live batch. Rows that reached the account any + // other way (the export.zip import, a batch before the column existed) left + // it null, and the card said "waiting for first data" beside a four-digit + // record count. Arrival is a data question: any Apple Health row settles it. + it("is not pending when the per-type ledger records an arrival", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([ + { + type: "PULSE", + lastReceivedAt: ago(2 * DAY), + lastTrigger: null, + lastNewSampleAt: null, + }, + ] as never); + expect((await read()).syncHealth.verdict).toBe("fresh"); + }); + + it("is not pending when Apple Health rows exist without any sync stamp", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "PULSE", last_seen: ago(DAY) }, + ] as never); + const body = await read(); + expect(body.syncHealth.verdict).toBe("fresh"); + expect(body.metricFreshness.map((e) => e.stale)).toEqual([false]); + }); + it("flags one quiet metric inside an otherwise healthy connection", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ healthKitConfigJson: null, diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index e104168ca..886e489ea 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit"; import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress"; import { getHealthKitTypeArrivals, + newestAppleHealthDataAt, withHealthKitArrivals, } from "@/lib/integrations/healthkit-type-sync"; import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; @@ -162,21 +163,9 @@ export const GET = apiHandler(async () => { // three-week-dead pipe rendered the same green as one that delivered an hour // ago. Fail-soft on the per-metric read — an honesty signal is never worth // 500-ing the config the iOS client needs. - // No `connected` hint: Apple Health has nothing to connect or disconnect, so - // "never delivered" resolves to `pending_first_sync`, not `disconnected`. - const syncHealth = resolveSyncVerdict({ - configured: true, - lastDataAt: lastSyncedAt, - legacyLastSyncedAt: lastSyncedAt, - cadence: PUSH_CADENCE, - }); // #1173 — when each type last ARRIVED and under which trigger, beside when // its newest sample was taken. Fail-soft to null, which leaves the entries // without arrival fields rather than reporting every type as never received. - const [samples, arrivals] = await Promise.all([ - getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), - getHealthKitTypeArrivals(user.id).catch(() => null), - ]); // Issue #778 — a first-run backfill used to be invisible from the web: the // card said "last sync 2 minutes ago" while giving no sense of how much had // arrived or how far back it reached. These two figures (row count + oldest @@ -184,9 +173,23 @@ export const GET = apiHandler(async () => { // progress; the phone's queue and throttle state stay on the phone and are // not guessed at. Fail-soft for the same reason the freshness read is — // a progress line is never worth 500-ing the config the iOS client needs. - const syncProgress = await getAppleHealthSyncProgress(user.id).catch( - () => null, - ); + const [samples, arrivals, syncProgress] = await Promise.all([ + getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), + getHealthKitTypeArrivals(user.id).catch(() => null), + getAppleHealthSyncProgress(user.id).catch(() => null), + ]); + // No `connected` hint: Apple Health has nothing to connect or disconnect, so + // "never delivered" resolves to `pending_first_sync`, not `disconnected`. + // "Delivered" is read off the data, not only off the user column: that + // column moves on a live batch alone, so rows from the export.zip import + // left it null and the card said "waiting for first data" beside a + // four-digit record count. + const syncHealth = resolveSyncVerdict({ + configured: true, + lastDataAt: newestAppleHealthDataAt(lastSyncedAt, arrivals, samples), + legacyLastSyncedAt: lastSyncedAt, + cadence: PUSH_CADENCE, + }); return apiSuccess({ entries, diff --git a/src/lib/integrations/healthkit-type-sync.ts b/src/lib/integrations/healthkit-type-sync.ts index 1945b8131..20570d0b0 100644 --- a/src/lib/integrations/healthkit-type-sync.ts +++ b/src/lib/integrations/healthkit-type-sync.ts @@ -131,6 +131,32 @@ export async function getHealthKitTypeArrivals( return out; } +/** + * The newest instant Apple Health data is known to have reached the account: + * the live-batch stamp, the per-type arrival ledger, or — for rows that came + * any other way, such as the export.zip import — the newest stored sample. + * Null only when none of the three holds anything, which is the one case the + * status may honestly call "waiting for first data". + */ +export function newestAppleHealthDataAt( + lastSyncedAt: string | null, + arrivals: Map | null, + samples: readonly { lastSeenAt: string }[], +): string | null { + let newest: number | null = null; + const consider = (value: string | null | undefined) => { + if (!value) return; + const ms = Date.parse(value); + if (Number.isFinite(ms) && (newest === null || ms > newest)) newest = ms; + }; + consider(lastSyncedAt); + for (const arrival of arrivals?.values() ?? []) { + consider(arrival.lastReceivedAt); + } + for (const sample of samples) consider(sample.lastSeenAt); + return newest === null ? null : new Date(newest).toISOString(); +} + /** A freshness entry with the arrival facts beside the newest sample. */ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry { /** When a live sync last carried this type; null when none has. */ From 1a52384a1d5901979bc7405ed0c75cd3771a7834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 18:20:57 +0200 Subject: [PATCH 04/31] Open stat strips on the chart's window instead of all time The strip painted the all-time summary until the chart reported its visible window, then swapped to it, so every number jumped a beat after load. The chart now reports only once its read has settled (a failed read settles too), and until then the strip holds its cells at their final height. --- src/app/insights/blood-pressure/page.tsx | 4 ++- src/app/insights/pulse/page.tsx | 3 ++- src/app/insights/weight/page.tsx | 3 ++- src/components/charts/health-chart.tsx | 9 ++++--- .../integer-metric-precision-guard.test.tsx | 9 +++++++ .../__tests__/metric-stat-strip.test.tsx | 27 +++++++++++++++++++ .../insights/healthkit-metric-page.tsx | 3 ++- src/components/insights/metric-stat-strip.tsx | 22 ++++++++++++--- .../recovery/recovery-metric-block.tsx | 3 ++- src/hooks/use-chart-domain-stats.ts | 9 +++++++ 10 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 1a5782055..58d15478b 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -60,7 +60,7 @@ export default function InsightsBlutdruckPage() { // v1.12.8 — chart-reactive metric statistics. Blood pressure tracks BOTH // series at once: the single chart reports per-type visible-range stats and // each strip column reads its own half. - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); const bpTargets = useBpTargetBand(); if (isEmpty) { @@ -142,6 +142,7 @@ export default function InsightsBlutdruckPage() { seriesLabel: t("charts.systolic"), icon: ArrowUpRight, windowStats: statsByType?.BLOOD_PRESSURE_SYS ?? null, + windowPending: !statsSettled, }, { dataKey: "dia", @@ -151,6 +152,7 @@ export default function InsightsBlutdruckPage() { seriesLabel: t("charts.diastolic"), icon: ArrowDownRight, windowStats: statsByType?.BLOOD_PRESSURE_DIA ?? null, + windowPending: !statsSettled, }, ]} /> diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 081748ee1..864032539 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -88,7 +88,7 @@ export default function InsightsPulsPage() { // v1.12.8 — visible-range stats shared between the pulse chart and the // strip (the VO2 chart-row below keeps its own full-range read). - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); // v1.4.27 F17 — gate the sub-page on at least one pulse observation. // Brand-new accounts (no manual logs, no Apple-Health upload yet) @@ -133,6 +133,7 @@ export default function InsightsPulsPage() { seriesLabel={t("insights.pulseSectionTitle")} icon={Heart} windowStats={statsByType?.PULSE ?? null} + windowPending={!statsSettled} /> } coachReadStrip={ diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 8539072d5..c1a5c25ea 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -109,7 +109,7 @@ export default function InsightsGewichtPage() { : weightSummary; // v1.12.8 — visible-range stats shared between the chart and the strip. - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); if (isEmpty) { return ( @@ -181,6 +181,7 @@ export default function InsightsGewichtPage() { seriesLabel={t("insights.weightSectionTitle")} icon={Scale} windowStats={statsByType?.WEIGHT ?? null} + windowPending={!statsSettled} /> } coachReadStrip={} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index beedff5eb..ab229be3c 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1309,10 +1309,13 @@ export function HealthChart({ // Report the visible-range stats up to the sub-page so the shared // `` can read them. Effect (not render-time call) so the // parent state update never fires during this component's render. + // Only once the read has settled: a report during the initial fetch would + // tell the strip "no window" and it would paint the full-range summary + // first. A failed read settles too, so the strip never waits forever. useEffect(() => { - if (mini) return; - onVisibleStats?.(visibleStatsByType); - }, [mini, visibleStatsByType, onVisibleStats]); + if (mini || isLoading) return; + onVisibleStats?.(isError ? null : visibleStatsByType); + }, [mini, isLoading, isError, visibleStatsByType, onVisibleStats]); // v1.4.16 phase B8 — comparison overlay. // diff --git a/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx b/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx index a6da4062a..357ad8ff8 100644 --- a/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx +++ b/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx @@ -50,6 +50,15 @@ vi.mock("@/hooks/use-insights-layout-prefs", () => ({ // The coach-read strip paints nothing until it is client-mounted (React // #418). Force the mounted branch so the band sentence reaches the markup. vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +// The chart is stubbed, so it never reports its window; settle the strip so +// it paints the summary this guard reads. +vi.mock("@/hooks/use-chart-domain-stats", () => ({ + useChartDomainStats: () => ({ + statsByType: null, + statsSettled: true, + onVisibleStats: () => {}, + }), +})); const analyticsMock = vi.fn(); vi.mock("@/hooks/use-insights-analytics", () => ({ diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index 051a28c65..4268af7e9 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -198,3 +198,30 @@ describe("", () => { }); }); }); + +describe(" waits for the chart's window", () => { + // The chart opens on a 30-day window. Painting the all-time summary until + // the chart reports, then swapping to the window, made every number jump + // a beat after the page loaded. + it("holds the cells while the chart's window is still pending", () => { + const html = render( + , + ); + expect(html).toContain('data-stat="min"'); + expect(html).toContain('data-window-pending="true"'); + expect(html).not.toContain("58.0 bpm"); + expect(html).not.toContain("88.0 bpm"); + }); + + it("paints the window's numbers once the chart reported", () => { + const html = render( + , + ); + expect(html).toContain("60.0 bpm"); + expect(html).not.toContain("58.0 bpm"); + }); +}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 9e023d7eb..d3eb28880 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -266,7 +266,7 @@ export function HealthKitMetricPage({ // v1.12.8 — shared visible-range state. The chart reports the per-type // Min / Max / Median / Mean for the data under its active range tab; the // stat strip reads it back for this page's single series. - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); // v1.17.0 — fallback-type swap. When the primary type has no rows but the // declared fallback does (HRV: SDNN empty, RMSSD present), key the chart, @@ -446,6 +446,7 @@ export function HealthKitMetricPage({ } icon={statIcon} windowStats={statsByType?.[effectiveType] ?? null} + windowPending={!statsSettled} medianLabel={statMedianLabel} /> } diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index d9682e215..63d68803c 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -118,6 +118,13 @@ export interface MetricStatSeries { * `h1 → h2` outline under `SubPageShell` stays nested with no skipped level. */ titleAs?: "h2"; + /** + * The chart this strip reads has not reported its window yet. The cells + * hold their place instead of painting the full-range summary: the chart + * opens on a narrower window, so the all-time numbers would be replaced a + * beat later and every value would jump. + */ + windowPending?: boolean; } type MetricStatStripProps = Partial & { @@ -165,6 +172,7 @@ function SeriesBlock({ medianLabel, description, titleAs, + windowPending = false, }: MetricStatSeries) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -181,6 +189,7 @@ function SeriesBlock({ // chart's range tab is the single, visible selector for both. const windowed = windowStats != null && windowStats.count > 0; const source = windowed ? windowStats : summary; + const holding = windowPending && !windowed; const format = (value: number | null): string => value === null ? "—" : `${fmt.number(value, fractionDigits)} ${unit}`; @@ -205,6 +214,7 @@ function SeriesBlock({ data-slot="metric-stat-series" data-series={dataKey} data-windowed={windowed ? "true" : undefined} + data-window-pending={holding ? "true" : undefined} className="space-y-1.5" > {seriesLabel ? ( @@ -228,9 +238,13 @@ function SeriesBlock({

{cell.label}

-

- {format(cell.value)} -

+ {holding ? ( + + ) : ( +

+ {format(cell.value)} +

+ )} ))} @@ -249,6 +263,7 @@ export function MetricStatStrip({ medianLabel, description, titleAs, + windowPending, series, groupLabel, pending = false, @@ -318,6 +333,7 @@ export function MetricStatStrip({ medianLabel={medianLabel} description={description} titleAs={titleAs} + windowPending={windowPending} /> diff --git a/src/components/insights/recovery/recovery-metric-block.tsx b/src/components/insights/recovery/recovery-metric-block.tsx index 262771972..6e7cc7aab 100644 --- a/src/components/insights/recovery/recovery-metric-block.tsx +++ b/src/components/insights/recovery/recovery-metric-block.tsx @@ -67,7 +67,7 @@ export function RecoveryMetricBlock({ }: RecoveryMetricBlockProps) { const { user, isAuthenticated } = useAuth(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); return (
@@ -86,6 +86,7 @@ export function RecoveryMetricBlock({ titleAs="h2" description={explainer} windowStats={statsByType?.[type] ?? null} + windowPending={!statsSettled} /> | null; + /** + * False until the chart has settled and reported once (even an empty + * window). The strip holds its cells until then rather than painting the + * full-range summary and swapping to the chart's window a beat later. + */ + statsSettled: boolean; /** Stable callback handed to ``. */ onVisibleStats: (stats: Record | null) => void; } @@ -40,10 +46,12 @@ export function useChartDomainStats(): ChartDomainStats { string, MetricWindowStats > | null>(null); + const [statsSettled, setStatsSettled] = useState(false); const lastRef = useRef("null"); const onVisibleStats = useCallback( (stats: Record | null) => { + setStatsSettled(true); // Cheap shallow signature so an identical window (a re-render that // recomputes the same slice) doesn't re-set state and re-render the // strip. @@ -57,6 +65,7 @@ export function useChartDomainStats(): ChartDomainStats { return { statsByType, + statsSettled, onVisibleStats, }; } From a0c5ab6591fa1b08abfdb596c4b6b70bbd431e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 18:34:11 +0200 Subject: [PATCH 05/31] Hold data-shaped page bodies until their reads settle Several pages stacked cards whose final height is the data's: the illness day timeline, the measurement list, the insights arrange list, the system status cards, the score notices that land with the slower report. Each resolving on its own pushed the rest down the page, for a CLS of 0.11 to 0.18 at desktop width. RevealWhenSettled mounts the body at once but keeps it out of layout until no query is in flight (two frames running, two seconds at most), with a placeholder in its place. The chart loading shells were also ~100 px shorter than the card they became: they now reserve the day-dot row, its caption and the collapsed data-table disclosure, and the range-tab row at its 44 px height. --- src/app/admin/[section]/renderer.tsx | 19 +- src/app/measurements/page.tsx | 23 +- src/components/charts/chart-skeleton.tsx | 46 ++- .../charts/health-chart-dynamic.tsx | 42 +- src/components/charts/health-chart.tsx | 11 +- .../illness/illness-episode-detail.tsx | 84 ++-- src/components/settings/insights-section.tsx | 11 +- src/components/settings/score-section.tsx | 362 +++++++++--------- .../ui/__tests__/reveal-when-settled.test.tsx | 27 ++ src/components/ui/reveal-when-settled.tsx | 79 ++++ 10 files changed, 464 insertions(+), 240 deletions(-) create mode 100644 src/components/ui/__tests__/reveal-when-settled.test.tsx create mode 100644 src/components/ui/reveal-when-settled.tsx diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 89cb04f39..d927a353e 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -36,6 +36,8 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit import { RemindersSection } from "@/components/admin/reminders-section"; import { ServicesSection } from "@/components/admin/services-section"; import { SystemStatusSection } from "@/components/admin/system-status-section"; +import { RevealWhenSettled } from "@/components/ui/reveal-when-settled"; +import { Skeleton } from "@/components/ui/skeleton"; import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card"; import { InviteTokensSection } from "@/components/admin/invite-tokens-section"; import { UserManagementSection } from "@/components/admin/user-management-section"; @@ -59,11 +61,18 @@ export function AdminSectionRenderer({ switch (slug) { case "system-status": return ( - - - {/* v1.42 — the table maintenance after the tombstone purge. */} - - + // The status cards grow as their reads land (host metrics, workers, + // queues); revealed together so the maintenance card below is not + // pushed down the page three times while they load. + } + > + + + {/* v1.42 — the table maintenance after the tombstone purge. */} + + + ); case "general": return ( diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 64e0d653a..ac0efe6cc 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -17,6 +17,8 @@ import { PageHeader } from "@/components/ui/page-header"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { RevealWhenSettled } from "@/components/ui/reveal-when-settled"; +import { Skeleton } from "@/components/ui/skeleton"; import { Plus } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "@/lib/i18n/context"; @@ -203,14 +205,21 @@ export default function MeasurementsPage() { /> - { - setReturnTo(null); - setDialogOpen(true); - }} - /> + {/* The list's height is the data's; the custom metrics sit below it. + Revealed together once both reads settle, so neither pushes the + other down the page while it loads. */} + } + > + { + setReturnTo(null); + setDialogOpen(true); + }} + /> - + + ); } diff --git a/src/components/charts/chart-skeleton.tsx b/src/components/charts/chart-skeleton.tsx index 6e1c0e7c3..2d10370cd 100644 --- a/src/components/charts/chart-skeleton.tsx +++ b/src/components/charts/chart-skeleton.tsx @@ -48,11 +48,51 @@ import { useTranslations } from "@/lib/i18n/context"; const SLOW_HINT_DELAY_MS = 3_000; +/** + * The footer a loaded `` paints under its plot: the row of day + * dots and its caption on a day-linked chart, and the collapsed "show as + * table" disclosure. A loading shell without it was ~100 px shorter than the + * card it turned into (CLS on every metric page, the BMI page most visibly), + * so the shells reserve the same boxes: `h-3` rug, `mt-1` caption (two lines + * on a phone, one from `sm`), and the disclosure's `mt-3 border-t pt-3` frame + * around its `min-h-11` button. + */ +export function ChartFooterReserve({ + dayLinks = false, + dataTable = false, +}: { + dayLinks?: boolean; + dataTable?: boolean; +}) { + if (!dayLinks && !dataTable) return null; + return ( +