Merge pull request #264 from MBombeck/release/v1.12.7

release/v1.12.7 — Insights that react, read clean, and stay legible
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-05 14:23:05 +02:00
commit 07356e914f
99 files changed
+2662 -980

No files matched your search

+20
View File
@@ -2,6 +2,26 @@
## [Unreleased]
## [1.12.7] — 2026-06-05 — Insights that react, read clean, and stay legible
### Added
- **The stats above a chart now follow your selection.** Brush a window on a metric chart and the min / max / median / average recompute for just that span; clear the selection to return to the full range. Works the same on every metric page, and blood pressure now shows systolic and diastolic in one card, side by side.
### Changed
- **The mood page reads top to bottom the way you'd walk it.** Summary, then the mood calendar, then the chart, then the target, then the assessment — followed by the breakdowns. The two "what stands out" sections are merged into one card, the statistical footnote is gone (the detail lives in a small info tooltip), and the mood colours are aligned to the rest of the charts.
- **VO₂max opens its full chart.** The compact row on the pulse page now links to the dedicated cardio-fitness page instead of showing a chart-less summary.
- **AI assessments vary more across your metrics.** The anti-repetition context now reaches the main cards (blood pressure, weight, pulse, mood, BMI, medication, overall), a step is only suggested when the finding implies one, and the default models were refreshed so the shared-key fallback isn't a quality drop.
### Fixed
- **Status colours stay legible on the light theme.** Connection states, recommendation badges, score provenance, and the settings success messages now use tokens that meet contrast in both themes.
- **A late device sync no longer shrinks a day's step or energy total.** Trailing samples are folded into the existing daily total instead of replacing it.
- **The medication streak is correct across time zones**, and the dashboard now serves its snapshot instantly after a sync while refreshing in the background.
- **Metric pages and the Insights overview show a retry on error** instead of a blank or a false "no data yet".
- **Account profile:** the timezone selector fills its row, and the "globally excluded injection sites" card matches the other cards.
## [1.12.6] — 2026-06-05 — one consistent Insights surface
### Changed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.12.6
version: 1.12.7
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+16 -15
View File
@@ -11,8 +11,8 @@ Ollama endpoint can fall back from one to the other automatically.
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
| -------- | ---------------- | ------------- | -------- | -------------- |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o-mini` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-3-5-sonnet-latest` | Anthropic SDK default | Measurement context sent to Anthropic |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
@@ -71,16 +71,17 @@ the default rather than 500-ing.
4. In HealthLog, open `/settings/ai`. Set provider to **OpenAI**,
paste the key, optionally override the model.
The default model is `gpt-4o-mini` (`src/lib/ai/provider.ts:80`) —
cheap, fast, and grounded enough for the structured-output Coach
prompts. Override to `gpt-4o` or `gpt-4.1` if you want more reasoning
headroom and accept the per-token cost.
The default model is `gpt-4o` (`src/lib/ai/provider.ts:80`) — a
full-size model that keeps the headline Insights assessments grounded
and well-reasoned out of the box. Override to a lighter model such as
`gpt-4o-mini` if you want to trade some reasoning headroom for a lower
per-token cost.
**Cost expectation.** A daily briefing for an active account runs at
roughly 4–8k input tokens + ~1k output. At gpt-4o-mini's published
rates that is well under a US cent per briefing; a heavy user
generating multiple Coach turns plus a weekly report typically spends
a few dollars per month.
roughly 4–8k input tokens + ~1k output. At gpt-4o's published rates
that is a fraction of a US cent per briefing; a heavy user generating
multiple Coach turns plus a weekly report typically spends a few
dollars per month. A lighter override drops this further.
## Anthropic setup
@@ -91,16 +92,16 @@ a few dollars per month.
4. In HealthLog, open `/settings/ai`. Set provider to **Anthropic**,
paste the key, optionally override the model.
The default model is `claude-3-5-sonnet-latest` (`src/lib/ai/provider.ts:55`).
The default model is `claude-sonnet-4-6` (`src/lib/ai/provider.ts`).
The Anthropic client refuses to forward an Anthropic key to a stale
`aiBaseUrl` even if one is parked in the shared column — Anthropic
has no per-tenant base URL the UI exposes; the SDK default is the
only correct endpoint.
**Cost expectation.** Claude 3.5 Sonnet runs roughly 5x the per-token
cost of `gpt-4o-mini` but produces noticeably stronger reasoning on
the more structured Coach prompts. Most BYOK users on Claude spend
in the low single digits per month.
**Cost expectation.** A current Sonnet runs at a higher per-token
cost than a lightweight OpenAI model but produces noticeably stronger
reasoning on the more structured Coach prompts. Most BYOK users on
Claude spend in the low single digits per month.
## Local endpoints (Ollama, LM Studio, vLLM)
+10 -4
View File
@@ -1494,7 +1494,8 @@
"sat": "Samstag",
"sun": "Sonntag"
},
"rangeBand": "Bereich"
"rangeBand": "Bereich",
"selectRange": "Zeitraum auswählen"
},
"chart": {
"overlay": {
@@ -1679,7 +1680,10 @@
"statLatest": "Aktuell",
"statMin": "Min",
"statMax": "Max",
"empty": "Noch keine VO₂-Max-Daten — synchronisiere Apple Health, um diese Ansicht zu füllen."
"empty": "Noch keine VO₂-Max-Daten — synchronisiere Apple Health, um diese Ansicht zu füllen.",
"cardioLinkTitle": "Cardio-Fitness (VO₂ max)",
"cardioLinkBody": "VO₂ max ist das zentrale Maß für die aerobe Fitness. Das vollständige Diagramm und die Einschätzung findest du auf der eigenen Seite.",
"cardioLinkCta": "Cardio-Fitness öffnen"
},
"trendAnnotation": {
"emptyBp": "Mehr Daten nötig, um diesen Trend zu kommentieren.",
@@ -1983,6 +1987,7 @@
}
},
"subPage": {
"loadError": "Diese Messgröße konnte gerade nicht geladen werden.",
"blutdruckDescription": "Dein systolisch-/diastolisch-Verlauf mit altersangepassten Zielbereichen und einer textbasierten Einschätzung.",
"gewichtDescription": "Gewichtsverlauf gegen das aus deiner Größe abgeleitete gesunde Band, mit Einschätzung.",
"pulsDescription": "Pulsverlauf gegenüber dem persönlichen Karvonen-Zielband, mit Einschätzung.",
@@ -2104,7 +2109,8 @@
"min": "Min",
"max": "Max",
"median": "Median",
"mean": "Mittelwert"
"mean": "Mittelwert",
"selectedRange": "Ausgewählter Bereich"
},
"showAllValues": "Alle Werte anzeigen",
"valuesPageTitle": "{metric} — alle Werte",
@@ -2603,7 +2609,7 @@
},
"discovery": {
"title": "Was auffällt",
"description": "Tag-zu-Tag-Zusammenhänge rund um deine Stimmung, die auch nach Korrektur für viele gleichzeitige Tests bestehen.",
"subheading": "Entdeckte Zusammenhänge",
"footer": "Herausgefiltert aus {tested} Tag-zu-Tag-Paaren, die über all deine Werte getestet wurden, nach der Korrektur für Fehlentdeckungen.",
"empty": "Bisher hat kein Stimmungszusammenhang die statistische Hürde genommen. Logge weiter.",
"disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend – ein Muster, das einen Blick wert ist, keine Ursache.",
+10 -4
View File
@@ -1494,7 +1494,8 @@
"sat": "Saturday",
"sun": "Sunday"
},
"rangeBand": "Range"
"rangeBand": "Range",
"selectRange": "Select a range"
},
"chart": {
"overlay": {
@@ -1679,7 +1680,10 @@
"statLatest": "Latest",
"statMin": "Min",
"statMax": "Max",
"empty": "No VO₂ max data yet — sync Apple Health to populate this view."
"empty": "No VO₂ max data yet — sync Apple Health to populate this view.",
"cardioLinkTitle": "Cardio fitness (VO₂ max)",
"cardioLinkBody": "VO₂ max is the headline measure of aerobic fitness. The full chart and assessment live on its own page.",
"cardioLinkCta": "Open cardio fitness"
},
"trendAnnotation": {
"emptyBp": "Awaiting more data to annotate this trend.",
@@ -1983,6 +1987,7 @@
}
},
"subPage": {
"loadError": "Could not load this metric right now.",
"blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written assessment.",
"gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus a written assessment.",
"pulsDescription": "Pulse trend against the personalised Karvonen target band, plus a written assessment.",
@@ -2104,7 +2109,8 @@
"min": "Min",
"max": "Max",
"median": "Median",
"mean": "Mean"
"mean": "Mean",
"selectedRange": "Selected range"
},
"showAllValues": "Show all readings",
"valuesPageTitle": "{metric} — all readings",
@@ -2603,7 +2609,7 @@
},
"discovery": {
"title": "What stands out",
"description": "Day-to-day relationships involving your mood that hold up after correcting for testing many pairs at once.",
"subheading": "Discovered relationships",
"footer": "Surfaced from {tested} day-to-day pairs tested across all your metrics, after the false-discovery correction.",
"empty": "No mood relationships cleared the statistical bar yet. Keep logging.",
"disclaimer": "Statistically filtered, but still observational — a pattern worth watching, not a cause.",
+10 -4
View File
@@ -1494,7 +1494,8 @@
"sat": "Sábado",
"sun": "Domingo"
},
"rangeBand": "Rango"
"rangeBand": "Rango",
"selectRange": "Seleccionar un intervalo"
},
"chart": {
"overlay": {
@@ -1679,7 +1680,10 @@
"statLatest": "Actual",
"statMin": "Mín",
"statMax": "Máx",
"empty": "Aún no hay datos de VO₂ máx — sincroniza Apple Health para llenar esta vista."
"empty": "Aún no hay datos de VO₂ máx — sincroniza Apple Health para llenar esta vista.",
"cardioLinkTitle": "Forma cardiovascular (VO₂ máx)",
"cardioLinkBody": "El VO₂ máx es la medida principal de la forma aeróbica. El gráfico completo y la evaluación están en su propia página.",
"cardioLinkCta": "Abrir forma cardiovascular"
},
"trendAnnotation": {
"emptyBp": "Necesitamos más datos para comentar esta tendencia.",
@@ -1983,6 +1987,7 @@
}
},
"subPage": {
"loadError": "No se pudo cargar esta métrica ahora mismo.",
"blutdruckDescription": "Tu evolución sistólica/diastólica con rangos objetivo ajustados a la edad y una valoración en texto.",
"gewichtDescription": "Evolución del peso frente a la banda saludable derivada de tu altura, con valoración.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
@@ -2104,7 +2109,8 @@
"min": "Mín",
"max": "Máx",
"median": "Mediana",
"mean": "Media"
"mean": "Media",
"selectedRange": "Intervalo seleccionado"
},
"showAllValues": "Mostrar todas las mediciones",
"valuesPageTitle": "{metric} — todas las mediciones",
@@ -2603,7 +2609,7 @@
},
"discovery": {
"title": "Lo que destaca",
"description": "Relaciones de un día a otro con tu estado de ánimo que se mantienen tras corregir el haber probado muchos pares a la vez.",
"subheading": "Relaciones descubiertas",
"footer": "Extraídas de {tested} pares de un día a otro probados en todas tus métricas, tras la corrección de falsos descubrimientos.",
"empty": "Todavía ninguna relación de ánimo superó el umbral estadístico. Sigue registrando.",
"disclaimer": "Filtrado estadísticamente, pero aún observacional: un patrón que vale la pena observar, no una causa.",
+10 -4
View File
@@ -1494,7 +1494,8 @@
"sat": "Samedi",
"sun": "Dimanche"
},
"rangeBand": "Plage"
"rangeBand": "Plage",
"selectRange": "Sélectionner une plage"
},
"chart": {
"overlay": {
@@ -1679,7 +1680,10 @@
"statLatest": "Actuel",
"statMin": "Min",
"statMax": "Max",
"empty": "Pas encore de données VO₂ max — synchronise Apple Health pour remplir cette vue."
"empty": "Pas encore de données VO₂ max — synchronise Apple Health pour remplir cette vue.",
"cardioLinkTitle": "Condition cardio (VO₂ max)",
"cardioLinkBody": "Le VO₂ max est la mesure de référence de la condition aérobie. Le graphique complet et l’évaluation se trouvent sur leur propre page.",
"cardioLinkCta": "Ouvrir la condition cardio"
},
"trendAnnotation": {
"emptyBp": "Il faut plus de données pour commenter cette tendance.",
@@ -1983,6 +1987,7 @@
}
},
"subPage": {
"loadError": "Impossible de charger cette mesure pour le moment.",
"blutdruckDescription": "Ton évolution systolique/diastolique avec des plages cibles adaptées à l’âge et une évaluation textuelle.",
"gewichtDescription": "Évolution du poids face à la bande saine dérivée de ta taille, avec évaluation.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
@@ -2104,7 +2109,8 @@
"min": "Min",
"max": "Max",
"median": "Médiane",
"mean": "Moyenne"
"mean": "Moyenne",
"selectedRange": "Plage sélectionnée"
},
"showAllValues": "Afficher toutes les mesures",
"valuesPageTitle": "{metric} — toutes les mesures",
@@ -2603,7 +2609,7 @@
},
"discovery": {
"title": "Ce qui ressort",
"description": "Des relations d'un jour à l'autre autour de votre humeur qui tiennent après correction des tests multiples.",
"subheading": "Relations découvertes",
"footer": "Issues de {tested} paires d'un jour à l'autre testées sur l'ensemble de vos mesures, après correction des fausses découvertes.",
"empty": "Aucune relation d'humeur n'a encore franchi le seuil statistique. Continuez à enregistrer.",
"disclaimer": "Filtré statistiquement, mais toujours observationnel : une tendance à surveiller, pas une cause.",
+10 -4
View File
@@ -1494,7 +1494,8 @@
"sat": "Sabato",
"sun": "Domenica"
},
"rangeBand": "Intervallo"
"rangeBand": "Intervallo",
"selectRange": "Seleziona un intervallo"
},
"chart": {
"overlay": {
@@ -1679,7 +1680,10 @@
"statLatest": "Attuale",
"statMin": "Min",
"statMax": "Max",
"empty": "Ancora nessun dato VO₂ max — sincronizza Apple Health per riempire questa vista."
"empty": "Ancora nessun dato VO₂ max — sincronizza Apple Health per riempire questa vista.",
"cardioLinkTitle": "Forma cardio (VO₂ max)",
"cardioLinkBody": "Il VO₂ max è la misura principale della forma aerobica. Il grafico completo e la valutazione sono nella loro pagina dedicata.",
"cardioLinkCta": "Apri forma cardio"
},
"trendAnnotation": {
"emptyBp": "Servono più dati per commentare questo trend.",
@@ -1983,6 +1987,7 @@
}
},
"subPage": {
"loadError": "Impossibile caricare questa metrica al momento.",
"blutdruckDescription": "Il tuo andamento sistolico/diastolico con range obiettivo adattati all’età e una valutazione testuale.",
"gewichtDescription": "Andamento del peso rispetto alla fascia sana derivata dalla tua altezza, con valutazione.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
@@ -2104,7 +2109,8 @@
"min": "Min",
"max": "Max",
"median": "Mediana",
"mean": "Media"
"mean": "Media",
"selectedRange": "Intervallo selezionato"
},
"showAllValues": "Mostra tutte le misurazioni",
"valuesPageTitle": "{metric} — tutte le misurazioni",
@@ -2603,7 +2609,7 @@
},
"discovery": {
"title": "Cosa risalta",
"description": "Relazioni giorno per giorno legate al tuo umore che reggono dopo aver corretto per i test multipli.",
"subheading": "Relazioni scoperte",
"footer": "Estratte da {tested} coppie giorno per giorno testate su tutte le tue misure, dopo la correzione per le false scoperte.",
"empty": "Nessuna relazione sull'umore ha ancora superato la soglia statistica. Continua a registrare.",
"disclaimer": "Filtrato statisticamente, ma sempre osservazionale: uno schema da tenere d'occhio, non una causa.",
+10 -4
View File
@@ -1494,7 +1494,8 @@
"sat": "Sobota",
"sun": "Niedziela"
},
"rangeBand": "Zakres"
"rangeBand": "Zakres",
"selectRange": "Wybierz zakres"
},
"chart": {
"overlay": {
@@ -1679,7 +1680,10 @@
"statLatest": "Aktualne",
"statMin": "Min",
"statMax": "Maks",
"empty": "Brak danych VO₂ max — zsynchronizuj Apple Health, aby uzupełnić ten widok."
"empty": "Brak danych VO₂ max — zsynchronizuj Apple Health, aby uzupełnić ten widok.",
"cardioLinkTitle": "Wydolność krążeniowa (VO₂ max)",
"cardioLinkBody": "VO₂ max to główna miara wydolności tlenowej. Pełny wykres i ocenę znajdziesz na osobnej stronie.",
"cardioLinkCta": "Otwórz wydolność krążeniową"
},
"trendAnnotation": {
"emptyBp": "Potrzeba więcej danych, aby skomentować ten trend.",
@@ -1983,6 +1987,7 @@
}
},
"subPage": {
"loadError": "Nie udało się teraz wczytać tej metryki.",
"blutdruckDescription": "Twój przebieg skurczowego/rozkurczowego z zakresami docelowymi dopasowanymi do wieku i oceną tekstową.",
"gewichtDescription": "Przebieg wagi na tle zdrowego pasma wyliczonego z twojego wzrostu, z oceną.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
@@ -2104,7 +2109,8 @@
"min": "Min",
"max": "Maks",
"median": "Mediana",
"mean": "Średnia"
"mean": "Średnia",
"selectedRange": "Wybrany zakres"
},
"showAllValues": "Pokaż wszystkie pomiary",
"valuesPageTitle": "{metric} — wszystkie pomiary",
@@ -2603,7 +2609,7 @@
},
"discovery": {
"title": "Co się wyróżnia",
"description": "Relacje dzień po dniu dotyczące Twojego nastroju, które utrzymują się po korekcie wielokrotnych testów.",
"subheading": "Wykryte relacje",
"footer": "Wyłonione z {tested} par dzień po dniu testowanych na wszystkich Twoich wskaźnikach, po korekcie fałszywych odkryć.",
"empty": "Żadna relacja nastroju nie przekroczyła jeszcze progu statystycznego. Notuj dalej.",
"disclaimer": "Przefiltrowane statystycznie, ale wciąż obserwacyjne — wzorzec warty uwagi, nie przyczyna.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.12.6",
"version": "1.12.7",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -145,15 +145,45 @@ describe("GET /api/dashboard/snapshot", () => {
vi.useRealTimers();
});
it("evicts the snapshot on a measurement-write invalidation", async () => {
it("rebuilds after a measurement write once the stale entry is gone", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1);
// A measurement write sweeps the `${userId}|` prefix, which covers
// the longer-TTL snapshot key.
// v1.12.7 — a measurement write now marks the `${userId}|` prefix
// stale rather than hard-evicting. A plain `get()` treats a
// marked-stale entry (expiresAt === now) as a miss and removes it.
invalidateUserMeasurements("user-1");
expect(caches.analytics.get("user-1|dashboard-snapshot")).toBeNull();
await callGet(makeReq());
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2);
});
it("serves the prior snapshot stale + warms a background rebuild on a measurement write (A4)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Warm the snapshot.
const first = await callGet(makeReq());
expect((await first.json()).data.briefingState).toBe("preparing");
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1);
// The next rebuild would produce a different body.
const FRESH_BODY = { ...SNAPSHOT_BODY, briefingState: "ready" };
buildDashboardSnapshot.mockResolvedValue(FRESH_BODY);
// A measurement write marks the bucket stale (no hard evict, no
// intervening get()). The entry stays servable inside the SWR window.
invalidateUserMeasurements("user-1");
// The next read serves the PRIOR (stale) body immediately and kicks
// off a single background recompute — the foreground request does not
// pay the cold rebuild.
const stale = await callGet(makeReq());
expect((await stale.json()).data.briefingState).toBe("preparing");
// Let the detached background rebuild settle, then a follow-up read
// sees the freshly-warmed body.
await new Promise((r) => setTimeout(r, 0));
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2);
const fresh = await callGet(makeReq());
expect((await fresh.json()).data.briefingState).toBe("ready");
});
});
+19 -10
View File
@@ -12,14 +12,16 @@
* cookie-session surface but the route does not gate on it. `userId`
* is narrowed from the resolved session — never a body field.
*
* The body is read-through `caches.analytics` keyed
* `${userId}|dashboard-snapshot`, but with a per-key TTL of
* `SNAPSHOT_CACHE_TTL_MS` (>= the client's 120 s refetch interval) so a
* scheduled refetch lands on a warm entry instead of re-running the
* full builder on every tick. It still rides the analytics bucket so
* the `${userId}|` eviction sweep on a measurement / mood / medication
* write covers it (see `src/lib/cache/invalidate.ts`) — correctness on
* writes is unchanged; only the idle-poll TTL is longer. Per-sub-query
* The body is read-through `caches.analytics` (stale-while-revalidate
* via `cachedSwr`) keyed `${userId}|dashboard-snapshot`, with a per-key
* TTL of `SNAPSHOT_CACHE_TTL_MS` (>= the client's 120 s refetch
* interval) so a scheduled refetch lands on a warm entry instead of
* re-running the full builder on every tick. It still rides the
* analytics bucket so a measurement / mood / medication write covers it
* (see `src/lib/cache/invalidate.ts`): a measurement write marks the
* bucket stale (the high-frequency iOS-sync path serves the prior
* snapshot + warms a background recompute), while a widget reorder still
* hard-evicts so the layout change paints synchronously. Per-sub-query
* timings surface under `meta.snapshot.sub_*_ms` on the cache-miss path
* so a regression is attributable without re-instrumenting.
*
@@ -32,7 +34,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import {
buildDashboardSnapshot,
@@ -87,7 +89,14 @@ export const GET = apiHandler(async () => {
dashboardWidgetsJson: user.dashboardWidgetsJson,
};
const body = await cached(
// Stale-while-revalidate: a measurement write marks the analytics
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
// sync serves the prior snapshot immediately (within the bucket's
// stale window) while a single background recompute warms a fresh one
// — the foreground request never pays the cold rebuild. Hard-evicting
// writes (widget reorder) still drop the key outright, forcing a clean
// miss + synchronous rebuild here.
const body = await cachedSwr(
caches.analytics as ServerCache<DashboardSnapshot>,
`${user.id}|dashboard-snapshot`,
() => buildDashboardSnapshot(prisma, snapshotUser, { time }),
+15 -2
View File
@@ -20,7 +20,8 @@ import {
buildUserPrompt,
type ComparisonSnapshot,
} from "@/lib/ai/prompts/insight-system-prompt";
import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator";
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
resolveDashboardLayout,
@@ -517,6 +518,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale);
}
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
// this briefing actually carries, so a normative claim can cite a real
// `referenceId` the schema + UI footnote already support. Returns the plain
// prompt unchanged when no applicable metric section is present.
const referenceMetrics = metricsFromPresentSections({
bloodPressure: features.bloodPressure != null,
weight: features.weight != null,
pulse: features.pulse != null,
mood: features.mood != null,
medication: (features.medications?.length ?? 0) > 0,
});
let result;
let workingProviderType: string;
let fallbackHopCount = 0;
@@ -536,7 +549,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// tolerates the strict shape via `insightResultSchema.safeParse`'s
// soft fallback to `parsed` and `passthrough()` on the strict
// schema, so switching the prompt does not break legacy callers.
systemPrompt: getStrictInsightsSystemPrompt(locale),
systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
userPrompt,
temperature: 0.3,
maxTokens: 1500,
+7 -7
View File
@@ -122,8 +122,8 @@ export default function BugReportPage() {
{t("bugreport.subtitle")}
</p>
</div>
<div className="bg-card border-border border-l-dracula-orange flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<Info className="text-dracula-orange mt-0.5 h-4 w-4 shrink-0" />
<div className="bg-card border-border border-l-warning flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<Info className="text-warning mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="text-foreground font-medium">
{t("bugreport.disabledTitle")}
@@ -149,16 +149,16 @@ export default function BugReportPage() {
</div>
{status?.configured && (
<div className="bg-card border-border border-l-dracula-cyan flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<GitPullRequest className="text-dracula-cyan mt-0.5 h-4 w-4 shrink-0" />
<div className="bg-card border-border border-l-info flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<GitPullRequest className="text-info mt-0.5 h-4 w-4 shrink-0" />
<p className="text-muted-foreground">
{t("bugreport.githubEscalationNote")}
</p>
</div>
)}
{!status?.configured && (
<div className="bg-card border-border border-l-dracula-purple flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<Info className="text-dracula-purple mt-0.5 h-4 w-4 shrink-0" />
<div className="bg-card border-border border-l-dose-accent flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<Info className="text-dose-accent mt-0.5 h-4 w-4 shrink-0" />
<p className="text-muted-foreground">
{t("bugreport.internalOnlyNote")}
</p>
@@ -215,7 +215,7 @@ export default function BugReportPage() {
<div
className={`rounded-lg p-3 text-sm ${
result.type === "success"
? "bg-dracula-green/10 text-dracula-green"
? "bg-success/10 text-success"
: "bg-destructive/10 text-destructive"
}`}
>
+37 -16
View File
@@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
@@ -50,6 +51,11 @@ export default function InsightsBlutdruckPage() {
const sysSummary = analytics?.summaries?.BLOOD_PRESSURE_SYS ?? null;
const diaSummary = analytics?.summaries?.BLOOD_PRESSURE_DIA ?? null;
// v1.12.7 — chart-reactive metric statistics. Blood pressure brushes BOTH
// series at once: the single chart reports per-type windowed stats and each
// strip reads its own half.
const { statsByType, onDomainStats } = useChartDomainStats();
if (isEmpty) {
return (
<SubPageShell
@@ -106,22 +112,35 @@ export default function InsightsBlutdruckPage() {
explainerMetric="bloodPressure"
coachLaunch
statStrip={
<div className="space-y-3" data-slot="bp-stat-strips">
<MetricStatStrip
summary={sysSummary}
unit="mmHg"
fractionDigits={0}
seriesLabel={t("charts.systolic")}
icon={ArrowUpRight}
/>
<MetricStatStrip
summary={diaSummary}
unit="mmHg"
fractionDigits={0}
seriesLabel={t("charts.diastolic")}
icon={ArrowDownRight}
/>
</div>
// v1.12.7 — blood pressure is two series, but they share ONE card
// with the systolic / diastolic columns side by side (stacking only
// on narrow mobile) rather than two stacked cards. Each column keeps
// its header, its four stats, and its own brushed-window behaviour:
// brushing the single chart reports per-type windowed stats and each
// column reads its own half.
<MetricStatStrip
groupLabel={t("insights.bloodPressureSectionTitle")}
series={[
{
dataKey: "sys",
summary: sysSummary,
unit: "mmHg",
fractionDigits: 0,
seriesLabel: t("charts.systolic"),
icon: ArrowUpRight,
windowStats: statsByType?.BLOOD_PRESSURE_SYS ?? null,
},
{
dataKey: "dia",
summary: diaSummary,
unit: "mmHg",
fractionDigits: 0,
seriesLabel: t("charts.diastolic"),
icon: ArrowDownRight,
windowStats: statsByType?.BLOOD_PRESSURE_DIA ?? null,
},
]}
/>
}
diversityNudge={
<MeasurementDiversityNudge
@@ -142,6 +161,8 @@ export default function InsightsBlutdruckPage() {
targetZones={bpTargetZones}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
selectableDomain
onDomainStats={onDomainStats}
/>
{/* v1.12.4 — target card sits between the chart and the assessment on
+1
View File
@@ -24,6 +24,7 @@ export default function InsightsCardioFitnessPage() {
color="#bd93f9"
unit="mL/(kg·min)"
yAxisUnit="mL/(kg·min)"
statIcon={Gauge}
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet — what does this metric tell me about my health, and how do I improve it?"
+13 -1
View File
@@ -97,6 +97,14 @@ export default function InsightsStimmungPage() {
explainerMetric="mood"
coachLaunch
>
{/* v1.12.7 — operator spine: heading + summary (the shell above), then
the Stimmungskalender, then the line chart, then the Ziel card, then
the better-days Einschätzung, then the classification + breakdowns.
The heatmap and the assessment are lifted out of `MoodInsightsSections`
into their own regions so they land in this exact order; all three
regions share one `moodInsights` query (TanStack dedups the fetch). */}
<MoodInsightsSections region="heatmap" />
{/* No `<MetricRangeControls>` here: mood is event-driven, not a
MeasurementType series, so the period-over-period range read
(`/api/analytics/range`, keyed on a MeasurementType enum) has
@@ -109,7 +117,11 @@ export default function InsightsStimmungPage() {
<MetricTargetSummary slug="mood" />
<MoodInsightsSections />
{/* The better-days Einschätzung sits directly under the Ziel card,
ahead of the classification tiles and breakdowns. */}
<MoodInsightsSections region="assessment" />
<MoodInsightsSections region="rest" />
{/* v1.12.2 — the assessment is the LAST block on every bespoke
metric-detail page, matching the canonical spine the generic
+37 -3
View File
@@ -3,7 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { TrendingUp } from "lucide-react";
import { RefreshCw, TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -214,7 +214,7 @@ export default function InsightsPage() {
// empty-state branch only fires once the query has resolved AND
// reported zero measurements; while it's in-flight the page paints
// the regular shell and the tiles fill in as their data lands.
const { data, isLoading, isFetched } = useQuery({
const { data, isLoading, isFetched, isError, refetch } = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
@@ -244,6 +244,35 @@ export default function InsightsPage() {
// single query instance, so the wellness lift adds no second request.
const dashboardDerived = useDashboardDerived(isAuthenticated);
// Error branch — a transient 500 / network drop (after the query's
// retries are exhausted) settles the comprehensive query with no data.
// Without this gate the page would fall through to the "no data yet —
// add a measurement" empty-state, which reads false for a user with
// history. Surface an error + a Retry that refetches the one query,
// mirroring the <VitalsDashboard> error pattern.
if (isError) {
return (
<div
data-slot="insights-overview-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
data-slot="insights-overview-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
);
}
// Empty-state shortcut — only paint once the comprehensive query has
// resolved AND reported zero measurements. While it's in-flight we
// fall through to the streamed shell so the user gets the hero +
@@ -305,7 +334,12 @@ export default function InsightsPage() {
// removed in v1.12.4 — the tab-strip regenerate button is the single
// affordance. The generic disclaimer lives once in the layout-shell footer.
return (
<div className="space-y-8">
// v1.12.7 (L3) — one consistent vertical rhythm down the overview. The
// page used `space-y-8` (32 px) between top-level blocks while the vitals
// wrap used `space-y-6` (24 px), so the overview read as two tiers. Unify
// to `space-y-6` — it matches the vitals wrap and tightens the overview in
// line with the "Insights gives away too much space" direction.
<div className="space-y-6">
<HeroStrip
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
+48 -22
View File
@@ -1,12 +1,13 @@
"use client";
import Link from "next/link";
import { Heart } from "lucide-react";
import { ArrowRight, Gauge, Heart } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
@@ -16,7 +17,6 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation-
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
@@ -32,24 +32,27 @@ import {
* MeasurementType filter is `PULSE` (the same field used elsewhere
* in the codebase).
*
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state
* branch consume `useInsightsAnalytics()` + `<MetricEmptyState>`. The
* VO2 max chart-row still reads `data.summaries.VO2_MAX` directly so
* the hook exposes the unwrapped payload alongside `isEmpty`.
* Analytics fetch + empty-state branch consume `useInsightsAnalytics()`
* + `<MetricEmptyState>`. VO₂ max is a cardio-fitness metric, so the page
* links across to the dedicated `/insights/cardio-fitness` surface (which
* owns the full chart + assessment) rather than rendering a second view.
*/
export default function InsightsPulsPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
// v1.4.25 W16a — VO2 max chart-row consumes the same `/api/analytics`
// bundle the mother page reads. Sharing the cache key keeps the
// payload single-fetch on tab navigation (React-Query unwraps from
// the same key).
const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE");
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
// VO₂ max rides on the same `/api/analytics` bundle; we only need the
// sample count to decide whether to surface the cross-link to the
// dedicated cardio-fitness page.
const hasVo2 = (analytics?.summaries?.VO2_MAX?.count ?? 0) > 0;
const pulseSummary = analytics?.summaries?.PULSE ?? null;
// v1.12.7 — brushed-window stats shared between the pulse chart and the
// strip (the VO2 chart-row below keeps its own full-range read).
const { statsByType, onDomainStats } = 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)
// see a one-line empty-state with a CTA into
@@ -119,6 +122,7 @@ export default function InsightsPulsPage() {
unit="bpm"
seriesLabel={t("insights.pulseSectionTitle")}
icon={Heart}
windowStats={statsByType?.PULSE ?? null}
/>
}
diversityNudge={
@@ -140,21 +144,43 @@ export default function InsightsPulsPage() {
valueBands={pulseBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
selectableDomain
onDomainStats={onDomainStats}
/>
<MetricTargetSummary slug="pulse" />
{/* v1.4.25 W16a — VO2 max sits on the cardio sub-page because it
is a cardio-fitness metric (Apple's Health app surfaces it
under "Heart"). The chart-row stays mounted even at zero
samples so a brand-new account sees the "no data yet" hint
rather than a missing surface — same pattern the dashboard
tile uses (opt-in via Settings → Dashboard). */}
<Vo2MaxChartRow
summary={vo2Summary}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it
under "Heart"), so the pulse page links across to its dedicated
`/insights/cardio-fitness` page — the single surface that carries
the full chart plus the plain-language assessment — rather than
duplicating a second, divergent VO₂ max view here. */}
{hasVo2 ? (
<Link
href="/insights/cardio-fitness"
data-slot="vo2-cardio-link"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 flex items-center justify-between gap-3 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span className="flex items-start gap-3">
<Gauge
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
<span className="space-y-1">
<span className="block text-sm font-semibold">
{t("insights.vo2Max.cardioLinkTitle")}
</span>
<span className="text-muted-foreground block text-xs leading-snug">
{t("insights.vo2Max.cardioLinkBody")}
</span>
</span>
</span>
<span className="text-primary inline-flex shrink-0 items-center gap-1 text-xs font-medium">
{t("insights.vo2Max.cardioLinkCta")}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</Link>
) : null}
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
the overview onto its metric page). */}
+7
View File
@@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
@@ -39,6 +40,9 @@ export default function InsightsGewichtPage() {
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
// v1.12.7 — brushed-window stats shared between the chart and the strip.
const { statsByType, onDomainStats } = useChartDomainStats();
if (isEmpty) {
return (
<SubPageShell
@@ -81,6 +85,7 @@ export default function InsightsGewichtPage() {
unit="kg"
seriesLabel={t("insights.weightSectionTitle")}
icon={Scale}
windowStats={statsByType?.WEIGHT ?? null}
/>
}
diversityNudge={
@@ -102,6 +107,8 @@ export default function InsightsGewichtPage() {
valueBands={weightBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
selectableDomain
onDomainStats={onDomainStats}
/>
<MetricTargetSummary slug="weight" />
+153 -3
View File
@@ -16,9 +16,10 @@ import {
ReferenceLine,
ReferenceArea,
ReferenceDot,
Brush,
} from "recharts";
import { Loader2 } from "lucide-react";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -46,6 +47,10 @@ import { ChartOverlayControls } from "./chart-overlay-controls";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { computeTickPositions } from "@/lib/charts/x-axis-density";
import {
computeWindowStats,
type MetricWindowStats,
} from "@/lib/charts/window-stats";
const TIME_RANGES_KEYS = [
{
@@ -188,6 +193,27 @@ interface HealthChartProps {
* uniformly.
*/
valueScale?: number;
/**
* v1.12.7 — chart-reactive metric statistics (opt-in).
*
* When `true`, the chart mounts a Recharts `<Brush>` beneath the plot so
* the user can drag a window over the visible range. The dashboard and the
* mini / rationale mounts do NOT pass it, so they render byte-identical to
* the pre-v1.12.7 chart — the feature is fully isolated behind this flag.
*
* Mini mode ignores the flag (the rationale card pins its own window and
* has no room for a brush).
*/
selectableDomain?: boolean;
/**
* v1.12.7 — companion to `selectableDomain`. Receives the per-type
* Min / Max / Median / Mean computed over the brushed window, keyed by
* `MeasurementType`, or `null` when the selection spans the whole range
* (or collapses to under one point). A metric sub-page lifts this into a
* shared state the `<MetricStatStrip>` reads, so the strip recomputes for
* the visible domain while the default stays the cheap full-range summary.
*/
onDomainStats?: (stats: Record<string, MetricWindowStats> | null) => void;
}
interface ChartDataPoint {
@@ -489,6 +515,8 @@ export function HealthChart({
verticalMarkers,
userTimezone = "Europe/Berlin",
valueScale = 1,
selectableDomain = false,
onDomainStats,
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
@@ -516,6 +544,18 @@ export function HealthChart({
: 30;
const [rangePoints, setRangePoints] = useState(initialRangePoints);
// v1.12.7 — chart-reactive metric statistics. The brush reports a
// `[startIndex, endIndex]` window into the rendered `chartData`; `null`
// means the full range is selected. Gated behind `selectableDomain` and
// suppressed in mini mode, so the dashboard + rationale charts never mount
// the brush and stay byte-identical. The range-tab change below clears the
// selection so the brush window can't outlive the data it indexed into.
const brushEnabled = selectableDomain && !mini;
const [brushWindow, setBrushWindow] = useState<{
startIndex: number;
endIndex: number;
} | null>(null);
// v1.4.18 — three overlay toggles (showTrendIndicator / showTrendArrow
// / showTargetRange) are persisted per chart via the new
// `useChartOverlayPrefs` hook. When the chart isn't bound to a
@@ -900,6 +940,60 @@ export function HealthChart({
return enriched;
}, [data, rangePoints, showMA, showTrend, types, tzFmt]);
// v1.12.7 — chart-reactive metric statistics.
//
// When a brush window is active, compute the per-type Min / Max / Median /
// Mean over just the selected slice of `chartData` (the array the chart
// already holds — no re-fetch). A selection that spans the whole range, or
// collapses to under one point, reports `null` so the strip falls back to
// the cheap full-range summary. Only runs when `brushEnabled`; the
// dashboard / mini mounts compute nothing.
const windowStatsByType = useMemo<Record<string, MetricWindowStats> | null>(
() => {
if (!brushEnabled || !brushWindow || !chartData?.length) return null;
const last = chartData.length - 1;
const start = Math.max(0, Math.min(brushWindow.startIndex, last));
const end = Math.max(0, Math.min(brushWindow.endIndex, last));
const lo = Math.min(start, end);
const hi = Math.max(start, end);
// A full-range selection is the default; reporting `null` keeps the
// strip on its precomputed summary rather than a redundant recompute.
if (lo <= 0 && hi >= last) return null;
const slice = chartData.slice(lo, hi + 1);
// Fewer than one data point in the window is not a meaningful summary;
// fall back to the full range.
if (slice.length < 1) return null;
const out: Record<string, MetricWindowStats> = {};
for (const type of types) {
const stats = computeWindowStats(
slice.map((point) => point[type] as number | undefined),
);
if (stats.count > 0) out[type] = stats;
}
return Object.keys(out).length > 0 ? out : null;
},
[brushEnabled, brushWindow, chartData, types],
);
// Report the windowed stats up to the sub-page so the shared
// `<MetricStatStrip>` can read them. Effect (not render-time call) so the
// parent state update never fires during this component's render.
useEffect(() => {
if (!brushEnabled) return;
onDomainStats?.(windowStatsByType);
}, [brushEnabled, windowStatsByType, onDomainStats]);
// Clamp a stale brush window in render (never via setState-in-effect) so the
// controlled `<Brush>` never points past the end of `chartData` after a
// refetch shrinks the series. A window whose end falls past the new last
// index is treated as "no selection" — the stats memo above already ignores
// it, and the brush re-seeds to the full range.
const chartLength = chartData?.length ?? 0;
const effectiveBrushWindow =
brushWindow && brushWindow.endIndex <= Math.max(0, chartLength - 1)
? brushWindow
: null;
// v1.4.16 phase B8 — comparison overlay.
//
// When the toggle is active, derive a prior-period series from the
@@ -1313,7 +1407,7 @@ export function HealthChart({
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<span
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
className="text-dose-accent bg-dose-accent/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
@@ -1346,7 +1440,14 @@ export function HealthChart({
variant={rangePoints === r.points ? "default" : "ghost"}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setRangePoints(r.points)}
onClick={() => {
// v1.12.7 — a range-tab change re-slices `chartData`, so
// any active brush window would now index into a different
// array. Clear it so the stat strip falls back to the new
// full range rather than a stale selection.
setBrushWindow(null);
setRangePoints(r.points);
}}
title={t(r.titleKey)}
data-slot="chart-range-tab"
>
@@ -1888,6 +1989,55 @@ export function HealthChart({
data-slot={`chart-compare-line-${type}`}
/>
))}
{/* v1.12.7 — chart-reactive metric statistics. The brush
lets the user drag a window over the visible range; the
`onChange` handler stores the `[startIndex, endIndex]`
pair, and the windowed-stats memo above recomputes the
stat strip's Min / Max / Median / Mean from the brushed
slice. Only mounts when `brushEnabled` (sub-pages opt in
via `selectableDomain`); the dashboard / mini mounts skip
it entirely so their render stays unchanged. `gap={1}`
reports every index so a one-day drag still resolves; the
travellers carry an `ariaLabel` so the brush announces
itself to assistive tech. */}
{brushEnabled ? (
<Brush
dataKey="pointIndex"
height={24}
travellerWidth={8}
gap={1}
stroke="var(--dracula-purple)"
fill="var(--muted)"
fillOpacity={0.3}
ariaLabel={t("charts.selectRange")}
startIndex={effectiveBrushWindow?.startIndex}
endIndex={effectiveBrushWindow?.endIndex}
onChange={(next) => {
const range = next as {
startIndex?: number;
endIndex?: number;
};
if (
typeof range.startIndex !== "number" ||
typeof range.endIndex !== "number"
) {
return;
}
setBrushWindow({
startIndex: range.startIndex,
endIndex: range.endIndex,
});
}}
tickFormatter={(value) =>
tzFmt.date(
new Date(
chartData?.[Math.round(Number(value))]?.timestamp ??
Date.now(),
),
)
}
/>
) : null}
</ComposedChart>
</ResponsiveContainer>
</div>
+8 -2
View File
@@ -632,7 +632,7 @@ export function MoodChart({
effectiveCompareBaseline !== "none" &&
hasComparisonData && (
<span
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
className="text-dose-accent bg-dose-accent/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
@@ -726,10 +726,16 @@ export function MoodChart({
)
) : (
<div
// v1.12.7 — the mood line plots a 1–5 score band; the former
// 240/280 px full-mode height left a tall dead strip below the
// line on the mood subpage. Tighten the default to 200/220 px so
// the chart fits its content without the empty gap. A per-mount
// `--chart-height` override (trends-row mini, etc.) still wins, so
// only the unoverridden full-mode mount shrinks.
className={`${
mini
? "h-[var(--chart-height,140px)]"
: "h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]"
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
} touch-pan-y`}
>
<ResponsiveContainer width="100%" height="100%">
+1 -1
View File
@@ -96,7 +96,7 @@ export function ErrorDetails({
)}
<Button onClick={handleCopy} variant="outline">
{copied ? (
<CheckCircle2 className="text-dracula-green mr-2 h-4 w-4" />
<CheckCircle2 className="text-success mr-2 h-4 w-4" />
) : (
<Copy className="mr-2 h-4 w-4" />
)}
@@ -112,9 +112,11 @@ describe("<DailyBriefing>", () => {
it("uses tone-specific colour classes per finding", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).toContain("bg-dracula-green"); // tone=good bar
expect(html).toContain("bg-dracula-orange"); // tone=watch bar
expect(html).toContain("bg-dracula-cyan"); // tone=info bar
// Routed through the semantic feedback tokens (AA-safe in light mode);
// raw `--dracula-*` text primitives have no light-mode override.
expect(html).toContain("bg-success"); // tone=good bar
expect(html).toContain("bg-warning"); // tone=watch bar
expect(html).toContain("bg-info"); // tone=info bar
});
it("renders the 'Generated <time>' caption when updatedAt is supplied", () => {
@@ -142,6 +142,48 @@ describe("<HealthKitMetricPage> status mount", () => {
expect(metricStatusMock).not.toHaveBeenCalled();
});
it("renders the loading skeleton while the analytics read is in flight", () => {
analyticsMock.mockReturnValue({
data: undefined,
isEmpty: false,
isLoading: true,
});
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
<HealthKitMetricPage {...baseProps} statusMetric="HEART_RATE_VARIABILITY" />,
);
// Stat-strip skeleton + chart skeleton paint; the assessment card never
// mounts in flight.
expect(html).toContain('data-slot="metric-stat-strip-skeleton"');
expect(html).toContain('data-slot="chart-skeleton"');
expect(metricStatusMock).not.toHaveBeenCalled();
});
it("renders an error message + retry control when the analytics read fails", () => {
const refetch = vi.fn();
analyticsMock.mockReturnValue({
data: undefined,
isEmpty: false,
isLoading: false,
error: new Error("boom"),
refetch,
});
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
const html = render(
<HealthKitMetricPage {...baseProps} statusMetric="HEART_RATE_VARIABILITY" />,
);
expect(html).toContain('data-slot="healthkit-metric-error"');
expect(html).toContain('data-slot="healthkit-metric-retry"');
expect(html).toContain('role="alert"');
// The error branch wins over the data / empty branches, so the
// assessment fetch never fires.
expect(metricStatusMock).not.toHaveBeenCalled();
});
it("wires the active-energy page to an accepted registry id (not the MeasurementType remap)", () => {
// v1.8.7.1 (Design H1) — the active-energy page formerly passed
// `ACTIVE_ENERGY_BURNED` (the DB MeasurementType), which the route's
@@ -78,4 +78,91 @@ describe("<MetricStatStrip>", () => {
expect(html).toContain('data-stat="median"');
expect(html).toContain('data-stat="mean"');
});
describe("multi-series mode (blood pressure — one card, two columns)", () => {
const sys: DataSummary = { ...populated, min: 118, max: 142, median: 128 };
const dia: DataSummary = { ...populated, min: 72, max: 91, median: 80 };
it("renders ONE card with both series as side-by-side columns", () => {
const html = render(
<MetricStatStrip
groupLabel="Blood pressure"
series={[
{ dataKey: "sys", summary: sys, unit: "mmHg", seriesLabel: "Sys" },
{ dataKey: "dia", summary: dia, unit: "mmHg", seriesLabel: "Dia" },
]}
/>,
);
// Exactly one strip card, flagged multi-series.
expect(html.match(/data-slot="metric-stat-strip"/g)?.length).toBe(1);
expect(html).toContain('data-multi-series="true"');
// Both series blocks present with their own data-series tokens.
expect(html).toContain('data-series="sys"');
expect(html).toContain('data-series="dia"');
// Both headers + both values render in the single card.
expect(html).toContain("Sys");
expect(html).toContain("Dia");
expect(html).toContain("118");
expect(html).toContain("72");
});
it("still paints the populated half when one series has no data", () => {
const html = render(
<MetricStatStrip
groupLabel="Blood pressure"
series={[
{ dataKey: "sys", summary: sys, unit: "mmHg", seriesLabel: "Sys" },
{ dataKey: "dia", summary: null, unit: "mmHg", seriesLabel: "Dia" },
]}
/>,
);
// The populated half paints; the empty half self-gates to nothing
// (no wrapper, no cells) so the card shows just the one column.
expect(html).toContain('data-series="sys"');
expect(html).not.toContain('data-series="dia"');
expect(html).not.toContain("72");
});
it("renders nothing when every series is empty", () => {
const html = render(
<MetricStatStrip
groupLabel="Blood pressure"
series={[
{ dataKey: "sys", summary: null, unit: "mmHg" },
{ dataKey: "dia", summary: null, unit: "mmHg" },
]}
/>,
);
expect(html).toBe("");
});
it("pins the 'selected range' pill on a brushed series", () => {
const html = render(
<MetricStatStrip
groupLabel="Blood pressure"
series={[
{
dataKey: "sys",
summary: sys,
unit: "mmHg",
seriesLabel: "Sys",
windowStats: {
count: 5,
min: 120,
max: 130,
median: 125,
mean: 124,
},
},
{ dataKey: "dia", summary: dia, unit: "mmHg", seriesLabel: "Dia" },
]}
/>,
);
// The brushed sys block flags windowed + carries the pill; dia does not.
expect(html).toContain('data-series="sys" data-windowed="true"');
expect(html).toContain('data-slot="metric-stat-window-badge"');
// Windowed numbers win over the full-range summary for sys.
expect(html).toContain("120");
});
});
});
@@ -1,129 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.4.25 W16a — `<Vo2MaxChartRow>` SSR coverage.
*
* Acceptance:
* 1. Stat strip renders four data-slot cells: latest, min, max, avg30.
* 2. When `summary.count === 0`, the chart is replaced by the empty-state
* hint; the stat strip remains so a brand-new account still gets a
* consistent shell.
* 3. With a populated summary, the dynamic chart stub mounts (we mock
* `next/dynamic` so the Recharts dependency doesn't pull in DOM
* APIs unavailable in jsdom-less SSR).
* 4. Compare-delta caption only renders when `compareBaseline` is set
* AND the summary carries `avg30LastMonth` / `avg30LastYear` data.
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="health-chart-stub">chart</div>;
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
import { Vo2MaxChartRow } from "../vo2-max-chart-row";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const populatedSummary: DataSummary = {
count: 12,
latest: 42.3,
min: 38.1,
max: 45.7,
mean: 41.5,
median: 41.4,
avg7: 42.0,
avg30: 41.8,
slope7: null,
slope30: { direction: "up", slope: 0.05, confidence: 0.42 },
slope90: null,
anomalyCount: 0,
avg30LastMonth: 40.4,
avg30LastYear: null,
};
const emptySummary: DataSummary = {
count: 0,
latest: null,
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
};
describe("<Vo2MaxChartRow>", () => {
it("renders the four stat cells regardless of data state", () => {
const html = render(<Vo2MaxChartRow summary={populatedSummary} />);
expect(html).toContain('data-stat="latest"');
expect(html).toContain('data-stat="min"');
expect(html).toContain('data-stat="max"');
expect(html).toContain('data-stat="avg30"');
});
it("mounts the chart stub when the summary carries data", () => {
const html = render(<Vo2MaxChartRow summary={populatedSummary} />);
expect(html).toContain('data-slot="health-chart-stub"');
// The empty-state hint must NOT appear when the chart is mounted.
expect(html).not.toContain('data-slot="vo2-chart-row-empty"');
});
it("falls back to the empty-state hint when count is zero", () => {
const html = render(<Vo2MaxChartRow summary={emptySummary} />);
expect(html).toContain('data-slot="vo2-chart-row-empty"');
expect(html).not.toContain('data-slot="health-chart-stub"');
// Stat strip stays mounted with em-dashes for the empty values.
expect(html).toContain('data-stat="latest"');
});
it("renders no compare-delta caption when comparison is off", () => {
const html = render(<Vo2MaxChartRow summary={populatedSummary} />);
expect(html).not.toContain('data-slot="vo2-compare-delta"');
});
it("renders a compare-delta caption when comparison is on and data exists", () => {
const html = render(
<Vo2MaxChartRow
summary={populatedSummary}
compareBaseline="lastMonth"
/>,
);
expect(html).toContain('data-slot="vo2-compare-delta"');
expect(html).toContain('data-compare-baseline="lastMonth"');
});
it("suppresses the compare-delta caption when the prior-period bucket is empty", () => {
const html = render(
<Vo2MaxChartRow
summary={populatedSummary}
compareBaseline="lastYear"
/>,
);
// avg30LastYear is null in the fixture, so the delta caption stays
// suppressed even though comparison is enabled.
expect(html).not.toContain('data-slot="vo2-compare-delta"');
});
it("renders the German labels under the de locale", () => {
const html = render(<Vo2MaxChartRow summary={emptySummary} />, "de");
expect(html).toContain("Aktuell");
expect(html).toMatch(/Apple Health/i);
});
});
@@ -362,7 +362,7 @@ function ChatBubble({
>
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/12 text-foreground",
"border-dose-accent/30 bg-dose-accent/12 text-foreground",
// Budget the avatar column (size-8 + gap-2.5 ≈ 2.625rem) out
// of the 80% cap so the bubble + avatar together never
// overflow a comfortable width on a narrow phone.
@@ -460,7 +460,7 @@ function ChatBubble({
: ""}
</div>
{safeError && (
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
<p className="text-warning/90 text-xs">{safeError}</p>
)}
{/* v1.12.0 — collapse the whole provenance block ("what was
included") behind one disclosure, collapsed by default. The
@@ -620,7 +620,7 @@ function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
data-slot="coach-message-feedback-helpful"
onClick={() => submit.mutate("helpful")}
disabled={submit.isPending}
className="text-muted-foreground hover:text-dracula-green focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
className="text-muted-foreground hover:text-success focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
<ThumbsUp className="size-3" aria-hidden="true" />
{t("insights.coach.feedbackHelpful")}
@@ -630,7 +630,7 @@ function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
data-slot="coach-message-feedback-unhelpful"
onClick={() => submit.mutate("unhelpful")}
disabled={submit.isPending}
className="text-muted-foreground hover:text-dracula-orange focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
className="text-muted-foreground hover:text-warning focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
<ThumbsDown className="size-3" aria-hidden="true" />
{t("insights.coach.feedbackUnhelpful")}
+3 -3
View File
@@ -2,7 +2,7 @@
* v1.4.20 phase D reconcile — shared confidence-band classnames.
*
* Both `<CorrelationCard>` and `<TrendAnnotation>` paint a discrete
* "low / moderate / high" badge with byte-identical Dracula utility
* "low / moderate / high" badge with byte-identical semantic utility
* classes. Per-component i18n key maps stay local because the copy
* differs (correlationRow.* vs trendAnnotation.*); only the classname
* table is the load-bearing duplication.
@@ -10,7 +10,7 @@
export type ConfidenceBand = "low" | "moderate" | "high";
export const CONFIDENCE_BADGE_CLASS: Record<ConfidenceBand, string> = {
high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green",
moderate: "border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange",
high: "border-success/40 bg-success/10 text-success",
moderate: "border-warning/40 bg-warning/10 text-warning",
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground",
};
+7 -7
View File
@@ -135,15 +135,15 @@ const METRIC_ICON: Record<
};
const TONE_BAR_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
good: "bg-dracula-green",
watch: "bg-dracula-orange",
info: "bg-dracula-cyan",
good: "bg-success",
watch: "bg-warning",
info: "bg-info",
};
const TONE_TEXT_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
good: "text-dracula-green",
watch: "text-dracula-orange",
info: "text-dracula-cyan",
good: "text-success",
watch: "text-warning",
info: "text-info",
};
function DeltaBadge({
@@ -275,7 +275,7 @@ export function DailyBriefing({
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-4 w-4 shrink-0"
className="text-dose-accent h-4 w-4 shrink-0"
aria-hidden="true"
/>
<CardTitle className="text-base font-semibold">
+24 -22
View File
@@ -92,17 +92,19 @@ const SOURCE_LABEL_KEY: Record<HealthScoreComponentSource, string> = {
/**
* Per-source pill colour vocabulary — mirrors the Coach provenance
* `<SourceChips>` accent for `withings` (dracula-cyan) so the user
* reads "same provenance grammar across surfaces". Manual rides on
* dracula-purple, Apple Health on dracula-pink, mixed on dracula-yellow.
* `none` is a muted slate so the empty-state row reads as dimmed
* without losing the pill affordance entirely.
* `<SourceChips>` accent for `withings` (info/cyan) so the user reads
* "same provenance grammar across surfaces". Routed through the
* theme-aware semantic tokens (dose-accent / info / warning) which carry
* an AA-safe override in light mode; the raw `--dracula-*` text primitives
* read illegibly there. The pill always renders its source label, so the
* label — not the hue — is the disambiguator. `none` is a muted slate so
* the empty-state row reads as dimmed without losing the pill affordance.
*/
const SOURCE_PILL_CLASS: Record<HealthScoreComponentSource, string> = {
manual: "border-dracula-purple/30 text-dracula-purple/90",
withings: "border-dracula-cyan/30 text-dracula-cyan/90",
appleHealth: "border-dracula-pink/30 text-dracula-pink/90",
mixed: "border-dracula-yellow/30 text-dracula-yellow/90",
manual: "border-dose-accent/30 text-dose-accent/90",
withings: "border-info/30 text-info/90",
appleHealth: "border-dose-accent/30 text-dose-accent/90",
mixed: "border-warning/30 text-warning/90",
none: "border-muted-foreground/30 text-muted-foreground",
};
@@ -119,15 +121,15 @@ const BAND_NUMBER_CLASS: Record<HealthScoreBand, string> = {
};
const BAND_BORDER_CLASS: Record<HealthScoreBand, string> = {
green: "border-dracula-green/40",
yellow: "border-dracula-orange/40",
red: "border-dracula-red/40",
green: "border-success/40",
yellow: "border-warning/40",
red: "border-destructive/40",
};
const BAND_PROGRESS_CLASS: Record<HealthScoreBand, string> = {
green: "bg-dracula-green",
yellow: "bg-dracula-orange",
red: "bg-dracula-red",
green: "bg-success",
yellow: "bg-warning",
red: "bg-destructive",
};
const COMPONENT_LABEL_KEY: Record<
@@ -296,7 +298,7 @@ export function HealthScoreCard({
{delta !== null && delta > 0 && (
<span
data-slot="health-score-card-delta-chip"
className="bg-dracula-green/15 text-dracula-green rounded-full px-2 py-0.5 text-[10px] font-semibold"
className="bg-success/15 text-success rounded-full px-2 py-0.5 text-[10px] font-semibold"
>
+{delta}
</span>
@@ -357,13 +359,13 @@ export function HealthScoreCard({
<>
{delta > 0 && (
<ArrowUp
className="text-dracula-green h-3 w-3"
className="text-success h-3 w-3"
aria-hidden="true"
/>
)}
{delta < 0 && (
<ArrowDown
className="text-dracula-red h-3 w-3"
className="text-destructive h-3 w-3"
aria-hidden="true"
/>
)}
@@ -478,7 +480,7 @@ export function HealthScoreCard({
<p
data-slot="health-score-card-provenance-mixed-banner"
role="status"
className="border-dracula-yellow/30 text-dracula-yellow/90 bg-dracula-yellow/5 rounded border px-2 py-1 text-[10px] leading-snug"
className="border-warning/30 text-warning/90 bg-warning/5 rounded border px-2 py-1 text-[10px] leading-snug"
>
{t("insights.healthScore.provenance.mixedBanner")}
</p>
@@ -534,8 +536,8 @@ export function HealthScoreCard({
>
{row.value === null ? "—" : Math.round(row.value)}
</span>
{/* weight share — second, narrower bar tinted
dracula-cyan to read "provenance grammar"
{/* weight share — second, narrower bar tinted with
the `info` token to read "provenance grammar"
alongside the Coach <SourceChips> accent */}
<div
className="bg-muted/40 h-1 w-10 shrink-0 overflow-hidden rounded-full"
@@ -546,7 +548,7 @@ export function HealthScoreCard({
"h-full",
isEmpty
? "bg-muted"
: "bg-dracula-cyan/60",
: "bg-info/60",
)}
style={{
width: isEmpty
@@ -3,16 +3,20 @@
import Link from "next/link";
import type { ComponentProps, ComponentType, ReactNode } from "react";
import { Sparkles } from "lucide-react";
import { RefreshCw, Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
import { useTranslations } from "@/lib/i18n/context";
import type { InsightMetric } from "@/lib/insights/metric-availability";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card";
@@ -163,7 +167,19 @@ export function HealthKitMetricPage({
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric);
const {
data: analytics,
isEmpty,
isLoading,
error,
refetch,
} = useInsightsAnalytics(insightMetric);
// v1.12.7 — shared brushed-window state. The chart reports the per-type
// Min / Max / Median / Mean for the selected domain; the stat strip reads
// it back for this page's single series. No selection → null → the strip
// keeps the full-range summary.
const { statsByType, onDomainStats } = useChartDomainStats();
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
// The stat strip renders display-unit values. The summary holds stored
@@ -188,6 +204,57 @@ export function HealthKitMetricPage({
const title = t(`${i18nPrefix}.title`);
const description = t(`${i18nPrefix}.description`);
// v1.12.7 — in-flight skeleton. The page consumed only `{data, isEmpty}`
// before, so the ~30 HealthKit sub-pages painted nothing until the
// analytics read landed, then popped the content in. Branch on the hook's
// `isLoading` to reserve the stat-strip + chart height with the same
// skeletons the rest of the surface uses, so the layout holds.
if (isLoading) {
return (
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
statStrip={<StatStripSkeleton />}
>
<ChartSkeleton />
</SubPageShell>
);
}
// v1.12.7 — error + retry. A failed analytics read used to fall through to
// the empty-state (or a blank surface); surface a compact message + a
// Retry that re-issues the query, mirroring the `<VitalsDashboard>`
// pattern.
if (error) {
return (
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
>
<div
data-slot="healthkit-metric-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.subPage.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
data-slot="healthkit-metric-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
</SubPageShell>
);
}
if (isEmpty) {
const ctaNode =
emptyStateCtaType != null ? (
@@ -225,6 +292,7 @@ export function HealthKitMetricPage({
unit={yAxisUnit ?? unit}
seriesLabel={title}
icon={statIcon}
windowStats={statsByType?.[measurementType] ?? null}
/>
}
diversityNudge={
@@ -248,6 +316,8 @@ export function HealthKitMetricPage({
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
valueScale={valueScale}
selectableDomain
onDomainStats={onDomainStats}
/>
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
@@ -274,3 +344,32 @@ export function HealthKitMetricPage({
</SubPageShell>
);
}
/**
* v1.12.7 — layout-stable loading shell for the stat strip slot. Mirrors
* the loaded `<MetricStatStrip>` card chrome (denser `py-3` rhythm, one
* header row + a four-up grid) so the page does not jump when the analytics
* read lands. Decorative — hidden from assistive tech; the chart skeleton
* below it carries the `aria-busy` announcement.
*/
function StatStripSkeleton() {
return (
<Card
data-slot="metric-stat-strip-skeleton"
aria-hidden="true"
className="gap-2 py-3 md:py-4"
>
<CardContent className="space-y-3">
<Skeleton className="h-5 w-32" />
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="min-h-[44px] space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -17,14 +17,26 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
* `<TileHeader>`. Callers still pass a pre-sized icon NODE (e.g.
* `<HeartPulse className="h-5 w-5" />`) because the bespoke metric pages
* own that prop; `<TileHeader>` drives its icon off a component, so wrap
* the node in a thin component that renders it verbatim. The node already
* carries the `h-5 w-5` foreground sizing `<TileHeader>` would apply, so
* the rendered row is byte-identical to the previous hand-rolled `flex
* items-center gap-2` header.
* the node in a thin component that renders it.
*
* v1.12.7 (M2) — `<TileHeader>` owns the canonical `h-5 w-5 text-foreground`
* sizing and passes it through as `className` to its icon component. The
* previous wrapper rendered the node verbatim and dropped that class, so a
* caller passing an unsized (or mis-sized) node could drift off the contract.
* The wrapper now forwards `<TileHeader>`'s className onto a sizing span that
* normalizes the glyph — the `[&>svg]:size-5` rule pins any child SVG to the
* canonical box regardless of what the caller passed. Callers that already
* pass `<Icon className="h-5 w-5" />` stay visually identical.
*/
function nodeIcon(icon: React.ReactNode): React.ComponentType {
return function StatusIcon() {
return <>{icon}</>;
function nodeIcon(icon: React.ReactNode): React.ComponentType<{
className?: string;
}> {
return function StatusIcon({ className }: { className?: string }) {
return (
<span className={cn("inline-flex [&>svg]:size-5", className)}>
{icon}
</span>
);
};
}
+194 -57
View File
@@ -4,9 +4,10 @@ import type { ComponentType } from "react";
import { Sigma } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import type { DataSummary } from "@/lib/analytics/trends";
import type { MetricWindowStats } from "@/lib/charts/window-stats";
/**
* v1.8.5 — at-a-glance stat strip for the insights category pages.
@@ -26,18 +27,36 @@ import type { DataSummary } from "@/lib/analytics/trends";
* The thin HealthKit pages (`HealthkitMetricPage`) mount this too so they
* reach parity with the bespoke pages — a rich numeric header even where
* there is no target band or AI assessment.
*
* v1.12.7 follow-up — two render modes:
* - single-series (default): one Card, one `<TileHeader>` + 4-stat grid.
* - multi-series (`series=[…]`): ONE Card with the series side by side
* (two columns on desktop, stacking on narrow mobile). Blood pressure
* uses this so systolic + diastolic share a single card rather than
* stacking two. Each column keeps its own header, its own four stats,
* and its own brushed-window behaviour.
*
* The card chrome is tightened from the earlier `py-5 / gap-3 / min-h-56px`
* to a denser rhythm so the numbers-first header gives away less vertical
* space on every subpage without dropping below a readable value size or
* the AA-contrast micro-labels.
*/
interface MetricStatStripProps {
/**
* One series rendered inside the strip — its header (label + icon), the
* full-range `summary`, and the optional brushed-window stats. Single-series
* callers pass these props at the top level; multi-series callers pass an
* array of these via `series`.
*/
export interface MetricStatSeries {
/**
* Analytics summary for the page's `MeasurementType`. Null is allowed
* so the parent doesn't have to gate on the network read finishing —
* the strip simply doesn't paint until the data lands. A zero-count
* summary (brand-new metric) also renders nothing; the page's
* empty-state owns that surface.
* Analytics summary for the series' `MeasurementType`. Null is allowed
* so the parent doesn't have to gate on the network read finishing — the
* series simply doesn't paint until the data lands. A zero-count summary
* (brand-new metric) also renders nothing.
*/
summary: DataSummary | null;
/** Unit suffix rendered next to each value (e.g. `bpm`, `kg`). */
/** Unit suffix rendered next to each value (e.g. `bpm`, `kg`, `mmHg`). */
unit: string;
/**
* Decimal precision for the formatted values. Defaults to 1 — enough
@@ -46,98 +65,216 @@ interface MetricStatStripProps {
*/
fractionDigits?: number;
/**
* v1.12.4 — series caption rendered above the grid. Single-series metrics
* pass the metric name (e.g. "Gewicht"); blood pressure stacks two strips
* (systolic / diastolic) and labels each so the unified strip covers both
* halves rather than silently dropping one. Also feeds the `aria-label`
* for the section.
*
* v1.12.6 — the caption is now rendered through the canonical
* `<TileHeader>` (white heading + white icon, matching the Einschätzung
* card) rather than a small muted uppercase line, so every series block
* leads with the same header language as the rest of the surface.
* Series caption rendered through the canonical `<TileHeader>` (white
* heading + white icon). Single-series metrics pass the metric name;
* blood pressure labels each half (systolic / diastolic).
*/
seriesLabel?: string;
/**
* v1.12.6 — leading glyph for the series `<TileHeader>`. Pass the metric's
* icon component (e.g. `Scale`, `Heart`); blood pressure passes a
* directional glyph per series. Defaults to a generic stats glyph so a
* caller that supplies a `seriesLabel` without an icon still renders a
* complete header.
* Leading glyph for the series `<TileHeader>`. Defaults to a generic
* stats glyph so a caller that supplies a `seriesLabel` without an icon
* still renders a complete header.
*/
icon?: ComponentType<{ className?: string }>;
/**
* v1.12.7 — chart-reactive metric statistics. When the user brushes a
* window in the metric's chart, the sub-page lifts the chart's per-series
* Min / Max / Median / Mean for that window and threads it here. When
* present (non-null) it renders in place of the full-range `summary` and
* the header pins a "selected range" pill. Null falls back to the
* precomputed `summary`.
*/
windowStats?: MetricWindowStats | null;
/**
* Stable suffix for the per-cell `data-slot`s. Multi-series mode passes a
* distinct token per column (e.g. the unit or label slug) so the two
* columns' cells don't collide on `[data-stat="min"]` in e2e. Optional;
* single-series mode omits it.
*/
dataKey?: string;
}
export function MetricStatStrip({
type MetricStatStripProps = Partial<MetricStatSeries> & {
/**
* v1.12.7 — multi-series mode. When supplied, the strip renders ONE Card
* with each entry as a side-by-side column (two columns on desktop,
* stacking on narrow mobile). The top-level `summary` / `unit` /
* `seriesLabel` / … props are ignored. `aria-label` then describes the
* group; pass `groupLabel` for the accessible name.
*
* Single-series callers MUST still pass `summary` + `unit` at the top
* level (the contract the ~30 HealthKit pages + the weight/pulse/… pages
* rely on); they are optional in the type only so multi-series callers can
* omit them, and the single-series branch self-gates on a missing
* `summary` by rendering nothing.
*/
series?: MetricStatSeries[];
/**
* Accessible name for the multi-series group (e.g. "Blood pressure").
* Single-series mode derives the label from `seriesLabel` instead.
*/
groupLabel?: string;
};
/**
* Render one series' four stats. Self-gates: returns null while the
* summary is in flight or for a zero-count metric. The brushed-window
* source wins over the full-range summary when a selection is active.
*/
function SeriesBlock({
summary,
unit,
fractionDigits = 1,
seriesLabel,
icon,
}: MetricStatStripProps) {
windowStats,
dataKey,
}: MetricStatSeries) {
const { t } = useTranslations();
const fmt = useFormatters();
// Nothing to show until the summary lands or for a metric with no
// readings — the page's empty-state covers the zero-data case.
// readings. The gate rides the full-range summary even when a window is
// active, so a brush never resurrects a series that has no data at all.
if (!summary || summary.count <= 0) return null;
// A window selection with at least one reading wins over the full-range
// summary; otherwise the block reads the precomputed numbers.
const windowed = windowStats != null && windowStats.count > 0;
const source = windowed ? windowStats : summary;
const format = (value: number | null): string =>
value === null ? "—" : `${fmt.number(value, fractionDigits)} ${unit}`;
const cells: Array<{ key: string; label: string; value: number | null }> = [
{ key: "min", label: t("insights.subPage.stats.min"), value: summary.min },
{ key: "max", label: t("insights.subPage.stats.max"), value: summary.max },
{ key: "min", label: t("insights.subPage.stats.min"), value: source.min },
{ key: "max", label: t("insights.subPage.stats.max"), value: source.max },
{
key: "median",
label: t("insights.subPage.stats.median"),
value: summary.median,
value: source.median,
},
{
key: "mean",
label: t("insights.subPage.stats.mean"),
value: summary.mean,
value: source.mean,
},
];
return (
// Card-wrapped so the header-to-body offset matches the `<CardHeader
// pb-2>` tiles on the same subpage spine (assessment, mood, medication)
// rather than the old bare-div `space-y-3`. The grid keeps its own
// data-slots; the section semantics ride on `role` + `aria-label`.
<div
data-slot="metric-stat-series"
data-series={dataKey}
data-windowed={windowed ? "true" : undefined}
className="space-y-2"
>
{seriesLabel ? (
<TileHeader
icon={icon ?? Sigma}
title={seriesLabel}
right={
windowed ? (
<span
data-slot="metric-stat-window-badge"
className="bg-dracula-purple/10 text-dracula-purple rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
>
{t("insights.subPage.stats.selectedRange")}
</span>
) : undefined
}
/>
) : null}
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4 [&>div]:min-h-[44px]">
{cells.map((cell) => (
<div
key={cell.key}
data-slot="metric-stat"
data-stat={cell.key}
className="space-y-0.5"
>
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
{cell.label}
</p>
<p className="text-base font-semibold tabular-nums">
{format(cell.value)}
</p>
</div>
))}
</div>
</div>
);
}
export function MetricStatStrip({
summary,
unit,
fractionDigits,
seriesLabel,
icon,
windowStats,
dataKey,
series,
groupLabel,
}: MetricStatStripProps) {
const { t } = useTranslations();
// v1.12.7 — multi-series mode: ONE Card, columns side by side. The card
// self-gates to nothing only when EVERY series is empty so a one-sided
// metric (e.g. only systolic logged) still paints its half.
if (series && series.length > 0) {
const anyData = series.some((s) => s.summary && s.summary.count > 0);
if (!anyData) return null;
return (
<Card
data-slot="metric-stat-strip"
data-multi-series="true"
role="group"
aria-label={groupLabel ?? t("insights.subPage.stats.label")}
className="gap-2 py-3 md:py-4"
>
<CardContent>
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
{series.map((s, i) => (
<SeriesBlock key={s.dataKey ?? s.seriesLabel ?? i} {...s} />
))}
</div>
</CardContent>
</Card>
);
}
// Single-series mode (default, unchanged contract). Gate on the one
// series' summary so a null / zero-count read paints nothing.
if (!summary || summary.count <= 0) return null;
return (
// Card-wrapped so the header-to-body offset matches the sibling tiles
// on the same subpage spine (assessment, mood, medication). The series
// header now lives inside `SeriesBlock`, so the Card carries body chrome
// only.
<Card
data-slot="metric-stat-strip"
data-windowed={
windowStats != null && windowStats.count > 0 ? "true" : undefined
}
role="group"
aria-label={
seriesLabel
? `${t("insights.subPage.stats.label")} — ${seriesLabel}`
: t("insights.subPage.stats.label")
}
className="gap-2 py-4 md:gap-3 md:py-5"
className="gap-2 py-3 md:py-4"
>
{seriesLabel ? (
<CardHeader className="pb-2">
<TileHeader icon={icon ?? Sigma} title={seriesLabel} />
</CardHeader>
) : null}
<CardContent>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 [&>div]:min-h-[56px]">
{cells.map((cell) => (
<div
key={cell.key}
data-slot="metric-stat"
data-stat={cell.key}
className="space-y-1"
>
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
{cell.label}
</p>
<p className="text-lg font-semibold tabular-nums">
{format(cell.value)}
</p>
</div>
))}
</div>
<SeriesBlock
summary={summary}
unit={unit ?? ""}
fractionDigits={fractionDigits}
seriesLabel={seriesLabel}
icon={icon}
windowStats={windowStats}
dataKey={dataKey}
/>
</CardContent>
</Card>
);
@@ -1,53 +1,30 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
MoodDiscoveredRelations,
moodPairsOf,
type DiscoveredCorrelation,
} from "../mood-discovered-relations";
// Signed-in stub so the `enabled: isAuthenticated` gate opens without a fetch.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1" },
isLoading: false,
isAuthenticated: true,
error: null,
refetch: () => {},
}),
}));
import { MoodDiscoveredRelations } from "../mood-discovered-relations";
interface DiscoveredCorrelation {
behaviour: string;
outcome: string;
n: number;
r: number;
pValue: number;
qValue: number;
interpretation: string;
lagDays: number;
}
/**
* v1.12.7 — the discovered-relations surface is now a header-less pure
* renderer embedded inside the merged "What stands out" card. The card owns
* the correlation-discovery fetch and passes in the mood pairs; this test
* exercises the renderer directly with `moodPairsOf` doing the mood filtering
* the card performs.
*/
function renderWith(
discovered: DiscoveredCorrelation[],
pairsTested = discovered.length,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
queryClient.setQueryData(queryKeys.insightsCorrelations(), {
discovered,
pairsTested,
fdrQ: 0.1,
minPairs: 20,
});
const pairs = moodPairsOf(discovered);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<MoodDiscoveredRelations />
</I18nProvider>
</QueryClientProvider>,
<I18nProvider initialLocale="en">
<MoodDiscoveredRelations pairs={pairs} pairsTested={pairsTested} />
</I18nProvider>,
);
}
@@ -66,16 +43,11 @@ function pair(over: Partial<DiscoveredCorrelation>): DiscoveredCorrelation {
}
describe("<MoodDiscoveredRelations>", () => {
it("renders nothing without discovered data (loading / disabled surface)", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
it("renders nothing with no pairs", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<MoodDiscoveredRelations />
</I18nProvider>
</QueryClientProvider>,
<I18nProvider initialLocale="en">
<MoodDiscoveredRelations pairs={[]} pairsTested={0} />
</I18nProvider>,
);
expect(html).toBe("");
});
@@ -91,15 +63,15 @@ describe("<MoodDiscoveredRelations>", () => {
const html = renderWith([
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD", r: 0.5 }),
]);
expect(html).toContain('data-slot="mood-discovered-relations"');
expect(html).toContain('data-mood-role="outcome"');
expect(html).toContain('data-direction="up"');
// factor label resolves to the localized measurement name
expect(html).toContain("Time in Daylight");
expect(html).toContain("higher next-day mood");
// v1.12.4 (C4) — the n/r/q detail moved off the visible sub-row into a
// focusable explainer icon (its tooltip content is portalled on open, so
// it is absent from the static markup). Assert the icon is wired per pair
// instead of the inline stat string.
// The n/r/q detail rides a focusable explainer icon (its tooltip content
// is portalled on open, so it is absent from the static markup). Assert
// the icon is wired per pair instead of the inline stat string.
expect(html).toContain('aria-label="How this was computed"');
});
@@ -113,7 +85,7 @@ describe("<MoodDiscoveredRelations>", () => {
expect(html).toContain("lower next-day");
});
it("filters to mood pairs and folds the full-family footer into the header explainer", () => {
it("filters to mood pairs and folds the full-family caveat into the subsection explainer", () => {
const html = renderWith(
[
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }),
@@ -124,16 +96,14 @@ describe("<MoodDiscoveredRelations>", () => {
expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe(
1,
);
// v1.12.4 (C3) — the false-discovery footer (denominator = the full
// behaviour×outcome family the engine tested, i.e. 12, not the mood-only
// subset) + the observational disclaimer were two full-width footnote
// rows; both fold into a single header explainer icon now. Its tooltip
// content is portalled on open, so assert the trigger exists rather than
// the (now hidden) footer string.
// The false-discovery footer + the observational disclaimer fold into the
// subsection explainer icon; its tooltip content is portalled on open, so
// assert the trigger exists rather than the (now hidden) footer string.
expect(
(html.match(/aria-label="How this was computed"/g) ?? []).length,
).toBeGreaterThanOrEqual(1);
// The two old inline footnote rows are gone from the static markup.
// The former muted explainer paragraph + inline footnote rows are gone.
expect(html).not.toContain("day-to-day pairs tested");
expect(html).not.toContain("hold up after correcting");
});
});
@@ -36,9 +36,21 @@ describe("mood notes timeline removal", () => {
join(COMPONENT_DIR, "mood-insights-sections.tsx"),
"utf8",
);
expect(src).toContain("MoodNarrativeFeed");
// v1.12.7 — the narrative one-liners now ride the merged "What stands out"
// card (`MoodWhatStandsOut`), which renders `MoodNarrativeFeed` internally.
// The structural guard tracks the mount point on the sections surface.
expect(src).toContain("MoodWhatStandsOut");
expect(src).toContain("MoodInTargetTile");
});
it("renders the narrative feed inside the merged what-stands-out card", () => {
const src = readFileSync(
join(COMPONENT_DIR, "mood-what-stands-out.tsx"),
"utf8",
);
expect(src).toContain("MoodNarrativeFeed");
expect(src).toContain("MoodDiscoveredRelations");
});
});
describe("mood entries table — long-note truncation", () => {
@@ -47,10 +47,14 @@ const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
high: "insights.mood.influence.confidenceHigh",
};
// Confidence chips carry COLORED TEXT, so they ride the semantic feedback
// tokens (`--info` / `--success`) — these carry the Alucard light-mode
// overrides that clear AA on the white card. Raw `--dracula-*` stays bright
// green/cyan in light mode and fails AA for text.
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]",
high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]",
medium: "bg-info/15 text-info",
high: "bg-success/15 text-success",
};
export function MoodBetterDays({
@@ -82,9 +86,13 @@ export function MoodBetterDays({
}
const DirectionIcon = up ? ArrowUpRight : ArrowDownRight;
// Semantic feedback tokens (not raw `--dracula-*`): the effect
// read-out is COLORED TEXT, so it needs the Alucard light-mode
// override to clear AA. The arrow icon shares the same token so the
// good→down ramp stays coherent across both themes.
const directionColor = up
? "var(--dracula-green)"
: "var(--dracula-red)";
? "var(--success)"
: "var(--destructive)";
// The effect read-out: tag factors show the mood-point delta,
// metric factors show the correlation coefficient.
@@ -128,7 +128,7 @@ function MoodCorrelationCard({
ticks: [1, 2, 3, 4, 5],
}}
yAxis={{ dataKey: "y", name: t(Y_LABEL_KEY[kind]) }}
height={180}
height={160}
/>
</>
) : (
@@ -1,12 +1,5 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Compass } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { MoodExplainerIcon } from "./mood-explainer-icon";
@@ -21,13 +14,17 @@ import { MoodExplainerIcon } from "./mood-explainer-icon";
* applies are returned, so this is the statistically-defensible complement
* to the descriptive influence / better-days surfaces.
*
* Renders nothing when the operator has disabled the correlations surface
* (the route 403s — we degrade silently), while loading, or when no mood
* pair cleared the bar. Observational only; the standing disclaimer rides
* the card.
* v1.12.7 — the surface is now a header-less subsection rendered INSIDE the
* merged "What stands out" card (see `mood-insights-sections.tsx`). The owning
* card runs the correlation-discovery fetch once and passes the mood pairs in;
* this component is a pure renderer that paints the list (or returns nothing
* when no pair cleared the bar). The former muted explainer paragraph is gone:
* the statistical caveat rides a single info-icon tooltip beside the
* subsection heading, and every finding renders in the standard foreground
* text colour. Observational only.
*/
interface DiscoveredCorrelation {
export interface DiscoveredCorrelation {
behaviour: string;
outcome: string;
n: number;
@@ -38,7 +35,7 @@ interface DiscoveredCorrelation {
lagDays: number;
}
interface CorrelationDiscoveryResponse {
export interface CorrelationDiscoveryResponse {
discovered: DiscoveredCorrelation[];
pairsTested: number;
fdrQ: number;
@@ -57,60 +54,44 @@ const CHANNEL_LABEL_KEY: Record<string, string> = {
WEIGHT: "measurements.typeWeight",
};
export function MoodDiscoveredRelations() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.insightsCorrelations(),
queryFn: async () => {
const res = await fetch("/api/insights/correlations");
// 403 = operator disabled the surface; any non-OK degrades to nothing.
if (!res.ok) throw new Error("unavailable");
const json = await res.json();
return json.data as CorrelationDiscoveryResponse;
},
enabled: isAuthenticated,
staleTime: 60_000,
// The surface is optional — don't retry a deliberate 403 into noise.
retry: false,
});
if (isLoading || isError || !data) return null;
const moodPairs = data.discovered.filter(
/** Keep only the discovered pairs that involve the mood channel. */
export function moodPairsOf(
discovered: DiscoveredCorrelation[],
): DiscoveredCorrelation[] {
return discovered.filter(
(pair) => pair.behaviour === "MOOD" || pair.outcome === "MOOD",
);
if (moodPairs.length === 0) return null;
}
export function MoodDiscoveredRelations({
pairs,
pairsTested,
}: {
pairs: DiscoveredCorrelation[];
pairsTested: number;
}) {
const { t } = useTranslations();
if (pairs.length === 0) return null;
return (
<Card data-slot="mood-discovered-relations">
<CardHeader className="pb-2">
{/* v1.12.4 (C3) — the false-discovery footer + observational
disclaimer were two full-width footnote rows. Fold both into a
single explainer icon so the explanation is one hover/focus away
instead of a low-density block under the list. v1.12.6 — the
heading now rides the canonical `TileHeader`, with the explainer
icon pinned to its trailing slot. */}
<TileHeader
icon={Compass}
title={t("insights.mood.discovery.title")}
right={
<MoodExplainerIcon
label={t("insights.mood.discovery.explainerLabel")}
detail={`${t("insights.mood.discovery.footer", {
tested: data.pairsTested,
})} ${t("insights.mood.discovery.disclaimer")}`}
/>
}
// v1.12.7 — header-less subsection. The merged "What stands out" card owns
// the single TileHeader; this block leads with a compact subsection label
// plus the statistical-caveat explainer icon (the false-discovery footer +
// observational disclaimer fold into the one tooltip) so the surface stops
// spending a muted paragraph on a footnote.
<div data-slot="mood-discovered-relations" className="space-y-1">
<div className="text-foreground flex items-center gap-1.5 text-sm font-medium">
<span>{t("insights.mood.discovery.subheading")}</span>
<MoodExplainerIcon
label={t("insights.mood.discovery.explainerLabel")}
detail={`${t("insights.mood.discovery.footer", {
tested: pairsTested,
})} ${t("insights.mood.discovery.disclaimer")}`}
/>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.discovery.description")}
</p>
<ul className="divide-border divide-y">
{moodPairs.map((pair) => {
</div>
<ul className="divide-border divide-y">
{pairs.map((pair) => {
const moodIsOutcome = pair.outcome === "MOOD";
const channelKey = moodIsOutcome ? pair.behaviour : pair.outcome;
const factorLabel = t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey);
@@ -130,10 +111,10 @@ export function MoodDiscoveredRelations() {
data-mood-role={moodIsOutcome ? "outcome" : "behaviour"}
data-direction={up ? "up" : "down"}
>
{/* v1.12.4 (C4) — the {n} paired days · r · q line was a
low-density sub-row under each sentence. Move the numeric
detail behind an explainer icon on the same line as the
finding so the list stays one row per pair. */}
{/* The {n} paired days · r · q numeric detail rides an explainer
icon on the same line as the finding so the list stays one
row per pair. The finding itself reads in the standard
foreground colour. */}
<span className="text-foreground flex items-center gap-1.5">
<span>{t(sentenceKey, { factor: factorLabel })}</span>
<MoodExplainerIcon
@@ -148,8 +129,7 @@ export function MoodDiscoveredRelations() {
</li>
);
})}
</ul>
</CardContent>
</Card>
</ul>
</div>
);
}
@@ -81,10 +81,8 @@ import {
MoodStructuredTagBreakdown,
type MoodStructuredTagRow,
} from "./mood-structured-tag-breakdown";
import {
MoodNarrativeFeed,
type MoodNarrativeItem,
} from "./mood-narrative-feed";
import { type MoodNarrativeItem } from "./mood-narrative-feed";
import { MoodWhatStandsOut } from "./mood-what-stands-out";
import { MoodInTargetTile } from "./mood-in-target-tile";
import {
MoodStabilityTile,
@@ -102,7 +100,6 @@ import {
MoodTagMetricCrosstab,
type MoodTagMetricCrosstabRow,
} from "./mood-tag-metric-crosstab";
import { MoodDiscoveredRelations } from "./mood-discovered-relations";
/**
* v1.8.5 — additional Mood Insights sections.
@@ -170,7 +167,28 @@ function SectionCard({
);
}
export function MoodInsightsSections() {
/**
* v1.12.7 — the mood spine is split into three render regions so the page can
* thread the operator's exact top-to-bottom order without prop-drilling the
* whole aggregate payload:
*
* - "heatmap" — the Stimmungskalender, lifted above the line chart.
* - "assessment" — the better-days Einschätzung, placed right after the
* Ziel card.
* - "rest" — the Einordnung classification tiles, the "what stands
* out" card, and every deep-dive breakdown.
*
* All three regions read the SAME `queryKeys.moodInsights()` query, so
* TanStack Query dedups the fetch (single network read, 60 s staleTime); the
* regions are pure JSX slices of one resolved payload.
*/
export type MoodInsightsRegion = "heatmap" | "assessment" | "rest";
export function MoodInsightsSections({
region = "rest",
}: {
region?: MoodInsightsRegion;
} = {}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -187,7 +205,12 @@ export function MoodInsightsSections() {
});
if (isLoading) {
return <Skeleton className="h-48 w-full rounded-lg" />;
// Only the "rest" region carries the page-level loading skeleton; the
// heatmap / assessment regions stay invisible while loading so they don't
// stack three skeletons down the page.
return region === "rest" ? (
<Skeleton className="h-48 w-full rounded-lg" />
) : null;
}
if (!data || data.summary.totalEntries === 0) {
@@ -228,13 +251,50 @@ export function MoodInsightsSections() {
const crosstabRows = data.tagMetricCrosstab ?? [];
const hasCrosstab = crosstabRows.length > 0;
// v1.12.7 — the Stimmungskalender is lifted above the line chart on the page,
// so it renders as its own region here.
if (region === "heatmap") {
return (
<SectionCard title={t("insights.mood.heatmapTitle")} icon={CalendarDays}>
<MoodHeatmap cells={heatmapCells} days={data.heatmap.windowDays} stretch />
</SectionCard>
);
}
// v1.12.7 — the better-days Einschätzung is placed right after the Ziel card
// on the page, ahead of the classification tiles, so it renders as its own
// region. It carries the same assessment-card weight the per-metric
// `<InsightStatusCard>` uses on the other subpages (tighter `gap`/`py`, the
// `insight-in` entry, and the `insight-assessment` hook) so it reads as THE
// mood assessment, consistent across the app.
if (region === "assessment") {
if (!hasBetterDays) return null;
return (
<Card
aria-live="polite"
data-slot="insight-assessment"
className="animate-insight-in gap-2 py-4 md:gap-3 md:py-5"
>
<CardHeader className="pb-2">
<TileHeader
icon={Sparkles}
title={t("insights.mood.betterDays.title")}
/>
</CardHeader>
<CardContent>
<MoodBetterDays factors={betterDays} />
</CardContent>
</Card>
);
}
return (
// v1.12.6 — the mood spine is ordered as: Einordnung (the classification
// tiles: in-target share + stability band) FIRST, then the "what goes with
// your better days" assessment read, then the descriptive breakdown
// sections. The classification answers "where do I stand", the better-days
// board answers "what associates with my good days", and the rest is the
// supporting detail.
// v1.12.7 — "rest" region. The Stimmungskalender (now above the chart) and
// the better-days Einschätzung (now right after the Ziel card) render in
// their own regions above; this region carries the Einordnung classification
// tiles (in-target share + stability band) FIRST, then the "what stands
// out" card, then the descriptive breakdown sections. The classification
// answers "where do I stand", and the rest is the supporting detail.
<div className="space-y-4">
{/* Einordnung — the classification tiles. */}
{(hasInTargetTile || hasStabilityTile) && (
@@ -255,31 +315,11 @@ export function MoodInsightsSections() {
</div>
)}
{/* "Was zu deinen besseren Tagen passt" — the better-days assessment,
ranked by effect size. The substance of the Einschätzung sits here,
directly under the classification. */}
{hasBetterDays && (
<SectionCard
title={t("insights.mood.betterDays.title")}
icon={Sparkles}
>
<MoodBetterDays factors={betterDays} />
</SectionCard>
)}
{narratives.length > 0 && (
<SectionCard title={t("insights.mood.narrative.title")} icon={TrendingUp}>
<MoodNarrativeFeed items={narratives} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.heatmapTitle")} icon={CalendarDays}>
<MoodHeatmap
cells={heatmapCells}
days={data.heatmap.windowDays}
stretch
/>
</SectionCard>
{/* v1.12.7 — the single "What stands out" card folds the narrative
one-liners AND the FDR-controlled discovered relations into one tile
(was two separate cards). Self-fetches the discovery surface and
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard
@@ -344,11 +384,6 @@ export function MoodInsightsSections() {
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
/>
</SectionCard>
{/* F3 — the FDR-controlled discovered mood relations. Self-fetches the
correlation-discovery surface and renders nothing when the operator
disabled it, while loading, or when no mood pair cleared the bar. */}
<MoodDiscoveredRelations />
</div>
);
}
@@ -38,14 +38,20 @@ const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
high: "insights.mood.influence.confidenceHigh",
};
// Confidence chips carry COLORED TEXT, so they ride the semantic feedback
// tokens (`--info` / `--success`) — these carry the Alucard light-mode
// overrides that clear AA on the white card. Raw `--dracula-*` stays bright
// green/cyan in light mode and fails AA for text.
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]",
high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]",
medium: "bg-info/15 text-info",
high: "bg-success/15 text-success",
};
// Semantic feedback tokens (not raw `--dracula-*`): the delta read-out is
// COLORED TEXT, so it needs the Alucard light-mode override to clear AA.
function deltaColor(delta: number): string {
return delta >= 0 ? "var(--dracula-green)" : "var(--dracula-red)";
return delta >= 0 ? "var(--success)" : "var(--destructive)";
}
export function MoodTagInfluence({
@@ -57,10 +57,14 @@ const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
high: "insights.mood.influence.confidenceHigh",
};
// Confidence chips carry COLORED TEXT, so they ride the semantic feedback
// tokens (`--info` / `--success`) — these carry the Alucard light-mode
// overrides that clear AA on the white card. Raw `--dracula-*` stays bright
// green/cyan in light mode and fails AA for text.
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]",
high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]",
medium: "bg-info/15 text-info",
high: "bg-success/15 text-success",
};
/** One decimal for hours/score, whole numbers for kcal. */
@@ -91,9 +95,11 @@ export function MoodTagMetricCrosstab({
const unit = t(UNIT_KEY[row.display]);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
// Semantic feedback tokens (not raw `--dracula-*`): the delta read-out
// is COLORED TEXT, so it needs the Alucard light-mode override for AA.
const deltaColor = up
? "var(--dracula-green)"
: "var(--dracula-red)";
? "var(--success)"
: "var(--destructive)";
return (
<li
key={`${row.metricKey}:${row.tag}`}
@@ -0,0 +1,83 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { TrendingUp } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import {
MoodNarrativeFeed,
type MoodNarrativeItem,
} from "./mood-narrative-feed";
import {
MoodDiscoveredRelations,
moodPairsOf,
type CorrelationDiscoveryResponse,
} from "./mood-discovered-relations";
/**
* v1.12.7 — the single "What stands out" card.
*
* The mood page used to carry TWO separate "what stands out" surfaces: the
* narrative takeaway feed up top, and the FDR-controlled discovered relations
* lower down — both titled "What stands out", both taking a full card. This
* folds them into ONE card: one `TileHeader`, the narrative one-liners, then
* the discovered day-to-day relations as a labelled subsection. Each half
* gates independently — the card renders whatever has content and degrades to
* nothing when both are empty, so it never paints an empty shell.
*
* The discovered-relations fetch lives here (the correlation-discovery surface
* is optional and self-degrades on a 403); the narrative items arrive from the
* mood-insights aggregate the parent already holds.
*/
export function MoodWhatStandsOut({
narratives,
}: {
narratives: MoodNarrativeItem[];
}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.insightsCorrelations(),
queryFn: async () => {
const res = await fetch("/api/insights/correlations");
// 403 = operator disabled the surface; any non-OK degrades to nothing.
if (!res.ok) throw new Error("unavailable");
const json = await res.json();
return json.data as CorrelationDiscoveryResponse;
},
enabled: isAuthenticated,
staleTime: 60_000,
// The surface is optional — don't retry a deliberate 403 into noise.
retry: false,
});
const moodPairs =
isLoading || isError || !data ? [] : moodPairsOf(data.discovered);
const hasNarratives = narratives.length > 0;
const hasDiscovered = moodPairs.length > 0;
// Both halves empty → render nothing rather than an empty card.
if (!hasNarratives && !hasDiscovered) return null;
return (
<Card data-slot="mood-what-stands-out">
<CardHeader className="pb-2">
<TileHeader icon={TrendingUp} title={t("insights.mood.narrative.title")} />
</CardHeader>
<CardContent className="space-y-4">
{hasNarratives && <MoodNarrativeFeed items={narratives} />}
{hasDiscovered && (
<MoodDiscoveredRelations
pairs={moodPairs}
pairsTested={data?.pairsTested ?? moodPairs.length}
/>
)}
</CardContent>
</Card>
);
}
@@ -29,12 +29,12 @@ import { ConfidenceMeter } from "./confidence-meter";
*/
const SEVERITY_BADGE_STYLES: Record<string, string> = {
info: "bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25",
info: "bg-info/10 text-info border border-info/25",
suggestion:
"bg-dracula-purple/10 text-dracula-purple border border-dracula-purple/25",
"bg-dose-accent/10 text-dose-accent border border-dose-accent/25",
important:
"bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25",
urgent: "bg-dracula-red/10 text-dracula-red border border-dracula-red/25",
"bg-warning/10 text-warning border border-warning/25",
urgent: "bg-destructive/10 text-destructive border border-destructive/25",
};
interface RecommendationCardProps {
@@ -223,7 +223,7 @@ export function RecommendationFeedback({
className="text-muted-foreground flex items-center gap-1.5 text-xs"
data-slot="rec-feedback-confirmation"
>
<Check className="text-dracula-green h-3.5 w-3.5" aria-hidden="true" />
<Check className="text-success h-3.5 w-3.5" aria-hidden="true" />
<span data-feedback-confirmed={verdict}>
{t("insights.recommendation.feedbackThanks")}
</span>
@@ -240,12 +240,12 @@ export function RecommendationFeedback({
>
{verdict === "up" ? (
<ThumbsUp
className="text-dracula-green h-3.5 w-3.5"
className="text-success h-3.5 w-3.5"
aria-hidden="true"
/>
) : (
<ThumbsDown
className="text-dracula-orange h-3.5 w-3.5"
className="text-warning h-3.5 w-3.5"
aria-hidden="true"
/>
)}
@@ -269,7 +269,7 @@ export function RecommendationFeedback({
title={t("insights.recommendation.feedbackHelpful")}
disabled={disabled}
onClick={() => submit(true)}
className="text-muted-foreground hover:text-dracula-green disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
className="text-muted-foreground hover:text-success disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
>
{submittingUp ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
@@ -284,7 +284,7 @@ export function RecommendationFeedback({
title={t("insights.recommendation.feedbackNotHelpful")}
disabled={disabled}
onClick={() => submit(false)}
className="text-muted-foreground hover:text-dracula-orange disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
className="text-muted-foreground hover:text-warning disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
>
{submittingDown ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
+6 -8
View File
@@ -168,7 +168,7 @@ export function SubPageShell({
// state + renders the sheet, so the gear and the card don't have to
// share a parent beyond this shell.
<TargetAdjustProvider>
<div data-slot="insights-subpage" className="space-y-4 md:space-y-5">
<div data-slot="insights-subpage" className="space-y-3 md:space-y-4">
{/* v1.8.7.1 — back-nav leads the page, above the heading. */}
{backLink}
<header className="space-y-1.5">
@@ -262,13 +262,11 @@ export function SubPageShell({
brand-new-metric paths. Numbers-first is the Apple-Health /
Withings / Oura detail-screen convention.
The stats describe the full range. Recomputing them for a window
the user brushes / selects in the chart below would mean lifting the
chart's domain into this shell so both the chart and the strip read
it — but the shared `<HealthChart>` (also driving the dashboard) has
no Recharts `<Brush>` or domain-change callback and keeps its range
state internal, so that wiring is left for a later release to avoid
destabilising the chart. */}
v1.12.7 — the stats are chart-reactive: when the user brushes a
window in the chart below, the page lifts the chart's per-type
windowed Min / Max / Median / Mean and threads it back into the
strip, which swaps the header to a "selected range" pill. No
selection falls back to the cheap full-range summary. */}
{statStrip}
{/* v1.12.6 — the canonical spine body: intro (header) → stat strip
(above) → chart → target card → assessment. The page renders
@@ -1,218 +0,0 @@
"use client";
import { ArrowDown, ArrowRight, ArrowUp, Gauge, Minus } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import type { DataSummary } from "@/lib/analytics/trends";
import { cn } from "@/lib/utils";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
/**
* v1.4.25 W16a — VO2 max chart-row card for `/insights/pulse`.
*
* The dashboard already carries an opt-in VO2 max trend tile (W8d.5);
* this is the deeper Insights surface. Renders one HealthChart for the
* trend plus a compact stat strip:
*
* - latest value with the personalised target unit
* - min / max across the underlying samples
* - 30-day average and the comparison delta vs. the prior period
*
* The card stays mounted even when `summary` reports zero samples — a
* brand-new account gets the "no data yet" hint rather than a missing
* surface, matching the dashboard tile's opt-in pattern.
*
* The HealthChart mounts behind the shared `<HealthChartDynamic>`
* boundary so the lazy-import + skeleton-loading state stays in
* lock-step with the other Insights chart consumers.
*/
interface Vo2MaxChartRowProps {
/**
* Analytics summary for `MeasurementType=VO2_MAX`. Null is allowed so
* the parent doesn't have to gate on the network read finishing — the
* row falls back to the empty-state hint until the data lands.
*/
summary: DataSummary | null;
/**
* Comparison baseline propagated from the user's Insights layout pref
* so the chart's reference line + the in-card delta caption line up.
*/
compareBaseline?: ComparisonBaseline;
/** User's IANA timezone string — propagates to HealthChart formatting. */
userTimezone?: string;
}
export function Vo2MaxChartRow({
summary,
compareBaseline = "none",
userTimezone,
}: Vo2MaxChartRowProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const latest = summary?.latest ?? null;
const min = summary?.min ?? null;
const max = summary?.max ?? null;
const avg30 = summary?.avg30 ?? null;
const slope30 = summary?.slope30 ?? null;
const count = summary?.count ?? 0;
// 30-day delta. Mirrors the dashboard's `tileCompareDelta` rule:
// suppressed when comparison is off OR either side has no data.
const compareDelta = ((): number | null => {
if (!summary || compareBaseline === "none") return null;
const current = summary.avg30 ?? null;
const prior =
compareBaseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
: (summary.avg30LastYear ?? null);
if (current === null || prior === null) return null;
return Math.round((current - prior) * 100) / 100;
})();
// Higher VO2 max is better → up-good sentiment (mirrors the dashboard
// tile). Determines arrow + delta colour.
const TrendIcon =
slope30?.direction === "up"
? ArrowUp
: slope30?.direction === "down"
? ArrowDown
: slope30
? ArrowRight
: Minus;
const trendColor = ((): string => {
if (!slope30 || slope30.direction === "stable")
return "text-muted-foreground";
return slope30.direction === "up"
? "text-dracula-green"
: "text-dracula-orange";
})();
const formatValue = (value: number | null): string =>
value === null ? "—" : fmt.number(value, 1);
const formatDelta = (value: number): string => {
if (Math.abs(value) < 0.05) return "±0";
const sign = value > 0 ? "+" : "−";
return `${sign}${fmt.number(Math.abs(value), 1)}`;
};
const unit = t("dashboard.vo2MaxUnit") ?? "mL/(kg·min)";
const title = t("dashboard.vo2Max") ?? "VO₂ max";
const hasData = count > 0;
// Color the comparison delta with the same up-good sentiment rules.
const compareColor = ((): string => {
if (compareDelta === null || Math.abs(compareDelta) < 0.05) {
return "text-muted-foreground";
}
return compareDelta > 0 ? "text-dracula-green" : "text-dracula-orange";
})();
return (
<section
data-slot="vo2-chart-row"
aria-label={title}
className="space-y-3"
>
<div className="flex items-baseline justify-between gap-2">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Gauge className="text-muted-foreground h-4 w-4" aria-hidden="true" />
{title}
</h2>
<p className="text-muted-foreground text-xs">{unit}</p>
</div>
{/* Stat strip — latest / min / max / avg30 / Δ vs. prior period.
v1.4.27 MB7 / CF-38 — extend the 2-column phone grid all the
way to `<lg` so tablets keep the 2×2 layout (avoids
mid-viewport reflows on Pixel Fold) and lift each cell to a
`min-h-[64px]` floor so the chips paint at consistent
heights even when one stat has no trend arrow. */}
<div
data-slot="vo2-chart-row-stats"
className="bg-card border-border grid grid-cols-2 gap-3 rounded-xl border p-4 lg:grid-cols-4 [&>div]:min-h-[64px]"
>
<div data-slot="vo2-stat" data-stat="latest" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("insights.vo2Max.statLatest") ?? "Latest"}
</p>
<p className="flex items-baseline gap-1 text-xl font-semibold tabular-nums">
<span>{formatValue(latest)}</span>
{slope30 ? (
<TrendIcon
className={cn("h-4 w-4 shrink-0", trendColor)}
aria-hidden="true"
/>
) : null}
</p>
</div>
<div data-slot="vo2-stat" data-stat="min" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("insights.vo2Max.statMin") ?? "Min"}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatValue(min)}
</p>
</div>
<div data-slot="vo2-stat" data-stat="max" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("insights.vo2Max.statMax") ?? "Max"}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatValue(max)}
</p>
</div>
<div data-slot="vo2-stat" data-stat="avg30" className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("charts.avg30dShort") ?? "30d"}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatValue(avg30)}
</p>
{compareDelta !== null ? (
<p
data-slot="vo2-compare-delta"
data-compare-baseline={compareBaseline}
className={cn(
"text-xs font-medium tabular-nums",
compareColor,
)}
>
{`Δ ${formatDelta(compareDelta)} ${t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}`}
</p>
) : null}
</div>
</div>
{/* Trend chart. The chartKey isolates per-chart overlay prefs from
the dashboard VO2 tile and from the pulse chart sitting above
it on the same page. */}
{hasData ? (
<HealthChartDynamic
chartKey="vo2Max"
types={["VO2_MAX"]}
title={title}
colors={["#50fa7b"]}
unit={unit}
compareBaseline={compareBaseline}
userTimezone={userTimezone}
/>
) : (
<p
data-slot="vo2-chart-row-empty"
className="text-muted-foreground bg-muted/30 rounded-md border border-dashed p-6 text-center text-sm italic"
>
{t("insights.vo2Max.empty") ??
"No VO₂ max data yet — sync Apple Health to populate this view."}
</p>
)}
</section>
);
}
+1 -1
View File
@@ -404,7 +404,7 @@ export function AboutSection() {
role="alert"
className={`mt-2 text-xs ${
tourFeedback.type === "success"
? "text-dracula-green"
? "text-success"
: "text-destructive"
}`}
>
+5 -5
View File
@@ -573,7 +573,7 @@ export function AccountSection() {
role="alert"
className={`text-sm ${
saveMsgType === "success"
? "text-dracula-green"
? "text-success"
: "text-destructive"
}`}
>
@@ -627,7 +627,7 @@ export function AccountSection() {
role="alert"
className={`mt-2 text-right text-sm ${
passkeyMsgType === "success"
? "text-dracula-green"
? "text-success"
: "text-destructive"
}`}
>
@@ -709,7 +709,7 @@ export function AccountSection() {
role="alert"
className={`mt-2 text-xs ${
tourFeedback.type === "success"
? "text-dracula-green"
? "text-success"
: "text-destructive"
}`}
>
@@ -778,7 +778,7 @@ export function AccountSection() {
role="alert"
className={`text-sm ${
passwordMsgType === "success"
? "text-dracula-green"
? "text-success"
: "text-destructive"
}`}
>
@@ -1199,7 +1199,7 @@ function AvatarSection() {
<p
role="alert"
className={`mt-3 text-sm ${
msgType === "success" ? "text-dracula-green" : "text-destructive"
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
{msg}
+6 -6
View File
@@ -248,7 +248,7 @@ function ApiTokensCard() {
status={
<>
{activeTokens.length > 0 && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
<Badge className="border-success/30 bg-success/15 text-success">
{t("settings.configured")}
</Badge>
)}
@@ -284,10 +284,10 @@ function ApiTokensCard() {
{newToken && (
<div
className="bg-dracula-green/10 rounded-lg p-3 text-sm"
className="bg-success/10 rounded-lg p-3 text-sm"
data-slot="settings-api-token-created"
>
<p className="text-dracula-green mb-1 font-medium">
<p className="text-success mb-1 font-medium">
{t("settings.tokenCreated")}
</p>
<div className="flex items-start gap-2">
@@ -304,7 +304,7 @@ function ApiTokensCard() {
data-slot="settings-api-token-copy"
>
{tokenCopied ? (
<Check className="text-dracula-green h-3.5 w-3.5" />
<Check className="text-success h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
@@ -377,7 +377,7 @@ function ApiTokensCard() {
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.tokenActive")}
</Badge>
)}
@@ -465,7 +465,7 @@ function ApiTokensCard() {
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green text-[10px]">
<Badge className="bg-success/15 text-success text-[10px]">
{t("settings.tokenActive")}
</Badge>
)}
@@ -68,7 +68,7 @@ export function InjectionSitesCard({
}
return (
<div className="rounded-lg border p-4">
<div className="bg-card border-border rounded-lg border p-4">
<SettingsCardHeader
icon={Syringe}
title={t("settings.globalExcludedInjectionSitesLabel")}
@@ -99,20 +99,19 @@ export function IntegrationStatusPill({
// chipClass only applies to the `connected`, `warning`, and `parked`
// branches — `error` falls back to `variant="destructive"` and
// `disconnected` to `variant="outline"`. The warning chip uses amber
// tokens to signal "connected, but the upstream just returned a
// contract-level error a reauth won't fix" — distinct from the red
// reconnect pill. The parked chip uses the orange tokens to signal
// "we tried to keep going for 24h but the contract mismatch persists
// — manual intervention required" — visually heavier than warning
// (sustained problem), lighter than error (still recoverable without
// re-auth).
// `disconnected` to `variant="outline"`. The warning and parked chips
// both carry the semantic `warning` tone (an AA-safe amber that clears
// contrast in light mode); their distinct labels + icons carry the
// meaning — "connected, but the upstream returned a contract-level error
// a reauth won't fix" vs "tried to keep going for 24h but the mismatch
// persists, manual intervention required" — distinct from the red
// reconnect pill (`error`).
const connectedChipClass =
"border-dracula-green/30 bg-dracula-green/15 text-dracula-green";
"border-success/30 bg-success/15 text-success";
const warningChipClass =
"border-dracula-yellow/30 bg-dracula-yellow/15 text-dracula-yellow";
"border-warning/30 bg-warning/15 text-warning";
const parkedChipClass =
"border-dracula-orange/30 bg-dracula-orange/15 text-dracula-orange";
"border-warning/30 bg-warning/15 text-warning";
let label: string;
let icon: React.ReactNode;
@@ -433,9 +433,9 @@ function WithingsCard({
{pillState === "parked" && (
<div
data-testid="withings-parked-banner"
className="border-dracula-orange/30 bg-dracula-orange/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-dracula-orange min-w-0 break-words text-xs">
<span className="text-warning min-w-0 break-words text-xs">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
@@ -468,7 +468,7 @@ function WithingsCard({
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-dracula-green text-xs"
className="text-success text-xs"
data-testid="withings-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
@@ -553,7 +553,7 @@ function WithingsCard({
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
@@ -656,7 +656,7 @@ function WithingsCard({
{syncMsg && (
<p
role="alert"
className={`text-sm ${syncMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
@@ -857,9 +857,9 @@ function WhoopCard({
{pillState === "parked" && (
<div
data-testid="whoop-parked-banner"
className="border-dracula-orange/30 bg-dracula-orange/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-dracula-orange min-w-0 break-words text-xs">
<span className="text-warning min-w-0 break-words text-xs">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
@@ -892,7 +892,7 @@ function WhoopCard({
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-dracula-green text-xs"
className="text-success text-xs"
data-testid="whoop-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
@@ -972,7 +972,7 @@ function WhoopCard({
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
@@ -1074,7 +1074,7 @@ function WhoopCard({
{syncMsg && (
<p
role="alert"
className={`text-sm ${syncMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
@@ -1303,9 +1303,9 @@ function FitbitCard({
{pillState === "parked" && (
<div
data-testid="fitbit-parked-banner"
className="border-dracula-orange/30 bg-dracula-orange/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-dracula-orange min-w-0 break-words text-xs">
<span className="text-warning min-w-0 break-words text-xs">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
@@ -1338,7 +1338,7 @@ function FitbitCard({
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-dracula-green text-xs"
className="text-success text-xs"
data-testid="fitbit-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
@@ -1418,7 +1418,7 @@ function FitbitCard({
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
@@ -1520,7 +1520,7 @@ function FitbitCard({
{syncMsg && (
<p
role="alert"
className={`text-sm ${syncMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
@@ -1887,7 +1887,7 @@ function MoodLogCard({
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "error" ? "text-destructive" : "text-dracula-green"}`}
className={`text-sm ${msgType === "error" ? "text-destructive" : "text-success"}`}
>
{msg}
</p>
+1 -1
View File
@@ -149,7 +149,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
{saveMsg && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
</p>
+4 -4
View File
@@ -336,10 +336,10 @@ function ShareLinksCard() {
{newToken && (
<div
className="bg-dracula-green/10 space-y-2 rounded-lg p-3 text-sm"
className="bg-success/10 space-y-2 rounded-lg p-3 text-sm"
data-testid="share-token-reveal"
>
<p className="text-dracula-green font-medium">
<p className="text-success font-medium">
{t("settings.sharing.tokenCreated")}
</p>
<p className="text-muted-foreground text-[11px]">
@@ -360,7 +360,7 @@ function ShareLinksCard() {
</Button>
</div>
{copied && (
<p className="text-dracula-green text-[11px]">
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
@@ -390,7 +390,7 @@ function ShareLinksCard() {
{link.label}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="bg-dracula-green/15 text-dracula-green text-[10px]">
<Badge className="bg-success/15 text-success text-[10px]">
{t("settings.sharing.statusActive")}
</Badge>
{link.allowFhirApi && (
+1 -1
View File
@@ -157,7 +157,7 @@ export function TelegramCard({
role="alert"
className={`text-sm ${
msgType === "success"
? "text-dracula-green"
? "text-success"
: "text-destructive"
}`}
>
+3 -3
View File
@@ -31,8 +31,8 @@ import { useTranslations } from "@/lib/i18n/context";
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. The `sm:max-w-sm` modifier still composes via the
// `className` prop below so the picker keeps its narrow desktop width.
// contract now. The select spans the full width of its grid cell so it
// lines up with the fields above it rather than sitting narrower.
export interface TimezonePickerProps {
/** The user's current stored timezone. */
@@ -82,7 +82,7 @@ export function TimezonePicker({
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className="sm:max-w-sm"
className="w-full"
>
{/* Preserve the stored value even if the runtime's IANA
list changed across engines or tzdata rolls. */}
+1 -1
View File
@@ -216,7 +216,7 @@ export function WebPushCard() {
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useCallback, useRef, useState } from "react";
import type { MetricWindowStats } from "@/lib/charts/window-stats";
/**
* v1.12.7 — chart-reactive metric statistics.
*
* A metric sub-page lifts this hook so its `<HealthChart selectableDomain>`
* (which mounts a Recharts `<Brush>`) and its `<MetricStatStrip>` read the
* same brushed window. The chart reports the per-type Min / Max / Median /
* Mean for the selected domain through `onDomainStats`; the strip reads
* `statsByType[type]` and, when present, renders the windowed numbers in
* place of the full-range summary.
*
* `null` (no selection, or a selection that collapsed to under one point)
* means "show the full range" — the strip falls back to the precomputed
* summary, so the default is unchanged and costs no extra work. The chart
* owns the maths (it already holds the bucketed series), so the hook only
* stores what it is handed and exposes a stable callback.
*
* The callback is referentially stable across renders so it can sit in the
* chart's effect dependency list without re-firing. A shallow guard skips
* the state write when the chart reports the same window twice in a row,
* keeping a drag that lands on the same indices from thrashing the strip.
*/
export interface ChartDomainStats {
/**
* Per-type windowed stats, keyed by `MeasurementType`. Null means no
* active selection (full range). A type may be absent from the map when
* its series has no readings inside the brushed window — the strip falls
* back to the full-range summary for that series.
*/
statsByType: Record<string, MetricWindowStats> | null;
/** True while a sub-range is selected (drives the "selected range" label). */
isWindowed: boolean;
/** Stable callback handed to `<HealthChart onDomainStats>`. */
onDomainStats: (stats: Record<string, MetricWindowStats> | null) => void;
}
export function useChartDomainStats(): ChartDomainStats {
const [statsByType, setStatsByType] = useState<Record<
string,
MetricWindowStats
> | null>(null);
const lastRef = useRef<string>("null");
const onDomainStats = useCallback(
(stats: Record<string, MetricWindowStats> | null) => {
// Cheap shallow signature so an identical brush window (a drag that
// settles on the same indices, or a re-render that recomputes the
// same slice) doesn't re-set state and re-render the strip.
const signature = stats === null ? "null" : JSON.stringify(stats);
if (signature === lastRef.current) return;
lastRef.current = signature;
setStatsByType(stats);
},
[],
);
return {
statsByType,
isWindowed: statsByType !== null,
onDomainStats,
};
}
+9
View File
@@ -46,6 +46,12 @@ export interface UseInsightsAnalyticsResult {
*/
isEmpty: boolean;
error: Error | null;
/**
* Re-issue the underlying `/api/analytics?slice=summaries` query. Wired
* to the error+retry control on the HealthKit sub-pages so a failed load
* recovers without a full reload.
*/
refetch: () => void;
}
export function useInsightsAnalytics(
@@ -78,5 +84,8 @@ export function useInsightsAnalytics(
isLoading: query.isLoading,
isEmpty,
error: query.error as Error | null,
refetch: () => {
void query.refetch();
},
};
}
+118 -6
View File
@@ -6,7 +6,10 @@ vi.mock("@/lib/db", () => ({
idempotencyKey: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
},
apiToken: {
findUnique: vi.fn(),
@@ -54,6 +57,12 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
vi.mocked(prisma.idempotencyKey.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({
count: 1,
} as never);
});
describe("withIdempotency", () => {
@@ -80,18 +89,29 @@ describe("withIdempotency", () => {
});
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
// First call: nothing cached → handler runs → response stored.
// First call: nothing cached → key claimed (create) → handler runs →
// claim promoted to the completed response (updateMany).
const req1 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
const res1 = await wrapped(req1);
const body1 = await res1.json();
expect(res1.status).toBe(201);
expect(body1).toEqual({ data: { result: 1 }, error: null });
expect(handler).toHaveBeenCalledTimes(1);
// Claim inserted before the handler ran (pending sentinel).
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
const claim = (
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
data: { responseStatus: number; responseBody: string };
}
).data;
expect(claim.responseStatus).toBe(0);
expect(claim.responseBody).toBe("");
// Completed response promoted via updateMany.
expect(prisma.idempotencyKey.updateMany).toHaveBeenCalledTimes(1);
// Capture the persisted body for replay.
const persistedBody = (
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
vi.mocked(prisma.idempotencyKey.updateMany).mock.calls[0][0] as {
data: { responseBody: string; responseStatus: number };
}
).data;
@@ -199,6 +219,89 @@ describe("withIdempotency", () => {
});
});
// A3 — concurrent same-key requests must not both run the side-effect.
// The key is claimed (pending row) before the handler runs; a racing
// request either sees the pending row or loses the insert race, and
// must be refused with 409 instead of executing a second time.
describe("withIdempotency concurrency claim (A3)", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
vi.mocked(prisma.idempotencyKey.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({
count: 1,
} as never);
});
it("returns 409 without running the handler when a pending claim exists", async () => {
// A concurrent request already claimed the key — findUnique returns
// the pending sentinel row (responseStatus 0, not yet expired).
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
id: "idem-pending",
userId: "u-1",
key: "abc-12345678",
method: "POST",
path: "/api/example",
responseStatus: 0,
responseBody: "",
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
} as never);
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
expect(res.status).toBe(409);
expect(handler).not.toHaveBeenCalled();
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
});
it("returns 409 when a racing request wins the claim insert (P2002)", async () => {
// No row at lookup time, but the claim insert collides with a
// concurrent insert under the unique constraint.
vi.mocked(prisma.idempotencyKey.create).mockRejectedValueOnce(
Object.assign(new Error("unique"), { code: "P2002" }),
);
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
expect(res.status).toBe(409);
expect(handler).not.toHaveBeenCalled();
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
});
it("releases the claim and re-throws when the handler throws", async () => {
const boom = new Error("handler exploded");
const handler = vi.fn(async () => {
throw boom;
});
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await expect(
wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" })),
).rejects.toBe(boom);
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
// Claim released so a retry isn't locked out for the pending window.
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
});
});
// Audit C-4 / phase P2: defaultUserIdResolver must support both cookie
// sessions AND Bearer tokens. Without the Bearer fallback, idempotency
// silently turned off for the iOS / external-ingest paths it was built for.
@@ -282,6 +385,12 @@ describe("withIdempotency body-content exclusion (P12)", () => {
vi.resetAllMocks();
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
vi.mocked(prisma.idempotencyKey.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({
count: 1,
} as never);
});
it.each([
@@ -293,12 +402,15 @@ describe("withIdempotency body-content exclusion (P12)", () => {
"sk-ant- Anthropic key",
'{"data":{"echoed":"sk-ant-api03-xyz"},"error":null}',
],
])("does NOT cache responses containing %s", async (_label, body) => {
])("does NOT persist responses containing %s", async (_label, body) => {
const handler = vi.fn(async () => new NextResponse(body, { status: 201 }));
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
// The claim row is still inserted, but the secret-shaped body must
// never be promoted into it — the claim is released instead.
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledTimes(1);
});
it.each([
@@ -316,7 +428,7 @@ describe("withIdempotency body-content exclusion (P12)", () => {
async () => "u-1",
);
await wrapped(makeRequest("POST", { "idempotency-key": "key-12345678" }));
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.updateMany).toHaveBeenCalledTimes(1);
},
);
@@ -327,7 +439,7 @@ describe("withIdempotency body-content exclusion (P12)", () => {
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.updateMany).toHaveBeenCalledTimes(1);
});
});
@@ -37,7 +37,7 @@ describe("getStrictInsightsSystemPrompt — EN", () => {
const prompt = getStrictInsightsSystemPrompt("en");
it("carries the Apple Health silent-fallback contract (rule 11)", () => {
expect(prompt).toMatch(/11\. v1\.4\.23/);
expect(prompt).toMatch(/11\. Optional Apple Health metric categories/);
expect(prompt).toMatch(/Apple Health metric categories/);
expect(prompt).toMatch(/HRV/);
expect(prompt).toMatch(/sleep duration/);
@@ -49,11 +49,12 @@ describe("getStrictInsightsSystemPrompt — EN", () => {
it("does not bake live tenant figures into the new rule", () => {
// PII rule: the Apple Health rule frames in terms of metric
// categories, never a specific number / email / username. The block
// starts at "11. v1.4.23" (renumbered when v1.4.28 dropped the
// weekly-report rule) and ends at the GUIDELINE TARGETS heading
// that closes the ground-rules section.
// starts at "11. Optional Apple Health metric categories" and ends at
// the GUIDELINE TARGETS heading that closes the ground-rules section.
const ruleAppleHealth =
prompt.split("11. v1.4.23")[1]?.split("GUIDELINE TARGETS")[0] ?? "";
prompt
.split("11. Optional Apple Health metric categories")[1]
?.split("GUIDELINE TARGETS")[0] ?? "";
expect(ruleAppleHealth.length).toBeGreaterThan(0);
expect(ruleAppleHealth).not.toMatch(/@.+\.(com|net|org|de|test)/);
// The rule mentions metric category names but no concrete sample
@@ -72,7 +73,7 @@ describe("getStrictInsightsSystemPrompt — DE", () => {
const prompt = getStrictInsightsSystemPrompt("de");
it("carries the German silent-fallback wording (rule 11)", () => {
expect(prompt).toMatch(/11\. v1\.4\.23/);
expect(prompt).toMatch(/11\. Optionale Apple-Health-Metrik-Kategorien/);
expect(prompt).toMatch(/Apple-Health-Metrik-Kategorien/);
expect(prompt).toMatch(/HRV/);
expect(prompt).toMatch(/Schlafdauer/);
@@ -100,7 +101,7 @@ describe("getStrictInsightsSystemPrompt — DE", () => {
describe("getStrictInsightsSystemPrompt — enum-identifier ban (no enum names in prose, rule 12)", () => {
it("EN prompt carries the rule and enumerates the banned identifiers", () => {
const prompt = getStrictInsightsSystemPrompt("en");
expect(prompt).toMatch(/12\. v1\.4\.25/);
expect(prompt).toMatch(/12\. Internal metric identifiers/);
expect(prompt).toMatch(/Internal metric identifiers/);
// The banned-list is enumerated verbatim so the model has no
// ambiguity about which strings are off-limits in prose.
@@ -122,7 +123,7 @@ describe("getStrictInsightsSystemPrompt — enum-identifier ban (no enum names i
it("DE prompt carries the rule with the same banned list", () => {
const prompt = getStrictInsightsSystemPrompt("de");
expect(prompt).toMatch(/12\. v1\.4\.25/);
expect(prompt).toMatch(/12\. Interne Metrik-Identifier/);
expect(prompt).toMatch(/Interne Metrik-Identifier/);
expect(prompt).toMatch(/"Pressure_Sys"/);
expect(prompt).toMatch(/"BLOOD_PRESSURE_SYS"/);
+25
View File
@@ -163,6 +163,31 @@ export function getMedicalReferenceById(
return MEDICAL_REFERENCES.find((r) => r.id === id);
}
/**
* Map the set of feature/snapshot sections present for a generation onto
* the reference metric buckets. Lets the insight consumers pick the
* applicable curated SOURCES without re-encoding the bucket names.
*
* `present` carries one boolean per high-level section the snapshot
* actually contains; absent / false sections drop their bucket so a
* weight-only generation never lists ESH BP guidance.
*/
export function metricsFromPresentSections(present: {
bloodPressure?: boolean;
weight?: boolean;
pulse?: boolean;
mood?: boolean;
medication?: boolean;
}): MedicalReferenceMetric[] {
const out: MedicalReferenceMetric[] = [];
if (present.bloodPressure) out.push("bp");
if (present.weight) out.push("weight");
if (present.pulse) out.push("pulse");
if (present.mood) out.push("mood");
if (present.medication) out.push("medication");
return out;
}
/**
* Pick the references whose `metricApplicability` overlaps any of the
* supplied metric buckets. Order preserved from the canonical bundle
+15 -4
View File
@@ -11,7 +11,7 @@ const BP_SECTION_DE = `METRIK — BLUTDRUCK:
- targets: Falls bloodPressure.targets vorhanden, ordne gegen das persönliche Zielband ein; inTargetPctLast30DailyPoints ist der Anteil der letzten Tage im Ziel.
- Medikamenten-Bezug: bpMedications trägt compliance7/compliance30. bpMedicationContinuityVsSystolic.correlation und weightVsSystolic.correlation nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Stimmung: moodContext.moodVsSystolicCorrelation nur erwähnen, wenn vorhanden und |r| > 0.4.
- Eine Botschaft: Wenn die Werte über der Baseline liegen, schließe mit EINEM machbaren Schritt (z.B. ein paar Tage zur selben Uhrzeit nachmessen, oder — bei lückenhafter Einnahmetreue — die Einnahme verlässlicher machen). Bei stabilen Werten im Ziel: das ehrlich anerkennen.`;
- Eine Botschaft: Wenn die Werte über der Baseline liegen, schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. ein paar Tage zur selben Uhrzeit nachmessen, oder — bei lückenhafter Einnahmetreue — die Einnahme verlässlicher machen). Bei stabilen Werten im Ziel: das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
const BP_SECTION_EN = `METRIC — BLOOD PRESSURE:
- The snapshot carries systolic (bloodPressure.systolic) and diastolic (bloodPressure.diastolic), each with a summary + graded series; judge both components TOGETHER, never in isolation.
@@ -23,7 +23,7 @@ const BP_SECTION_EN = `METRIC — BLOOD PRESSURE:
- targets: if bloodPressure.targets is present, place values against the personal target band; inTargetPctLast30DailyPoints is the share of recent days in target.
- Medication link: bpMedications carries compliance7/compliance30. Mention bpMedicationContinuityVsSystolic.correlation and weightVsSystolic.correlation only when present and |r| > 0.4 — as an association, never a cause.
- Mood: mention moodContext.moodVsSystolicCorrelation only when present and |r| > 0.4.
- One message: if the values sit above the person's baseline, close with ONE doable step (e.g. take a few readings at the same time of day for a few days, or — when adherence is patchy — make the medication routine more reliable). When values are stable and in target, say so honestly.`;
- One message: if the values sit above the person's baseline, close with ONE doable step ONLY when the finding implies one (e.g. take a few readings at the same time of day for a few days, or — when adherence is patchy — make the medication routine more reliable). When values are stable and in target, say so honestly and name one thing worth keeping an eye on instead of manufacturing a step.`;
export function getBloodPressureSystemPrompt(locale: Locale): string {
const section = locale === "en" ? BP_SECTION_EN : BP_SECTION_DE;
@@ -37,19 +37,30 @@ export function getBloodPressureUserPrompt(
todayKey: string,
locale: Locale,
previousContextBlock?: string,
/**
* v1.12.7 — diversity / anti-repetition context (variety lead, data
* strength, steady-run repetition signal, cross-metric relations), the
* same block the archetype cards carry. Grounded in already-computed
* data; optional and may be empty.
*/
assessmentContextBlock?: string,
): string {
const ctxBlock =
previousContextBlock && previousContextBlock.trim().length > 0
? `\n\n${previousContextBlock}\n`
: "";
const extraBlock =
assessmentContextBlock && assessmentContextBlock.trim().length > 0
? `\n\n${assessmentContextBlock}\n`
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Write one short assessment of this person's blood pressure: name the current systolic/diastolic level, place the recent days against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
Write one short assessment of this person's blood pressure: name the current systolic/diastolic level, place the recent days against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Schreibe eine kurze Einschätzung zum Blutdruck dieser Person: benenne das aktuelle systolisch/diastolisch-Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
Schreibe eine kurze Einschätzung zum Blutdruck dieser Person: benenne das aktuelle systolisch/diastolisch-Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
+10 -4
View File
@@ -7,7 +7,7 @@ const BMI_SECTION_DE = `METRIK — BMI:
- WHO-Bänder zur groben Einordnung, nie als Etikett für die Person: Untergewicht < 18.5, Normal 18.5-24.9, Übergewicht 25.0-29.9, Adipositas I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. Ab 65+ gilt 22-27 als günstig (DEGAM).
- Bandbezug: Vergleiche den recent-Wert gegen das weekly/monthly-Mittel und nenne es ausdrücklich, wenn der recent-Wert ein WHO-Band gewechselt hat oder sich (innerhalb ~0.5 BMI-Punkte) einer Bandgrenze nähert. Bleibt der Wert sicher im selben Band, sage das knapp — keine Tempo-Erzählung erzwingen.
- Grenzen des BMI offen ansprechen: Er misst keine Körperzusammensetzung und ist bei muskulösen oder sehr sportlichen Personen wenig aussagekräftig.
- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Bandlage passt — bei stabilem Wert im günstigen Band das ehrlich anerkennen, statt einen Befund zu erzwingen.`;
- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, der zur Bandlage passt, wenn der Befund einen nahelegt — bei stabilem Wert im günstigen Band das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
const BMI_SECTION_EN = `METRIC — BMI:
- The snapshot carries bmi.summary + bmi.series (graded). bmi.latestDayFocus shows the latest value, its WHO classification and the step from the prior day; bmi.target is the green band (18.5-24.9).
@@ -15,7 +15,7 @@ const BMI_SECTION_EN = `METRIC — BMI:
- WHO bands for rough placement, never as a label for the person: Underweight < 18.5, Normal 18.5-24.9, Overweight 25.0-29.9, Obesity I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. From age 65+, 22-27 counts as favourable (DEGAM).
- Band reference: compare the recent value against the weekly/monthly mean and call it out explicitly when the recent value has crossed a WHO band or is nearing a band boundary (within ~0.5 BMI points). When the value stays safely inside the same band, say so briefly — do not force a pace narrative.
- Name BMI's limits plainly: it does not measure body composition and is weakly informative for muscular or very athletic people.
- One message: close with ONE doable step that fits the band placement — when the value is stable in the favourable band, acknowledge that honestly rather than forcing a finding.`;
- One message: close with ONE doable step that fits the band placement ONLY when the finding implies one — when the value is stable in the favourable band, acknowledge that honestly and name one thing worth keeping an eye on rather than forcing a finding or a step.`;
export function getBmiSystemPrompt(locale: Locale): string {
const section = locale === "en" ? BMI_SECTION_EN : BMI_SECTION_DE;
@@ -29,19 +29,25 @@ export function getBmiUserPrompt(
todayKey: string,
locale: Locale,
previousContextBlock?: string,
/** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */
assessmentContextBlock?: string,
): string {
const ctxBlock =
previousContextBlock && previousContextBlock.trim().length > 0
? `\n\n${previousContextBlock}\n`
: "";
const extraBlock =
assessmentContextBlock && assessmentContextBlock.trim().length > 0
? `\n\n${assessmentContextBlock}\n`
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Write one short assessment of this person's BMI: name the current value and WHO band, say whether it has crossed a band or is nearing a band boundary against their own weekly/monthly baseline, and close with one doable step. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock}
Write one short assessment of this person's BMI: name the current value and WHO band, say whether it has crossed a band or is nearing a band boundary against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Schreibe eine kurze Einschätzung zum BMI dieser Person: benenne den aktuellen Wert und das WHO-Band, sage gegen die eigene Wochen-/Monats-Baseline, ob er ein Band gewechselt hat oder sich einer Bandgrenze nähert, und schließe mit einem machbaren Schritt. Das Gewichtstempo bleibt der Gewichts-Karte überlassen. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
Schreibe eine kurze Einschätzung zum BMI dieser Person: benenne den aktuellen Wert und das WHO-Band, sage gegen die eigene Wochen-/Monats-Baseline, ob er ein Band gewechselt hat oder sich einer Bandgrenze nähert, und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Das Gewichtstempo bleibt der Gewichts-Karte überlassen. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
+10 -4
View File
@@ -8,7 +8,7 @@ const GENERAL_SECTION_DE = `METRIK — GESAMTBEWERTUNG:
- Pro Metrik dieselbe Logik: das recent-Mittel gegen das weekly/monthly-Mittel der Person stellen und nur signifikante Abweichungen von der eigenen Baseline melden.
- Positives ausdrücklich anerkennen — ein stabiler oder sich verbessernder Wert ist eine echte Botschaft, kein Lückenfüller.
- Zusammenhänge nur erwähnen, wenn ein r-Wert im Snapshot vorhanden und |r| > 0.4 ist — als Zusammenhang, nie als Ursache. Stimmung als Kontext einbeziehen, nicht übergewichten.
- Eine Botschaft: Auch über alle Metriken hinweg endet die Einschätzung mit GENAU EINER wichtigsten, machbaren Empfehlung ("eine Sache").`;
- Eine Botschaft: Auch über alle Metriken hinweg endet die Einschätzung NUR DANN mit GENAU EINER wichtigsten, machbaren Empfehlung ("eine Sache"), wenn das Gesamtbild eine nahelegt. Ist alles stabil und gibt es nichts Sinnvolles zu tun, sage das ehrlich und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt eine Empfehlung zu erzwingen.`;
const GENERAL_SECTION_EN = `METRIC — OVERALL ASSESSMENT:
- The snapshot carries measurementSeries (a map per metric type with summary + graded series), medicationAdherence (summary + series), bloodPressureTargets (when present) and moodContext (when ≥ 3 days). dataCoverage.avgDaysBetweenMeasurements shows the measurement density.
@@ -17,7 +17,7 @@ const GENERAL_SECTION_EN = `METRIC — OVERALL ASSESSMENT:
- Same logic per metric: place the recent mean against the person's weekly/monthly mean and report only significant deviations from their own baseline.
- Acknowledge the positive explicitly — a stable or improving value is a real message, not filler.
- Mention associations only when an r-value is present in the snapshot and |r| > 0.4 — as an association, never a cause. Include mood as context, do not over-weight it.
- One message: even across all metrics, close with EXACTLY ONE most-important, doable suggestion ("one thing").`;
- One message: even across all metrics, close with EXACTLY ONE most-important, doable suggestion ("one thing") ONLY when the overall picture implies one. When everything is steady and there is nothing useful to do, say so honestly and name one thing worth keeping an eye on instead of forcing a recommendation.`;
export function getGeneralStatusSystemPrompt(locale: Locale): string {
const section = locale === "en" ? GENERAL_SECTION_EN : GENERAL_SECTION_DE;
@@ -31,6 +31,8 @@ export function getGeneralStatusUserPrompt(
todayKey: string,
locale: Locale,
previousContextBlock?: string,
/** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */
assessmentContextBlock?: string,
): string {
// v1.4: when the previous-analysis context block is supplied, the
// model is instructed to call out improvements / regressions
@@ -40,14 +42,18 @@ export function getGeneralStatusUserPrompt(
previousContextBlock && previousContextBlock.trim().length > 0
? `\n\n${previousContextBlock}\n`
: "";
const extraBlock =
assessmentContextBlock && assessmentContextBlock.trim().length > 0
? `\n\n${assessmentContextBlock}\n`
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Write one short overall assessment across the available health metrics: name what stands out, place the recent days against the person's own weekly/monthly baseline, and close with the single most important doable step. Judge confidence from the measurement count, density and recency.${ctxBlock}
Write one short overall assessment across the available health metrics: name what stands out, place the recent days against the person's own weekly/monthly baseline, and — when something is genuinely actionable — close with the single most important doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count, density and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Schreibe eine kurze Gesamteinschätzung über die verfügbaren Gesundheitsdaten: benenne das Auffälligste, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit dem einen wichtigsten machbaren Schritt. Konfidenz aus Messanzahl, Dichte und Aktualität ableiten.${ctxBlock}
Schreibe eine kurze Gesamteinschätzung über die verfügbaren Gesundheitsdaten: benenne das Auffälligste, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit dem einen wichtigsten machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl, Dichte und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
+16 -16
View File
@@ -133,14 +133,14 @@ gib die obige Verweigerung zurück.`,
language. Reference the user's actual data trends — do not paste
in placeholder text. The rationale.dataWindow MUST equal the
metricSource.timeRange so the UI's mini-chart can pin to it.
6. v1.4.16 phase B8 — when the user prompt contains a "SYSTEM
6. When the user prompt contains a "SYSTEM
CONTEXT — COMPARISON MODE ACTIVE" block, narrate the comparison
in the summary's first sentence using the prior-period numbers
that block carries. Cite the deltas verbatim — do NOT invent
comparison numbers, and do NOT extrapolate beyond the metrics
listed. When the block reports "no prior-period data available"
for every metric, state that explicitly and skip the narration.
7. v1.4.19 — Do NOT open with a compliment about the data quantity
7. Do NOT open with a compliment about the data quantity
or data quality. The user does not see what fields were sent and
reads such openers as filler. Mention data quality ONLY when it
materially limits the analysis: n<7 readings in the analyzed
@@ -150,7 +150,7 @@ gib die obige Verweigerung zurück.`,
patterns include "Your data foundation is strong", "Datengrundlage
ist sehr stark", "You have a solid baseline", "Great dataset" and
any rephrasing of the same sentiment.
8. v1.4.20 — Optional "dailyBriefing" block. When the snapshot
8. Optional "dailyBriefing" block. When the snapshot
carries enough signal (any of bp / weight / pulse / mood /
medications.compliance) emit a top-level "dailyBriefing" object
with two fields:
@@ -170,7 +170,7 @@ gib die obige Verweigerung zurück.`,
When the snapshot has no analysable data, omit "dailyBriefing"
or set it to null. Empty paragraph or filler-only findings are
rejected by the parser.
9. v1.4.20 phase B3 — Optional "trendAnnotations" block. When the
9. Optional "trendAnnotations" block. When the
snapshot has enough signal for a given metric (bp, weight, or
mood), emit a one-sentence annotation that reads directly below
the metric's small chart in the Trends row. Each annotation MUST:
@@ -185,7 +185,7 @@ gib die obige Verweigerung zurück.`,
the entire block (or set null) when no metric qualifies. Trend
annotations are SHORT — they sit below charts and compete for
attention with the chart itself; one tight sentence is the goal.
10. v1.4.20 phase B4 — Optional "storyboardAnnotations" array (max 20).
10. Optional "storyboardAnnotations" array (max 20).
When the 90-day BP timeline includes notable factual events the
user logged, emit annotations that pin to specific dates. Each
annotation MUST:
@@ -205,7 +205,7 @@ gib die obige Verweigerung zurück.`,
Date format: "YYYY-MM-DD". Label cap: 80 chars. Detail cap:
400 chars. Hard array cap: 20. Omit the field entirely when the
timeline has no notable events.
11. v1.4.23 — Optional Apple Health metric categories. When the
11. Optional Apple Health metric categories. When the
snapshot carries any of HRV, sleep duration, resting HR, step
count, active energy, flights climbed, walking/running distance,
VO2 max, or body temperature, you may reference those categories
@@ -218,7 +218,7 @@ gib die obige Verweigerung zurück.`,
the user connect a wearable. The presence or absence of the
HealthKit metric block in the snapshot is the only signal you
should act on.
12. v1.4.25 — Internal metric identifiers stay OUT of your prose.
12. Internal metric identifiers stay OUT of your prose.
Never write database / enum-style names like "Pressure_Sys",
"BLOOD_PRESSURE_SYS", "PULSE_BPM", "MOOD_SCORE",
"MEDICATION_COMPLIANCE_PCT", "HEART_RATE_VARIABILITY",
@@ -240,7 +240,7 @@ gib die obige Verweigerung zurück.`,
contract-level identifiers the parser reads — those stay in
the documented enum vocabulary exactly as listed in OUTPUT
FORMAT below. The ban applies ONLY to prose.
13. v1.4.25 W4d — NEVER prescribe or modify medication doses, even
13. NEVER prescribe or modify medication doses, even
when the snapshot reveals a named GLP-1 receptor agonist
(Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda,
Rybelsus). Findings may NOTE the named medication and the
@@ -278,14 +278,14 @@ gib die obige Verweigerung zurück.`,
Platzhaltertext. rationale.dataWindow MUSS gleich
metricSource.timeRange sein, damit die UI das Mini-Chart auf das
gleiche Fenster fixieren kann.
6. v1.4.16 phase B8 — wenn der User-Prompt einen Block "SYSTEM
6. Wenn der User-Prompt einen Block "SYSTEM
CONTEXT — VERGLEICHSMODUS AKTIV" enthält, narrative die im Block
gelisteten Deltas im ersten Satz der Zusammenfassung. Zitiere die
Werte exakt — erfinde KEINE Vergleichszahlen und extrapoliere
nicht über die gelisteten Metriken hinaus. Wenn der Block für
alle Metriken "no prior-period data available" meldet, sag das
explizit und lass die Narration weg.
7. v1.4.19 — Beginne NICHT mit einem Kompliment über Datenmenge
7. Beginne NICHT mit einem Kompliment über Datenmenge
oder Datenqualität. Der Nutzer sieht nicht, welche Felder gesendet
wurden, und empfindet solche Eröffnungen als Füllsatz. Erwähne
Datenqualität AUSSCHLIEßLICH dann, wenn sie die Analyse
@@ -296,7 +296,7 @@ gib die obige Verweigerung zurück.`,
Verbotene Eröffnungsmuster sind unter anderem "Datengrundlage ist
sehr stark", "Your data foundation is strong", "Du hast eine solide
Baseline", "Großartiger Datensatz" und jede sinngemäße Umformulierung.
8. v1.4.20 — Optionaler "dailyBriefing"-Block. Wenn der Snapshot
8. Optionaler "dailyBriefing"-Block. Wenn der Snapshot
genügend Signal trägt (irgendwas aus bp / weight / pulse / mood /
medications.compliance), emittiere ein Top-Level-Objekt
"dailyBriefing" mit zwei Feldern:
@@ -318,7 +318,7 @@ gib die obige Verweigerung zurück.`,
Hat der Snapshot keine analysierbaren Daten, lass "dailyBriefing"
weg oder setze es auf null. Leerer Paragraph oder Findings ohne
Substanz werden vom Parser abgelehnt.
9. v1.4.20 phase B3 — Optionaler "trendAnnotations"-Block. Wenn der
9. Optionaler "trendAnnotations"-Block. Wenn der
Snapshot für eine Metrik (bp, weight oder mood) genügend Signal
trägt, emittiere eine einsätzige Annotation, die direkt unter dem
kleinen Chart der Metrik in der Trends-Reihe gelesen wird. Jede
@@ -336,7 +336,7 @@ gib die obige Verweigerung zurück.`,
Metrik qualifiziert. Trend-Annotationen sind KURZ — sie stehen unter
Charts und konkurrieren mit dem Chart selbst um Aufmerksamkeit; ein
prägnanter Satz ist das Ziel.
10. v1.4.20 phase B4 — Optionales "storyboardAnnotations"-Array
10. Optionales "storyboardAnnotations"-Array
(max 20). Wenn die 90-Tage-BP-Timeline bemerkenswerte vom Nutzer
geloggte Ereignisse enthält, emittiere Annotationen, die auf
konkrete Daten zeigen. Jede Annotation MUSS:
@@ -358,7 +358,7 @@ gib die obige Verweigerung zurück.`,
Datumsformat: "YYYY-MM-DD". Label-Cap: 80 Zeichen. Detail-Cap:
400 Zeichen. Array-Höchstgrenze: 20. Lass das Feld komplett
weg, wenn die Timeline keine bemerkenswerten Ereignisse hat.
11. v1.4.23 — Optionale Apple-Health-Metrik-Kategorien. Wenn der
11. Optionale Apple-Health-Metrik-Kategorien. Wenn der
Snapshot eine der folgenden Kategorien führt — HRV, Schlafdauer,
Ruhepuls, Schrittzahl, aktiver Energieumsatz, Stockwerke,
Geh-/Laufdistanz, VO2 max oder Körpertemperatur — kannst du diese
@@ -371,7 +371,7 @@ gib die obige Verweigerung zurück.`,
HealthKit, schlage nicht vor, ein Wearable zu verbinden. Das
Vorhandensein oder Fehlen des HealthKit-Metrik-Blocks im Snapshot
ist das einzige Signal, auf das du reagieren solltest.
12. v1.4.25 — Interne Metrik-Identifier gehören NICHT in deinen
12. Interne Metrik-Identifier gehören NICHT in deinen
Fließtext. Schreibe niemals Datenbank- bzw. Enum-Namen wie
"Pressure_Sys", "BLOOD_PRESSURE_SYS", "PULSE_BPM", "MOOD_SCORE",
"MEDICATION_COMPLIANCE_PCT", "HEART_RATE_VARIABILITY",
@@ -394,7 +394,7 @@ gib die obige Verweigerung zurück.`,
Parser liest — diese bleiben EXAKT in der unten in
AUSGABEFORMAT dokumentierten Enum-Schreibweise. Das Verbot
gilt AUSSCHLIEßLICH für Fließtext.
13. v1.4.25 W4d — Du verschreibst und änderst NIEMALS
13. Du verschreibst und änderst NIEMALS
Medikamenten-Dosen, auch wenn der Snapshot einen
GLP-1-Rezeptoragonisten namentlich benennt (Mounjaro, Ozempic,
Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). Befunde dürfen
+10 -4
View File
@@ -8,7 +8,7 @@ const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE:
- Einordnung (zur Orientierung, nicht als Urteil): ≥ 90 % sehr verlässlich, 80-89 % gut, 70-79 % lückenhaft, < 70 % deutlich lückenhaft — die therapeutische Wirkung kann dann eingeschränkt sein.
- Positiv rahmen: streak ist die längste ununterbrochene Serie — bei guter Treue ausdrücklich anerkennen. Bei mehreren Medikamenten das mit der niedrigsten Treue benennen.
- Zusammenhänge zu Vitalwerten nur erwähnen, wenn ein r-Wert im Snapshot vorhanden und |r| > 0.4 ist — als Zusammenhang, nie als Ursache. Keine Wirkung erfinden, wenn die Daten fehlen.
- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Lücke passt (z.B. Abenddosen werden häufiger vergessen — eine feste Routine oder ein Reminder zur kritischen Uhrzeit kann helfen). Keine Schuldzuweisung.`;
- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. Abenddosen werden häufiger vergessen — eine feste Routine oder ein Reminder zur kritischen Uhrzeit kann helfen). Bei durchgehend hoher Treue das ehrlich anerkennen, statt einen Schritt zu erzwingen. Keine Schuldzuweisung.`;
const MEDCO_SECTION_EN = `METRIC — MEDICATION ADHERENCE:
- The snapshot carries overall (medicationCount, averageCompliance7, averageCompliance30) and medications[] per medication with name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, dailySeries (graded) and latestDay.
@@ -17,7 +17,7 @@ const MEDCO_SECTION_EN = `METRIC — MEDICATION ADHERENCE:
- Placement (for orientation, not judgement): ≥ 90% very reliable, 80-89% good, 70-79% patchy, < 70% clearly patchy — therapeutic effect may then be reduced.
- Frame positively: streak is the longest uninterrupted run — acknowledge a good streak explicitly. With several medications, name the one with the lowest adherence.
- Mention links to vital signs only when an r-value is present in the snapshot and |r| > 0.4 — as an association, never a cause. Do not invent an effect when the data is absent.
- One message: close with ONE doable step that fits the gap (e.g. evening doses are missed more often — a fixed routine or a reminder at the critical time can help). No blame.`;
- One message: close with ONE doable step that fits the gap ONLY when the finding implies one (e.g. evening doses are missed more often — a fixed routine or a reminder at the critical time can help). When adherence is consistently high, acknowledge that honestly rather than manufacturing a step. No blame.`;
export function getMedicationComplianceSystemPrompt(locale: Locale): string {
const section = locale === "en" ? MEDCO_SECTION_EN : MEDCO_SECTION_DE;
@@ -31,19 +31,25 @@ export function getMedicationComplianceUserPrompt(
todayKey: string,
locale: Locale,
previousContextBlock?: string,
/** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */
assessmentContextBlock?: string,
): string {
const ctxBlock =
previousContextBlock && previousContextBlock.trim().length > 0
? `\n\n${previousContextBlock}\n`
: "";
const extraBlock =
assessmentContextBlock && assessmentContextBlock.trim().length > 0
? `\n\n${assessmentContextBlock}\n`
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Write one short assessment of this person's medication adherence: name the recent rate, place it against their own baseline (compliance7 vs compliance30), and close with one doable, blame-free step. Judge confidence from the event count and recency.${ctxBlock}
Write one short assessment of this person's medication adherence: name the recent rate, place it against their own baseline (compliance7 vs compliance30), and — when something is genuinely actionable — close with one doable, blame-free step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the event count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Schreibe eine kurze Einschätzung zur Einnahmetreue dieser Person: benenne die jüngste Rate, ordne sie gegen die eigene Baseline ein (compliance7 vs. compliance30) und schließe mit einem machbaren, wertfreien Schritt. Konfidenz aus Ereignisanzahl und Aktualität ableiten.${ctxBlock}
Schreibe eine kurze Einschätzung zur Einnahmetreue dieser Person: benenne die jüngste Rate, ordne sie gegen die eigene Baseline ein (compliance7 vs. compliance30) und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren, wertfreien Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Ereignisanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
+6 -6
View File
@@ -87,12 +87,12 @@ const ARCHETYPE_SECTION: Record<
de: "ARCHETYP — PHYSIOLOGISCHER VITALWERT:\n- Das ist ein Momentanwert. Benenne das aktuelle Niveau und ordne es sowohl gegen die eigene Baseline (recent vs. Monats-/Jahresmittel) als auch — falls ein Referenzband vorliegt — gegen dieses Band ein.\n- Ein einzelner Wert außerhalb des Bands ist selten ein Befund; eine anhaltende Verschiebung über das recent-Fenster gegen die Baseline schon. Erwähne einen Trend außerhalb des Bands sachlich, ohne zu alarmieren.\n- Formuliere jede Richtung (steigend/fallend) als mögliche Erklärung (Fitness, Stress, Schlaf, Krankheit, Medikament), nie als Diagnose.",
},
"activity-fitness": {
en: "ARCHETYPE — ACTIVITY / FITNESS:\n- This is a daily activity or fitness measure, mostly higher-better. Read the recent daily mean and compare it to the user's own weekly/monthly baseline — is their activity up, down, or steady?\n- Day-to-day swings are expected; report the multi-day trend, not a single low or high day. When a reference band is given, use it as a coarse target, not a verdict.\n- Close with one doable, specific step toward more (or sustained) movement — never the generic 'exercise more' platitude.",
de: "ARCHETYP — AKTIVITÄT / FITNESS:\n- Das ist ein tägliches Aktivitäts- oder Fitnessmaß, meist höher = besser. Lies das recent-Tagesmittel und vergleiche es mit der eigenen Wochen-/Monats-Baseline — ist die Aktivität gestiegen, gesunken oder stabil?\n- Tagesschwankungen sind normal; melde den Mehrtagestrend, nicht einen einzelnen niedrigen oder hohen Tag. Ein Referenzband ist ein grobes Ziel, kein Urteil.\n- Schließe mit einem machbaren, konkreten Schritt für mehr (oder gleichbleibende) Bewegung — nie die generische Floskel 'mehr Bewegung'.",
en: "ARCHETYPE — ACTIVITY / FITNESS:\n- This is a daily activity or fitness measure, mostly higher-better. Read the recent daily mean and compare it to the user's own weekly/monthly baseline — is their activity up, down, or steady?\n- Day-to-day swings are expected; report the multi-day trend, not a single low or high day. When a reference band is given, use it as a coarse target, not a verdict.\n- Close with one doable, specific step toward more (or sustained) movement ONLY when the finding implies one — never the generic 'exercise more' platitude. When activity is steady and already strong, affirm it and name one thing worth keeping an eye on instead of manufacturing a step.",
de: "ARCHETYP — AKTIVITÄT / FITNESS:\n- Das ist ein tägliches Aktivitäts- oder Fitnessmaß, meist höher = besser. Lies das recent-Tagesmittel und vergleiche es mit der eigenen Wochen-/Monats-Baseline — ist die Aktivität gestiegen, gesunken oder stabil?\n- Tagesschwankungen sind normal; melde den Mehrtagestrend, nicht einen einzelnen niedrigen oder hohen Tag. Ein Referenzband ist ein grobes Ziel, kein Urteil.\n- Schließe NUR DANN mit einem machbaren, konkreten Schritt für mehr (oder gleichbleibende) Bewegung, wenn der Befund einen nahelegt — nie die generische Floskel 'mehr Bewegung'. Ist die Aktivität stabil und bereits gut, erkenne das an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.",
},
"body-composition": {
en: "ARCHETYPE — BODY COMPOSITION:\n- This is a body-composition mass (or rating). A healthy absolute value depends entirely on body size, so judge the TREND toward or away from the user's own baseline, not an absolute number.\n- Report a sustained directional change across weeks/months; single readings carry scale + hydration noise. Tie it to weight context only if the snapshot supports it.\n- Close with one step that supports the favourable direction for THIS metric.",
de: "ARCHETYP — KÖRPERZUSAMMENSETZUNG:\n- Das ist eine Körperzusammensetzungs-Masse (oder Bewertung). Ein gesunder Absolutwert hängt stark von der Körpergröße ab — beurteile den TREND zur bzw. weg von der eigenen Baseline, nicht eine Absolutzahl.\n- Melde eine anhaltende Richtungsänderung über Wochen/Monate; Einzelwerte tragen Waagen- und Hydrationsrauschen. Stelle einen Gewichtsbezug nur her, wenn der Snapshot ihn stützt.\n- Schließe mit einem Schritt, der die für DIESE Metrik günstige Richtung unterstützt.",
en: "ARCHETYPE — BODY COMPOSITION:\n- This is a body-composition mass (or rating). A healthy absolute value depends entirely on body size, so judge the TREND toward or away from the user's own baseline, not an absolute number.\n- Report a sustained directional change across weeks/months; single readings carry scale + hydration noise. Tie it to weight context only if the snapshot supports it.\n- Close with one step that supports the favourable direction for THIS metric ONLY when the finding implies one. When the trend is steady and favourable, affirm it and name one thing worth keeping an eye on instead of manufacturing a step.",
de: "ARCHETYP — KÖRPERZUSAMMENSETZUNG:\n- Das ist eine Körperzusammensetzungs-Masse (oder Bewertung). Ein gesunder Absolutwert hängt stark von der Körpergröße ab — beurteile den TREND zur bzw. weg von der eigenen Baseline, nicht eine Absolutzahl.\n- Melde eine anhaltende Richtungsänderung über Wochen/Monate; Einzelwerte tragen Waagen- und Hydrationsrauschen. Stelle einen Gewichtsbezug nur her, wenn der Snapshot ihn stützt.\n- Schließe NUR DANN mit einem Schritt, der die für DIESE Metrik günstige Richtung unterstützt, wenn der Befund einen nahelegt. Ist der Trend stabil und günstig, erkenne das an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.",
},
"mobility-gait": {
en: "ARCHETYPE — MOBILITY / GAIT:\n- This is a gait/mobility-stability signal Apple surfaces in its Mobility section. Its value is as a stability + anomaly flag: a steady reading near the user's baseline is reassuring; a sustained drift in the unfavourable direction is the signal to surface.\n- These metrics move slowly; one off day is noise. Only flag a multi-week drift, and frame it as a mobility observation, never a fall-risk diagnosis.\n- If steady, say so plainly. If drifting unfavourably, give one gentle, doable step.",
@@ -103,8 +103,8 @@ const ARCHETYPE_SECTION: Record<
de: "ARCHETYP — UMWELT-EXPOSITION:\n- Das ist eine Umwelt-Exposition (Schallpegel / Anzahl lauter Ereignisse), niedriger = besser. Das Referenzband ist ein Schwellenwert (z.B. WHO 80 dBA) — recent-Werte, die ihn über mehrere Tage überschreiten, sind der erwähnenswerte Befund.\n- Ein einzelnes lautes Konzert oder ein Flug ist kein Muster. Melde wiederholte Überschreitungen, nicht einen Einzelausschlag.\n- Schließe mit einem praktischen Schritt zur Reduktion (z.B. Hörlautstärke senken, eine ruhige Pause) — schützend formuliert, nie alarmierend.",
},
sleep: {
en: "ARCHETYPE — SLEEP:\n- This is nightly sleep duration. The snapshot's values are in minutes; convert to hours for your prose (e.g. 450 min = 7.5 h). Assess both the typical duration and its CONSISTENCY: a stable ~7–9 h is the favourable pattern; high night-to-night variability is itself worth naming even when the mean looks fine.\n- Compare the recent nights to the user's own weekly/monthly baseline. A reference band of 7–9 h is a coarse adult guide, not a verdict — short or long sleepers vary.\n- Close with one doable step toward more consistent or sufficient sleep — never the forbidden 'get enough sleep' platitude; be specific (e.g. a steadier wind-down time).",
de: "ARCHETYP — SCHLAF:\n- Das ist die nächtliche Schlafdauer. Die Snapshot-Werte sind in Minuten; rechne für deinen Text in Stunden um (z.B. 450 min = 7,5 h). Bewerte sowohl die typische Dauer als auch ihre KONSTANZ: stabile ~7–9 h sind das günstige Muster; hohe Schwankung von Nacht zu Nacht ist selbst dann erwähnenswert, wenn der Mittelwert passt.\n- Vergleiche die jüngsten Nächte mit der eigenen Wochen-/Monats-Baseline. Ein Referenzband von 7–9 h ist eine grobe Erwachsenen-Orientierung, kein Urteil — Kurz- und Langschläfer variieren.\n- Schließe mit einem machbaren Schritt zu konstanterem oder ausreichenderem Schlaf — nie die verbotene Floskel 'achte auf ausreichend Schlaf'; sei konkret (z.B. eine festere Einschlafzeit).",
en: "ARCHETYPE — SLEEP:\n- This is nightly sleep duration. The snapshot's values are in minutes; convert to hours for your prose (e.g. 450 min = 7.5 h). Assess both the typical duration and its CONSISTENCY: a stable ~7–9 h is the favourable pattern; high night-to-night variability is itself worth naming even when the mean looks fine.\n- Compare the recent nights to the user's own weekly/monthly baseline. A reference band of 7–9 h is a coarse adult guide, not a verdict — short or long sleepers vary.\n- Close with one doable step toward more consistent or sufficient sleep ONLY when the finding implies one — never the forbidden 'get enough sleep' platitude; be specific (e.g. a steadier wind-down time). When sleep is already consistent and sufficient, affirm it and name one thing worth keeping an eye on instead of manufacturing a step.",
de: "ARCHETYP — SCHLAF:\n- Das ist die nächtliche Schlafdauer. Die Snapshot-Werte sind in Minuten; rechne für deinen Text in Stunden um (z.B. 450 min = 7,5 h). Bewerte sowohl die typische Dauer als auch ihre KONSTANZ: stabile ~7–9 h sind das günstige Muster; hohe Schwankung von Nacht zu Nacht ist selbst dann erwähnenswert, wenn der Mittelwert passt.\n- Vergleiche die jüngsten Nächte mit der eigenen Wochen-/Monats-Baseline. Ein Referenzband von 7–9 h ist eine grobe Erwachsenen-Orientierung, kein Urteil — Kurz- und Langschläfer variieren.\n- Schließe NUR DANN mit einem machbaren Schritt zu konstanterem oder ausreichenderem Schlaf, wenn der Befund einen nahelegt — nie die verbotene Floskel 'achte auf ausreichend Schlaf'; sei konkret (z.B. eine festere Einschlafzeit). Ist der Schlaf bereits konstant und ausreichend, erkenne das an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.",
},
};
+10 -4
View File
@@ -8,7 +8,7 @@ const MOOD_SECTION_DE = `METRIK — STIMMUNG / WOHLBEFINDEN:
- Bei anhaltend sehr niedriger Stimmung (mehrere Tage deutlich unter ~2.5): biete ruhig und autonomie-wahrend einen Unterstützungs-Pfad an — etwa "bei anhaltender Belastung kann ein Gespräch mit einer Vertrauensperson oder einer Fachperson helfen". Nie alarmierend, nie als Diagnose, kein Notfall-Ton; eine sanfte Option, kein Imperativ.
- Tags: Falls mood.tags vorhanden, kannst du ein wiederkehrendes Tag als möglichen Kontext nennen, ohne einen Zusammenhang zu behaupten.
- Cross-Metrik: crossMetricContext trägt Korrelationen zu Gewicht, Blutdruck und Puls; nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache. Erzwinge keinen Querverweis ohne klares Muster.
- Eine Botschaft: Schließe mit EINEM machbaren, freundlichen Schritt (z.B. an guten Tagen kurz festhalten, was geholfen hat). Bei stabil guter Stimmung das ehrlich anerkennen.`;
- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren, freundlichen Schritt, wenn der Befund einen nahelegt (z.B. an guten Tagen kurz festhalten, was geholfen hat). Bei stabil guter Stimmung das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
const MOOD_SECTION_EN = `METRIC — MOOD / WELL-BEING:
- Scale 1 (very bad) to 5 (very good). The snapshot carries mood.summary + mood.series (graded daily means), mood.target (green/orange band), mood.latestDayFocus and, where present, mood.tags (most frequent mood tags).
@@ -17,7 +17,7 @@ const MOOD_SECTION_EN = `METRIC — MOOD / WELL-BEING:
- When mood stays very low for several days (clearly below ~2.5), offer a support pathway calmly and in an autonomy-preserving way — e.g. "if the strain persists, talking it through with someone you trust or a professional can help". Never alarming, never a diagnosis, no emergency tone; a gentle option, not an imperative.
- Tags: if mood.tags is present, you may name a recurring tag as possible context, without claiming a link.
- Cross-metric: crossMetricContext carries correlations to weight, blood pressure and pulse; mention only when present and |r| > 0.4 — as an association, never a cause. Do not force a cross-link without a clear pattern.
- One message: close with ONE doable, kind step (e.g. on good days, briefly note what helped). When mood is steadily good, acknowledge that honestly.`;
- One message: close with ONE doable, kind step ONLY when the finding implies one (e.g. on good days, briefly note what helped). When mood is steadily good, acknowledge that honestly and name one thing worth keeping an eye on instead of manufacturing a step.`;
export function getMoodSystemPrompt(locale: Locale): string {
const section = locale === "en" ? MOOD_SECTION_EN : MOOD_SECTION_DE;
@@ -31,19 +31,25 @@ export function getMoodUserPrompt(
todayKey: string,
locale: Locale,
previousContextBlock?: string,
/** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */
assessmentContextBlock?: string,
): string {
const ctxBlock =
previousContextBlock && previousContextBlock.trim().length > 0
? `\n\n${previousContextBlock}\n`
: "";
const extraBlock =
assessmentContextBlock && assessmentContextBlock.trim().length > 0
? `\n\n${assessmentContextBlock}\n`
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Write one short assessment of this person's mood: name the recent level, place it against their own weekly/monthly baseline, and close with one kind, doable step. Judge confidence from the entry count and recency.${ctxBlock}
Write one short assessment of this person's mood: name the recent level, place it against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one kind, doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the entry count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Schreibe eine kurze Einschätzung zur Stimmung dieser Person: benenne das jüngste Niveau, ordne es gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem freundlichen, machbaren Schritt. Konfidenz aus Eintragsanzahl und Aktualität ableiten.${ctxBlock}
Schreibe eine kurze Einschätzung zur Stimmung dieser Person: benenne das jüngste Niveau, ordne es gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem freundlichen, machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Eintragsanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
+10 -4
View File
@@ -7,7 +7,7 @@ const PULSE_SECTION_DE = `METRIK — PULS / HERZFREQUENZ:
- Richtung: Ein über Wochen sinkender Ruhepuls deutet auf bessere kardiovaskuläre Fitness; ein steigender kann auf Stress, Schlafmangel oder Übertraining hinweisen — als mögliche Erklärung, nie als Diagnose.
- Einordnungs-Anker (Ruhepuls): athletisch 50-60, normal 60-100. Werte im Band 80-100 sind technisch normal, langfristig aber günstiger niedriger. < 50 oder > 100 über mehrere Tage ist erwähnenswert, ohne zu alarmieren.
- Medikamenten-Bezug: Betablocker senken die Herzfrequenz. moodContext.moodVsPulseCorrelation und ein pulseVsSystolic-Zusammenhang nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Eine Botschaft: Liegt der Ruhepuls über der eigenen Baseline, schließe mit EINEM machbaren Schritt (z.B. ein paar Tage morgens vor dem Aufstehen messen, um eine saubere Ruhe-Baseline zu bekommen). Verbessert er sich, erkenne das ehrlich an.`;
- Eine Botschaft: Liegt der Ruhepuls über der eigenen Baseline, schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. ein paar Tage morgens vor dem Aufstehen messen, um eine saubere Ruhe-Baseline zu bekommen). Verbessert er sich oder ist er stabil, erkenne das ehrlich an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
const PULSE_SECTION_EN = `METRIC — PULSE / HEART RATE:
- The snapshot carries pulse.summary + pulse.series (graded) and pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus shows the latest daily value and the step from the prior day.
@@ -15,7 +15,7 @@ const PULSE_SECTION_EN = `METRIC — PULSE / HEART RATE:
- Direction: a resting pulse falling over weeks suggests improving cardiovascular fitness; a rising one can point to stress, sleep loss or overtraining — as a possible explanation, never a diagnosis.
- Placement anchors (resting pulse): athletic 50-60, normal 60-100. Values in the 80-100 band are technically normal but tend to be more favourable lower over the long run. < 50 or > 100 across several days is worth noting without alarm.
- Medication link: beta-blockers lower heart rate. Mention moodContext.moodVsPulseCorrelation and any pulse-vs-systolic association only when present and |r| > 0.4 — as an association, never a cause.
- One message: if the resting pulse sits above the person's baseline, close with ONE doable step (e.g. take readings on waking for a few days to get a clean resting baseline). If it is improving, acknowledge that honestly.`;
- One message: if the resting pulse sits above the person's baseline, close with ONE doable step ONLY when the finding implies one (e.g. take readings on waking for a few days to get a clean resting baseline). If it is improving or steady, acknowledge that honestly and name one thing worth keeping an eye on instead of manufacturing a step.`;
export function getPulseSystemPrompt(locale: Locale): string {
const section = locale === "en" ? PULSE_SECTION_EN : PULSE_SECTION_DE;
@@ -29,19 +29,25 @@ export function getPulseUserPrompt(
todayKey: string,
locale: Locale,
previousContextBlock?: string,
/** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */
assessmentContextBlock?: string,
): string {
const ctxBlock =
previousContextBlock && previousContextBlock.trim().length > 0
? `\n\n${previousContextBlock}\n`
: "";
const extraBlock =
assessmentContextBlock && assessmentContextBlock.trim().length > 0
? `\n\n${assessmentContextBlock}\n`
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Write one short assessment of this person's resting pulse: name the current level, place the recent days against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
Write one short assessment of this person's resting pulse: name the current level, place the recent days against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Schreibe eine kurze Einschätzung zum Ruhepuls dieser Person: benenne das aktuelle Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
Schreibe eine kurze Einschätzung zum Ruhepuls dieser Person: benenne das aktuelle Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
+10 -4
View File
@@ -10,7 +10,7 @@ const WEIGHT_SECTION_DE = `METRIK — GEWICHT:
- Meilensteine gegen das eigene Maximum: ~5 % Verlust vom höchsten Wert (series-Max) bringt bereits metabolischen Nutzen, ~10 % einen deutlichen — wenn erreicht, ausdrücklich anerkennen.
- BMI-Bezug nur als knapper Nebensatz, wenn die Größe im Profil vorliegt (sonst nicht behaupten); die WHO-Band-Einordnung selbst gehört auf die BMI-Karte, nicht hierher.
- Zusammenhänge: weightVsSystolic.correlation / weightVsMeanBloodPressure.correlation und moodContext.moodVsWeightCorrelation nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Richtung passt (z.B. bei einem Plateau zur selben Tageszeit wiegen, um den echten Trend zu sehen, statt am Tagesrauschen zu hängen).`;
- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. bei einem Plateau zur selben Tageszeit wiegen, um den echten Trend zu sehen, statt am Tagesrauschen zu hängen). Ist der Trend stabil und gibt es nichts Sinnvolles zu tun, erkenne das ehrlich an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
const WEIGHT_SECTION_EN = `METRIC — WEIGHT:
- The snapshot carries weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure.
@@ -21,7 +21,7 @@ const WEIGHT_SECTION_EN = `METRIC — WEIGHT:
- Milestones against their own maximum: ~5% loss from the highest value (series max) already brings metabolic benefit, ~10% a substantial one — acknowledge explicitly when reached.
- BMI reference only as a brief aside when height is in the profile (do not claim it otherwise); the WHO-band placement itself belongs on the BMI card, not here.
- Associations: mention weightVsSystolic.correlation / weightVsMeanBloodPressure.correlation and moodContext.moodVsWeightCorrelation only when present and |r| > 0.4 — as an association, never a cause.
- One message: close with ONE doable step that fits the direction (e.g. on a plateau, weigh at the same time of day to see the real trend rather than the daily noise).`;
- One message: close with ONE doable step that fits the direction ONLY when the finding implies one (e.g. on a plateau, weigh at the same time of day to see the real trend rather than the daily noise). When the trend is steady and there is nothing useful to do, affirm it honestly and name one thing worth keeping an eye on instead of manufacturing a step.`;
export function getWeightSystemPrompt(locale: Locale): string {
const section = locale === "en" ? WEIGHT_SECTION_EN : WEIGHT_SECTION_DE;
@@ -35,19 +35,25 @@ export function getWeightUserPrompt(
todayKey: string,
locale: Locale,
previousContextBlock?: string,
/** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */
assessmentContextBlock?: string,
): string {
const ctxBlock =
previousContextBlock && previousContextBlock.trim().length > 0
? `\n\n${previousContextBlock}\n`
: "";
const extraBlock =
assessmentContextBlock && assessmentContextBlock.trim().length > 0
? `\n\n${assessmentContextBlock}\n`
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Write one short assessment of this person's weight: name the current level and direction, place the recent days against their own weekly/monthly baseline as a continuous trend and pace (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers), and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
Write one short assessment of this person's weight: name the current level and direction, place the recent days against their own weekly/monthly baseline as a continuous trend and pace (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers), and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Schreibe eine kurze Einschätzung zum Gewicht dieser Person: benenne Niveau und Richtung, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline als kontinuierlichen Trend und Tempo ein (kg/Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt) und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
Schreibe eine kurze Einschätzung zum Gewicht dieser Person: benenne Niveau und Richtung, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline als kontinuierlichen Trend und Tempo ein (kg/Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt) und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
+6 -6
View File
@@ -70,14 +70,14 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
// to the admin key if the user hasn't supplied their own. The
// model-default mirrors the admin path for consistency so a saved
// user "OPENAI" without an explicit model still produces an
// OpenAIClient with `gpt-4o-mini`.
// OpenAIClient with the current full-size default `gpt-4o`.
// Belt-and-braces: ignore any persisted `aiBaseUrl`. The column is
// shared with LOCAL, so a stale LAN URL there would otherwise
// redirect the user's OpenAI key to a private host.
if (!row.aiOpenaiKeyEncrypted) return null;
return new OpenAIClient({
apiKey: decrypt(row.aiOpenaiKeyEncrypted),
model: row.aiModel ?? "gpt-4o-mini",
model: row.aiModel ?? "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
}
@@ -97,7 +97,7 @@ async function resolveAdminProvider(): Promise<AIProvider> {
if (settings?.adminAiKeyEncrypted) {
return new OpenAIClient({
apiKey: decrypt(settings.adminAiKeyEncrypted),
model: settings.adminAiModel ?? "gpt-4o-mini",
model: settings.adminAiModel ?? "gpt-4o",
baseUrl: settings.adminAiBaseUrl ?? "https://api.openai.com/v1",
});
}
@@ -339,7 +339,7 @@ async function resolveProviderForType(
if (!enc) return null;
return new OpenAIClient({
apiKey: decrypt(enc),
model: ctx.userRow?.aiModel ?? "gpt-4o-mini",
model: ctx.userRow?.aiModel ?? "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
}
@@ -348,7 +348,7 @@ async function resolveProviderForType(
if (!enc) return null;
return new AnthropicClient({
apiKey: decrypt(enc),
model: ctx.userRow?.aiModel ?? "claude-3-5-sonnet-latest",
model: ctx.userRow?.aiModel ?? "claude-sonnet-4-6",
});
}
case "local": {
@@ -501,7 +501,7 @@ export async function resolveProviderForTest(
if (userKey) {
return new OpenAIClient({
apiKey: userKey,
model: model || "gpt-4o-mini",
model: model || "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
}
@@ -785,6 +785,44 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
expect(result.taken).toBe(2);
expect(result.rate).toBe(100);
});
it("streak is computed in the USER's timezone, not the host's", () => {
// The timeline `slot.day` is minted in the user's IANA zone. The
// streak walk must key days in the SAME zone — the prior host-tz
// `getFullYear/getMonth/getDate` walk drifted off by a day whenever
// the server clock's zone differed from the user's. Use a far-east
// user zone (UTC+13) so the user-local day for a near-UTC-midnight
// intake clearly differs from the host/UTC day; a daily-taken stream
// must still yield an unbroken streak.
const tz = "Pacific/Auckland";
vi.setSystemTime(new Date("2025-06-10T11:00:00Z")); // ~23:00 NZST.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
},
];
// Taken near 08:00 Auckland (= ~20:00 UTC previous day) for each of
// the past 7 user-local days.
const events = Array.from({ length: 7 }, (_, i) => {
const sched = new Date(
new Date("2025-06-09T20:00:00Z").getTime() - i * DAY_MS,
);
return { scheduledFor: sched, takenAt: sched, skipped: false };
});
const result = calculateCompliance(events, schedules, 7, undefined, {
medicationContext: ctx({
timeZone: tz,
createdAt: new Date("2025-05-01T00:00:00Z"),
}),
});
// Every user-local day in the window was taken → a multi-day streak.
// The host-tz walk would have mis-keyed the boundary days and broken
// the streak early; the user-tz walk counts them all.
expect(result.streak).toBeGreaterThanOrEqual(6);
});
});
// ────────────────────────────────────────────────────────────────────
+19 -27
View File
@@ -25,6 +25,7 @@ import {
type IntakeEventLike,
type ScheduleLike,
} from "@/lib/medications/scheduling/cadence";
import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance";
import {
occurrencesBetween,
type CanonicalSchedule,
@@ -694,33 +695,24 @@ export function calculateCompliance(
// Streak: consecutive days, ending today, where every expected dose
// was taken or skipped. Days with no expected dose advance the
// streak — out-of-cadence days are not failures. Walks from today
// backwards through the timeline grouped by local day.
const dayKey = (d: Date): string => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const byDay = new Map<string, "all-good" | "bad">();
for (const slot of timeline) {
const key = dayKey(slot.day);
const existing = byDay.get(key);
if (slot.status === "missed") {
byDay.set(key, "bad");
} else if (existing !== "bad") {
byDay.set(key, "all-good");
}
}
let streak = 0;
for (let d = 0; d < effectiveDays; d++) {
const cursor = new Date(now.getTime() - d * DAY_MS);
if (medicationCreatedAt && cursor <= medicationCreatedAt) break;
const state = byDay.get(dayKey(cursor));
if (state === "bad") break;
if (state === "all-good") streak++;
// No state = no expected dose that day → streak advances silently.
}
// streak — out-of-cadence days are not failures. Delegated to the
// shared `streaksFromTimeline` so the analytics streak and the
// detail-page chip streak agree on every dose AND so the day keys are
// computed in the USER's IANA timezone, not the host's. The prior
// host-tz `getFullYear/getMonth/getDate` walk drifted off by a day
// whenever the server clock's zone differed from the user's — the
// timeline `slot.day` is already minted in the user zone, so the
// walk has to match it. The window is `effectiveDays` ending at
// `now`, which starts no earlier than `effectiveStart` (= the later
// of the period start and the medication's creation) — so days
// before the medication existed are never iterated, preserving the
// old `cursor <= medicationCreatedAt` break by construction.
const { current: streak } = streaksFromTimeline(
timeline,
now,
effectiveDays,
engineCtx?.timeZone,
);
return { totalExpected, taken, skipped, missed, rate, streak };
}
+12 -1
View File
@@ -70,6 +70,7 @@ import {
import {
isFullyCovered,
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import {
@@ -220,6 +221,16 @@ export interface SummariesSlice {
export async function computeSummariesSlice(
userId: string,
/**
* v1.12.7 A5 — optional pre-probed coverage map. The dashboard
* snapshot already calls `probeRollupCoverage` up front to decide
* whether to run the thick `extras` phase, then called this slice
* which re-probed the identical query. Threading the map in lets the
* slice reuse it and saves one round-trip on the snapshot's hot path.
* Omit it (every other caller) to keep the internal probe as the
* fallback — byte-stable for those callers.
*/
precomputedCoverage?: RollupCoverageMap,
): Promise<SummariesSlice> {
// v1.4.37.1 hotfix — fire-and-forget. See `src/app/api/analytics/route.ts`
// for the full rationale: awaiting this on the read path can stall
@@ -242,7 +253,7 @@ export async function computeSummariesSlice(
// only take the rollup path when EVERY type the user has logged is
// covered. Partial coverage falls back to the live aggregate so the
// brand-new-type case stays correct.
const coverage = await probeRollupCoverage(userId);
const coverage = precomputedCoverage ?? (await probeRollupCoverage(userId));
if (isFullyCovered(coverage)) {
return withSleepNightTotals(userId, await computeFromRollups(userId));
}
+19 -9
View File
@@ -22,12 +22,12 @@ import { caches } from "./server-cache";
/**
* v1.7.0 W6 — server cache key for the unified dashboard first-paint
* snapshot. Lives under the analytics cache bucket so the existing
* `caches.analytics.deleteByPrefix(`${userId}|`)` sweep on a
* measurement / mood / medication write already covers it; the widget
* + insight invalidators that DON'T touch the analytics bucket call
* `invalidateUserDashboardSnapshot` explicitly. Exported so the cache
* key stays single-source-of-truth across the route and the tests.
* snapshot. Lives under the analytics cache bucket so the `${userId}|`
* sweep on a measurement / mood / medication write already covers it
* (measurement writes mark it stale; mood / medication writes hard-evict
* it); the widget + insight invalidators that DON'T touch the analytics
* bucket call `invalidateUserDashboardSnapshot` explicitly. Exported so
* the cache key stays single-source-of-truth across route and tests.
*/
export function dashboardSnapshotCacheKey(userId: string): string {
return `${userId}|dashboard-snapshot`;
@@ -49,10 +49,20 @@ export function invalidateUserDashboardSnapshot(userId: string): void {
* because mood writes don't change measurement rows.
*/
export function invalidateUserMeasurements(userId: string): void {
// The `${userId}|` prefix sweep covers the slim / thick analytics
// cells, the iOS summary cell, AND the v1.7.0 dashboard snapshot
// The `${userId}|` prefix covers the slim / thick analytics cells, the
// iOS summary cell, AND the v1.7.0 dashboard snapshot
// (`${userId}|dashboard-snapshot`) in one pass.
caches.analytics.deleteByPrefix(`${userId}|`);
//
// v1.12.7 — mark stale rather than hard-evict. Measurement writes are
// the highest-frequency dirty signal (every iOS Apple-Health sync
// posts a batch), and a hard evict busts the snapshot into a cold
// rebuild on the next read all day long. Marking stale lets the
// `cachedSwr` snapshot read serve the prior value immediately while a
// single background recompute warms a fresh one. The slim / thick /
// summary cells read via plain `cached` are unaffected: a marked-stale
// entry has `expiresAt === now`, so their next read is a clean miss and
// rebuilds fresh — identical to the old evict for those keys.
caches.analytics.markStaleByPrefix(`${userId}|`);
caches.achievements.deleteByPrefix(userId);
caches.workouts.deleteByPrefix(`${userId}|`);
// v1.4.36 W1 — measurement writes change the per-target consistency
+19
View File
@@ -339,10 +339,29 @@ export class ServerCache<T> {
* `/api/mood/analytics`) without churning the helper module.
*/
export const caches = {
/**
* Slim / thick analytics cells, the iOS summary cell, the dashboard
* summary, AND the unified dashboard first-paint snapshot (per-key TTL
* override). 60 s fresh TTL keeps multiple sub-page mounts inside a
* minute on a warm entry.
*
* v1.12.7 — stale-while-revalidate window. The snapshot read uses
* `cachedSwr` and a measurement write marks the bucket stale (not a
* hard evict) via `invalidateUserMeasurements`, so a high-frequency
* iOS Apple-Health sync no longer busts the snapshot into a cold
* rebuild on every batch (the v1.8.7 "sync evicting the cache all day"
* class, on the dashboard snapshot). Slim / thick / summary cells read
* via plain `cached` are unaffected: a marked-stale entry has
* `expiresAt === now`, so their next `cached` read is a clean miss and
* rebuilds fresh — only the `cachedSwr` snapshot serves stale + warms a
* background recompute. The 10-minute stale window bounds how old a
* served snapshot can be when no reader triggers a revalidation.
*/
analytics: new ServerCache<unknown>({
name: "analytics",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
medications: new ServerCache<unknown>({
name: "medications",
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { computeWindowStats } from "@/lib/charts/window-stats";
describe("computeWindowStats", () => {
it("computes min / max / median / mean over an odd-length series", () => {
const stats = computeWindowStats([3, 1, 2]);
expect(stats).toEqual({
count: 3,
min: 1,
max: 3,
median: 2,
mean: 2,
});
});
it("uses the linear-interpolated midpoint on an even-length series", () => {
// Matches the PERCENTILE_CONT(0.5) definition `summarize()` uses.
const stats = computeWindowStats([1, 2, 3, 4]);
expect(stats.median).toBe(2.5);
expect(stats.mean).toBe(2.5);
expect(stats.min).toBe(1);
expect(stats.max).toBe(4);
expect(stats.count).toBe(4);
});
it("drops non-finite / gap entries before the fold", () => {
const stats = computeWindowStats([
10,
undefined,
null,
Number.NaN,
Number.POSITIVE_INFINITY,
20,
]);
expect(stats.count).toBe(2);
expect(stats.min).toBe(10);
expect(stats.max).toBe(20);
expect(stats.mean).toBe(15);
expect(stats.median).toBe(15);
});
it("returns the all-null shape for an empty or all-gap window", () => {
for (const input of [[], [undefined, null, Number.NaN]]) {
const stats = computeWindowStats(input as Array<number | null>);
expect(stats).toEqual({
count: 0,
min: null,
max: null,
median: null,
mean: null,
});
}
});
it("summarises a single-point window without dividing by zero", () => {
const stats = computeWindowStats([42]);
expect(stats).toEqual({
count: 1,
min: 42,
max: 42,
median: 42,
mean: 42,
});
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* v1.12.7 — chart-reactive metric statistics.
*
* When the user brushes a window in a metric sub-page chart, the
* Min / Max / Median / Mittelwert strip recomputes over just the visible
* domain rather than the full range. This helper is the single pure
* computation behind that: hand it the visible per-type values and it
* returns the four central statistics.
*
* The four-statistic shape matches the subset of `DataSummary` the strip
* renders (`min` / `max` / `median` / `mean`), and the median uses the same
* linear-interpolated midpoint definition as `summarize()` in
* `src/lib/analytics/trends.ts` so a brushed window and the full-range
* summary read on the same algorithm — only the window differs.
*
* The helper is deliberately free of any Recharts / React dependency so the
* chart can compute windowed stats off the array it already holds without
* re-fetching, and the unit suite can pin the maths without mounting a chart.
*/
export interface MetricWindowStats {
/** Number of finite values in the window. */
count: number;
min: number | null;
max: number | null;
median: number | null;
mean: number | null;
}
const EMPTY: MetricWindowStats = {
count: 0,
min: null,
max: null,
median: null,
mean: null,
};
/**
* Compute Min / Max / Median / Mean over a list of values. Non-finite
* entries (NaN, Infinity, null, undefined) are dropped before the fold so a
* sparse series with gaps still summarises cleanly. An empty (or all-gap)
* input returns the all-null shape so the caller can fall back to the
* full-range summary rather than paint zeros.
*/
export function computeWindowStats(
values: Array<number | null | undefined>,
): MetricWindowStats {
const finite: number[] = [];
for (const v of values) {
if (typeof v === "number" && Number.isFinite(v)) finite.push(v);
}
if (finite.length === 0) return { ...EMPTY };
// Single-pass sum/min/max — mirrors the bounded fold in `summarize()`
// so a multi-year power user's brushed window never spreads a huge
// argument list onto the stack.
let sum = 0;
let min = finite[0];
let max = finite[0];
for (const v of finite) {
sum += v;
if (v < min) min = v;
if (v > max) max = v;
}
const mean = sum / finite.length;
// Linear-interpolated midpoint on an even-length series — the standard
// PERCENTILE_CONT(0.5) definition, matching `summarize()`.
const sorted = [...finite].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median =
sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
return { count: finite.length, min, max, median, mean };
}
@@ -145,6 +145,28 @@ describe("buildDashboardSnapshot — envelope shape", () => {
expect(snap.layout).toBeDefined();
expect(typeof snap.generatedAt).toBe("string");
});
it("probes rollup coverage once and threads it into the summaries slice (A5)", async () => {
const coverageMap = new Map([["WEIGHT", true]]);
probeRollupCoverage.mockResolvedValue(coverageMap);
isFullyCovered.mockReturnValue(true);
computeBpInTargetFastPath.mockResolvedValue({
last7Days: { pct: 70 },
last30Days: { pct: 80 },
allTime: { pct: 75 },
priorMonth: { pct: 60 },
priorYear: { pct: 50 },
});
await buildDashboardSnapshot(fakePrisma, baseUser());
// The snapshot probes coverage exactly once up front — the slice no
// longer re-runs the identical probe internally.
expect(probeRollupCoverage).toHaveBeenCalledTimes(1);
// The already-probed map is passed as the slice's second argument so
// it reuses it instead of probing again.
expect(computeSummariesSlice).toHaveBeenCalledWith("user-1", coverageMap);
});
});
describe("buildDashboardSnapshot — two-phase null extras", () => {
+3 -1
View File
@@ -609,7 +609,9 @@ export async function buildDashboardSnapshot(
const warm = isFullyCovered(coverage);
const [slim, mood, extras, flags] = await Promise.all([
time("summaries", () => computeSummariesSlice(user.id)),
// A5 — reuse the coverage map already probed above so the slice
// doesn't re-run the identical `probeRollupCoverage` query.
time("summaries", () => computeSummariesSlice(user.id, coverage)),
time("mood", () => buildMoodBlock(prisma, user.id)),
warm
? time("extras", () => buildExtras(prisma, user, userTz, coverage))
+158 -18
View File
@@ -14,10 +14,26 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { hashToken } from "@/lib/auth/hmac";
import { annotate } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
const TTL_MS = 24 * 60 * 60 * 1000;
/**
* Claim TTL — how long an in-flight "pending" row is honoured before a
* retry is allowed to re-run the handler. Bounds the blast radius of a
* crashed handler that never wrote its result: the key self-heals after
* this window instead of being locked for the full 24h response TTL.
* Sized above the longest realistic write-handler latency.
*/
const PENDING_TTL_MS = 2 * 60 * 1000;
const SUPPORTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/**
* Sentinel `responseStatus` for a claimed-but-not-yet-completed row. A
* real HTTP status is never 0, so `findCached` can distinguish an
* in-flight claim from a cached response without a schema change.
*/
const PENDING_STATUS = 0;
const KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/;
export interface IdempotencyContext {
@@ -36,12 +52,21 @@ function getIdempotencyKey(request: Request | NextRequest): string | null {
}
/**
* Look up a cached response for a (userId, key, method, path) tuple.
* Returns the cached NextResponse or null.
* Outcome of a key lookup:
* - `{ kind: "replay" }` — a completed response is cached; replay it.
* - `{ kind: "pending" }` — another request holds an in-flight claim
* for this key; the caller must NOT run the
* handler again.
* - `null` — no live row; the caller may claim + run.
*/
async function findCached(
ctx: IdempotencyContext,
): Promise<NextResponse | null> {
type CacheLookup = { kind: "replay"; response: NextResponse } | { kind: "pending" } | null;
/**
* Look up the state of a (userId, key, method, path) tuple. Distinguishes
* a completed cached response (replay) from an in-flight claim (pending)
* via the `PENDING_STATUS` sentinel.
*/
async function findCached(ctx: IdempotencyContext): Promise<CacheLookup> {
const row = await prisma.idempotencyKey.findUnique({
where: {
userId_key_method_path: {
@@ -55,13 +80,20 @@ async function findCached(
if (!row) return null;
if (row.expiresAt <= new Date()) {
// Stale — purge and fall through.
// Stale (completed row past its TTL, or a crashed claim past the
// pending window) — purge and fall through so the retry re-runs.
await prisma.idempotencyKey
.delete({ where: { id: row.id } })
.catch(() => {});
return null;
}
if (row.responseStatus === PENDING_STATUS) {
// A concurrent request claimed this key and is still running its
// side-effect. Signal the caller to refuse rather than double-execute.
return { kind: "pending" };
}
let parsed: unknown = null;
try {
parsed = JSON.parse(row.responseBody);
@@ -75,12 +107,63 @@ async function findCached(
});
// Replay original status to keep client behaviour byte-identical.
return NextResponse.json(parsed, {
status: row.responseStatus,
headers: {
"X-Idempotent-Replay": "true",
},
});
return {
kind: "replay",
response: NextResponse.json(parsed, {
status: row.responseStatus,
headers: {
"X-Idempotent-Replay": "true",
},
}),
};
}
/**
* Atomically claim a key by inserting a `PENDING_STATUS` row under the
* `(userId, key, method, path)` unique constraint BEFORE the handler
* runs. Returns `true` when this caller won the claim, `false` when a
* concurrent request already holds it (unique-constraint collision) so
* the wrapper can refuse with 409 instead of double-executing the
* side-effect. The claim carries a short `PENDING_TTL_MS` so a crashed
* handler self-heals.
*/
async function claimKey(ctx: IdempotencyContext): Promise<boolean> {
try {
await prisma.idempotencyKey.create({
data: {
userId: ctx.userId,
key: ctx.key,
method: ctx.method,
path: ctx.path,
responseStatus: PENDING_STATUS,
responseBody: "",
expiresAt: new Date(Date.now() + PENDING_TTL_MS),
},
});
return true;
} catch (err) {
// Another request inserted the claim first — refuse this one.
if (isP2002(err)) return false;
throw err;
}
}
/**
* Release a claim that produced a non-cachable result (or threw) so a
* later retry gets a fresh attempt rather than a stuck pending row.
*/
async function releaseClaim(ctx: IdempotencyContext): Promise<void> {
await prisma.idempotencyKey
.deleteMany({
where: {
userId: ctx.userId,
key: ctx.key,
method: ctx.method,
path: ctx.path,
responseStatus: PENDING_STATUS,
},
})
.catch(() => {});
}
async function persistCached(
@@ -95,21 +178,27 @@ async function persistCached(
.text()
.catch(() => ""));
// Promote the claimed pending row to the completed response, extending
// the TTL from the short claim window to the full 24h replay window.
// The claim is held under the unique constraint, so this caller owns
// the row — a plain update, not an upsert.
await prisma.idempotencyKey
.create({
data: {
.updateMany({
where: {
userId: ctx.userId,
key: ctx.key,
method: ctx.method,
path: ctx.path,
},
data: {
responseStatus: response.status,
responseBody: body,
expiresAt: new Date(Date.now() + TTL_MS),
},
})
.catch(() => {
// Concurrent insert with the same key — the other writer wins.
// Ignore because a future replay will hit their cached value.
// The claim row vanished (e.g. purged as stale) — a future replay
// will simply miss and re-run; never throw out of the cache path.
});
}
@@ -213,9 +302,52 @@ export function withIdempotency<
};
const cached = await findCached(ctx);
if (cached) return cached;
if (cached?.kind === "replay") return cached.response;
if (cached?.kind === "pending") {
// A concurrent request is mid-flight on this exact key. Refuse
// rather than run the side-effect a second time. The client should
// retry after the in-flight request lands, at which point the
// completed row replays.
annotate({
action: { name: "idempotency.inflight_conflict" },
meta: { method: ctx.method, path: ctx.path },
});
return NextResponse.json(
{
data: null,
error: { message: "A request with this Idempotency-Key is already in progress" },
},
{ status: 409, headers: { "X-Idempotent-Replay": "false" } },
);
}
const response = await handler(...args);
// Claim the key before running the handler. If a racing request beats
// us to the insert, treat it as the in-flight conflict above — only
// one caller may execute the side-effect for a given key.
const won = await claimKey(ctx);
if (!won) {
annotate({
action: { name: "idempotency.inflight_conflict" },
meta: { method: ctx.method, path: ctx.path },
});
return NextResponse.json(
{
data: null,
error: { message: "A request with this Idempotency-Key is already in progress" },
},
{ status: 409, headers: { "X-Idempotent-Replay": "false" } },
);
}
let response: Response;
try {
response = await handler(...args);
} catch (err) {
// Handler threw — release the claim so a retry isn't locked out for
// the full pending window, then re-throw to the error envelope.
await releaseClaim(ctx);
throw err;
}
if (isCachableStatus(response.status)) {
// Defence-in-depth: never persist a body that carries a freshly-issued
@@ -235,7 +367,15 @@ export function withIdempotency<
const text = await cloned.text();
if (!SECRET_PATTERN.test(text)) {
await persistCached(ctx, response, text);
} else {
// Secret-shaped body — drop the claim so the key isn't left
// pending (it was never going to cache).
await releaseClaim(ctx);
}
} else {
// Non-cachable status (401/403/408/429/5xx) — release the claim so a
// retry gets a fresh attempt rather than a stuck pending row.
await releaseClaim(ctx);
}
return response;
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
parseSummaryFromContent,
stripJsonFences,
} from "@/lib/insights/status-shared";
/**
* The Anthropic + local providers have no native JSON mode, so a compliant
* model still routinely wraps its `{ "summary": … }` reply in a ```json
* fence or prefixes it with a sentence. These guards pin the fence-strip
* fallback so such replies parse instead of surfacing the raw fenced string
* as the user-facing assessment.
*/
describe("stripJsonFences", () => {
it("strips a ```json fence", () => {
const out = stripJsonFences('```json\n{"summary":"ok"}\n```');
expect(out).toBe('{"summary":"ok"}');
});
it("strips a bare ``` fence", () => {
const out = stripJsonFences('```\n{"summary":"ok"}\n```');
expect(out).toBe('{"summary":"ok"}');
});
it("narrows to the first { … last } when prose surrounds the object", () => {
const out = stripJsonFences('Here is the result: {"summary":"ok"} done.');
expect(out).toBe('{"summary":"ok"}');
});
it("is a no-op on already-clean JSON", () => {
expect(stripJsonFences('{"summary":"ok"}')).toBe('{"summary":"ok"}');
});
it("returns the trimmed input when there is no object body", () => {
expect(stripJsonFences(" plain prose ")).toBe("plain prose");
});
});
describe("parseSummaryFromContent", () => {
it("parses a clean JSON envelope", () => {
expect(parseSummaryFromContent('{"summary":"hello"}')).toBe("hello");
});
it("parses a ```json-fenced envelope (no native JSON mode)", () => {
expect(
parseSummaryFromContent('```json\n{"summary":"hello"}\n```'),
).toBe("hello");
});
it("parses a sentence-prefixed envelope", () => {
expect(
parseSummaryFromContent('Sure:\n{"summary":"hello"} — done'),
).toBe("hello");
});
it("falls back to the raw content for bare prose", () => {
expect(parseSummaryFromContent("just prose, no json")).toBe(
"just prose, no json",
);
});
});
+5 -2
View File
@@ -186,10 +186,13 @@ export function formatRepetitionSignal(
* mean that still counts as "no material change" (default 8 %).
*/
export function computeSteadyRun(
weekly: ReadonlyArray<{ mean: number }>,
monthly: ReadonlyArray<{ mean: number }>,
weekly: ReadonlyArray<{ mean: number }> | undefined,
monthly: ReadonlyArray<{ mean: number }> | undefined,
bandFraction = 0.08,
): number {
// Defensive: a graded slice can be absent on a degraded / stubbed series.
// Treat a missing slice as no signal so the repetition block stays silent.
if (!weekly || !monthly) return 0;
if (weekly.length < 2) return 0;
// Baseline: the longer-horizon mean the recent weeks are placed against.
+39 -1
View File
@@ -7,6 +7,12 @@ import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
computeSteadyRun,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
@@ -276,6 +282,12 @@ export async function generateBloodPressureStatusForUser(
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
// v1.12.7 — bound to the ~365-day window the continuity series
// and the 7/30-day compliance gate consume; this read was
// otherwise unbounded over all intake history.
scheduledFor: {
gte: new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000),
},
medicationId: {
in: bpMedications.map((medication) => medication.id),
},
@@ -534,6 +546,29 @@ export async function generateBloodPressureStatusForUser(
locale,
);
// v1.12.7 — same diversity / anti-repetition block the archetype cards
// carry, now threaded into this headline card. Pure formatting of
// already-computed data; the correlation fetch is best-effort and resolves
// to no block on failure, never a generation failure.
const varietyLead = pickVarietyLead(userId, "blood-pressure", todayKey);
const steadyRun = computeSteadyRun(sysGraded.weekly, sysGraded.monthly);
const relations = await getRelevantCorrelationsForMetric(
userId,
"BLOOD_PRESSURE_SYS",
);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: sysSeries.daily.length,
newestDaysAgo: newestMeasurementDaysAgo,
},
repeatCount: steadyRun,
relations,
},
locale,
);
const outcome = await runStatusCompletion({
userId,
cacheAction,
@@ -544,8 +579,11 @@ export async function generateBloodPressureStatusForUser(
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
),
temperature: 0.3,
// v1.12.7 — match the archetype cards' 0.45: more cadence entropy while
// FACTS stay pinned by the snapshot + the forbidden-phrase guards.
temperature: 0.45,
maxTokens: 1000,
});
+27 -1
View File
@@ -6,6 +6,11 @@ import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
computeSteadyRun,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
buildGradedSeriesWithRollups,
@@ -238,6 +243,25 @@ export async function generateBmiStatusForUser(
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
// BMI is a per-user height transform of WEIGHT (no discovery channel of its
// own), so the RELATIONS sub-block stays empty; the variety / data-strength
// / repetition signals carry the rotation.
const varietyLead = pickVarietyLead(userId, "bmi", todayKey);
const steadyRun = computeSteadyRun(bmiGraded.weekly, bmiGraded.monthly);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: bmiSeries.daily.length,
newestDaysAgo: newestMeasurementDaysAgo,
},
repeatCount: steadyRun,
relations: [],
},
locale,
);
const outcome = await runStatusCompletion({
userId,
cacheAction,
@@ -248,8 +272,10 @@ export async function generateBmiStatusForUser(
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
),
temperature: 0.3,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
maxTokens: 1000,
});
+23 -4
View File
@@ -33,7 +33,8 @@ import {
buildUserPrompt,
type ComparisonSnapshot,
} from "@/lib/ai/prompts/insight-system-prompt";
import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator";
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
resolveDashboardLayout,
@@ -52,7 +53,10 @@ import {
enqueueStatusGeneration,
type InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { normalizeLocale } from "@/lib/insights/status-shared";
import {
normalizeLocale,
stripJsonFences,
} from "@/lib/insights/status-shared";
import {
isTimeoutStub,
statusCacheAction,
@@ -524,6 +528,18 @@ export async function generateComprehensiveInsight(
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
}
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
// this generation actually carries, so a normative claim ("target < 140/90")
// can cite a real `referenceId` the schema + UI footnote already support.
// Returns the plain prompt unchanged when no applicable metric is present.
const referenceMetrics = metricsFromPresentSections({
bloodPressure: features.bloodPressure != null,
weight: features.weight != null,
pulse: features.pulse != null,
mood: features.mood != null,
medication: (features.medications?.length ?? 0) > 0,
});
let result;
let workingProviderType: string;
try {
@@ -531,7 +547,7 @@ export async function generateComprehensiveInsight(
userId,
providers: chain,
params: {
systemPrompt: getStrictInsightsSystemPrompt(locale),
systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
userPrompt,
temperature: 0.3,
maxTokens: 1500,
@@ -548,7 +564,10 @@ export async function generateComprehensiveInsight(
let insights: InsightResult | Record<string, unknown>;
try {
const parsed = JSON.parse(result.content);
// Anthropic + local have no native JSON mode, so a ```json-fenced or
// sentence-prefixed reply would otherwise fail the whole generation.
// Strip the fence before parsing; clean JSON passes through unchanged.
const parsed = JSON.parse(stripJsonFences(result.content));
const validated = insightResultSchema.safeParse(parsed);
insights = validated.success ? validated.data : parsed;
} catch {
+37 -2
View File
@@ -11,6 +11,10 @@ import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
buildGradedSeriesFromPoints,
@@ -178,9 +182,19 @@ export async function generateGeneralStatusForUser(
}),
);
// v1.12.7 — bound the intake read to the ~365-day window the adherence
// series renders. Without a `scheduledFor` floor this scanned all intake
// history; the adherence fold below only consumes the trailing year.
const intakeWindowStart = new Date(
now.getTime() - 365 * 24 * 60 * 60 * 1000,
);
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null },
where: {
userId,
deletedAt: null,
scheduledFor: { gte: intakeWindowStart },
},
orderBy: { scheduledFor: "asc" },
select: {
scheduledFor: true,
@@ -359,6 +373,25 @@ export async function generateGeneralStatusForUser(
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
// The overview spans many metrics with no single graded mean to band, so
// the steady-run signal is left to the previous-context comparison
// (repeatCount 0) and there is no single discovery channel (relations
// empty); the variety lead + overall data-strength carry the rotation.
const varietyLead = pickVarietyLead(userId, "general", todayKey);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: measurements.length,
newestDaysAgo,
},
repeatCount: 0,
relations: [],
},
locale,
);
const outcome = await runStatusCompletion({
userId,
cacheAction,
@@ -369,8 +402,10 @@ export async function generateGeneralStatusForUser(
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
),
temperature: 0.3,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
maxTokens: 1000,
});
@@ -16,6 +16,10 @@ import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
@@ -350,6 +354,29 @@ export async function generateMedicationComplianceStatusForUser(
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
// Adherence spans multiple medications with no single card-level graded
// mean to band, so the steady-run signal is left to the previous-context
// comparison (repeatCount 0) and there is no measurement discovery channel
// (relations empty); the variety lead + data-strength carry the rotation.
const varietyLead = pickVarietyLead(
userId,
"medication-compliance",
todayKey,
);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: medicationEvents.length,
newestDaysAgo: newestMeasurementDaysAgo,
},
repeatCount: 0,
relations: [],
},
locale,
);
const outcome = await runStatusCompletion({
userId,
cacheAction,
@@ -360,8 +387,10 @@ export async function generateMedicationComplianceStatusForUser(
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
),
temperature: 0.3,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
maxTokens: 1000,
});
+28 -1
View File
@@ -5,6 +5,11 @@ import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
computeSteadyRun,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { pearsonCorrelation } from "@/lib/analytics/correlations";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
@@ -456,6 +461,26 @@ export async function generateMoodStatusForUser(
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
// MOOD is mood-entry backed (not a MeasurementType), so the cross-metric
// RELATIONS sub-block is sourced from the snapshot's own crossMetricContext
// rather than the measurement discovery engine; relations stays empty here
// and the variety / data-strength / repetition signals carry the rotation.
const varietyLead = pickVarietyLead(userId, "mood", todayKey);
const steadyRun = computeSteadyRun(moodGraded.weekly, moodGraded.monthly);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: moodSeries.daily.length,
newestDaysAgo: newestEntryDaysAgo,
},
repeatCount: steadyRun,
relations: [],
},
locale,
);
const outcome = await runStatusCompletion({
userId,
cacheAction,
@@ -466,8 +491,10 @@ export async function generateMoodStatusForUser(
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
),
temperature: 0.3,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
maxTokens: 1000,
});
+26 -1
View File
@@ -7,6 +7,12 @@ import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
computeSteadyRun,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
@@ -281,6 +287,23 @@ export async function generatePulseStatusForUser(
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
const varietyLead = pickVarietyLead(userId, "pulse", todayKey);
const steadyRun = computeSteadyRun(pulseGraded.weekly, pulseGraded.monthly);
const relations = await getRelevantCorrelationsForMetric(userId, "PULSE");
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: pulseSeries.daily.length,
newestDaysAgo: newestMeasurementDaysAgo,
},
repeatCount: steadyRun,
relations,
},
locale,
);
const outcome = await runStatusCompletion({
userId,
cacheAction,
@@ -291,8 +314,10 @@ export async function generatePulseStatusForUser(
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
),
temperature: 0.3,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
maxTokens: 1000,
});
+45 -1
View File
@@ -86,17 +86,61 @@ export function summarizeSeries(
};
}
/**
* Strip a Markdown code fence and isolate the JSON object body.
*
* The Anthropic and local providers have no native JSON mode (only the
* OpenAI-family clients send `response_format: json_object`), so a
* compliant model still routinely wraps its `{ "summary": … }` reply in a
* ```json … ``` fence or prefixes it with a sentence. `JSON.parse` then
* throws and the caller would surface the raw fenced string as the
* user-facing assessment. This helper removes a leading/trailing fence and
* narrows to the first `{` … last `}` span so the parse sees clean JSON.
*
* It is a no-op on already-clean JSON and on genuinely fence-free prose
* with no braces — in both cases the original (trimmed) string is returned,
* so the bare-prose fallback below still works.
*/
export function stripJsonFences(content: string): string {
let text = content.trim();
// Drop a leading ```json / ``` fence and a trailing ``` fence if present.
const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
if (fenced) {
text = fenced[1].trim();
}
// Narrow to the first `{` … last `}` so a leading/trailing sentence the
// model added around the object does not break the parse.
const first = text.indexOf("{");
const last = text.lastIndexOf("}");
if (first !== -1 && last !== -1 && last > first) {
return text.slice(first, last + 1);
}
return text;
}
/**
* Pull the `summary` string out of a model completion. The status
* prompts return a `{ "summary": "…" }` envelope; a model that ignores
* the contract and returns bare prose falls back to the raw content.
*
* Tries the content as-is first (the common, fence-free case), then a
* fence-stripped retry for providers without a native JSON mode.
*/
export function parseSummaryFromContent(content: string): string {
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") return parsed.summary;
} catch {
// not JSON — fall through to the raw content
// not directly parseable — try a fence-stripped retry below
}
try {
const stripped = stripJsonFences(content);
if (stripped !== content) {
const parsed = JSON.parse(stripped) as { summary?: string };
if (typeof parsed.summary === "string") return parsed.summary;
}
} catch {
// still not JSON — fall through to the raw content
}
return content;
}
+26 -1
View File
@@ -12,6 +12,12 @@ import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
computeSteadyRun,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
import {
applyPayloadBudget,
dayOffsetToBerlinDayKey,
@@ -429,6 +435,23 @@ export async function generateWeightStatusForUser(
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
const varietyLead = pickVarietyLead(userId, "weight", todayKey);
const steadyRun = computeSteadyRun(weightGraded.weekly, weightGraded.monthly);
const relations = await getRelevantCorrelationsForMetric(userId, "WEIGHT");
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: weightSeries.daily.length,
newestDaysAgo: newestMeasurementDaysAgo,
},
repeatCount: steadyRun,
relations,
},
locale,
);
// Run the provider chain bounded by the aligned 60 s budget. A
// timeout / error / empty content is a transient miss — serve the
// fallback for this render without persisting it, so the next mount
@@ -443,8 +466,10 @@ export async function generateWeightStatusForUser(
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
),
temperature: 0.3,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
maxTokens: 1000,
});
@@ -324,3 +324,95 @@ describe("drainPerSampleCumulative — cutoffHours", () => {
expect(call.where.measuredAt).toBeUndefined();
});
});
// A1 — a late watch sync after a day's total was already collapsed must
// FOLD the new per-sample rows into the existing total, never overwrite
// it with just the partial late sum (the original samples are gone).
describe("drainPerSampleCumulative — late-sync fold into existing total", () => {
function buildFoldMock(existingValue: number | null) {
const findManyUser = vi.fn().mockResolvedValue([
{ id: "user-1", timezone: "Europe/Berlin" },
]);
// Only ACTIVITY_STEPS yields the late per-sample rows; every other
// cumulative type returns an empty scan so the run stays a single
// bucket.
const findManyMeasurement = vi.fn(
async (args: { where: { type: string } }) => {
if (args.where.type === "ACTIVITY_STEPS") {
return [
{
id: "late-1",
type: "ACTIVITY_STEPS",
value: 300,
measuredAt: new Date("2026-05-16T20:00:00.000Z"),
externalId: "hk-uuid-late-1",
},
{
id: "late-2",
type: "ACTIVITY_STEPS",
value: 200,
measuredAt: new Date("2026-05-16T21:00:00.000Z"),
externalId: "hk-uuid-late-2",
},
];
}
return [];
},
);
const upsert = vi.fn().mockResolvedValue({});
const deleteMany = vi.fn().mockResolvedValue({ count: 2 });
const findUnique = vi.fn().mockResolvedValue(
existingValue === null ? null : { value: existingValue },
);
const findFirst = vi.fn().mockResolvedValue({ unit: "count" });
const tx = {
measurement: { upsert, deleteMany, findUnique, findFirst },
};
return {
prisma: {
user: { findMany: findManyUser },
measurement: { findMany: findManyMeasurement },
$transaction: vi.fn(async (cb: (t: typeof tx) => Promise<unknown>) =>
cb(tx),
),
} as unknown as PrismaClient,
upsert,
findUnique,
};
}
it("folds late samples into a pre-existing collapsed total (no shrink)", async () => {
// Day already collapsed to 9000 on an earlier run; a late sync of
// 300 + 200 = 500 must take the row to 9500, not down to 500.
const { prisma, upsert, findUnique } = buildFoldMock(9000);
await drainPerSampleCumulative(prisma, { log: () => {} });
expect(findUnique).toHaveBeenCalledTimes(1);
expect(upsert).toHaveBeenCalledTimes(1);
const upsertArg = upsert.mock.calls[0]?.[0] as {
update: { value: number };
};
expect(upsertArg.update.value).toBe(9500);
});
it("mints the partial sum when no collapsed total exists yet", async () => {
const { prisma, upsert } = buildFoldMock(null);
await drainPerSampleCumulative(prisma, { log: () => {} });
const upsertArg = upsert.mock.calls[0]?.[0] as {
create: { value: number };
update: { value: number };
};
// Fresh day: create branch carries the raw sum; update branch (taken
// on an idempotent re-run with no prior total) also resolves to the
// raw sum since `existing` was null.
expect(upsertArg.create.value).toBe(500);
expect(upsertArg.update.value).toBe(500);
});
});
@@ -228,6 +228,40 @@ export async function drainPerSampleCumulative(
}): Promise<DayWriteOutcome> => {
let removed = 0;
await pc.$transaction(async (tx) => {
// Fold late per-sample rows INTO an existing collapsed daily
// total rather than overwriting it. `bucketRowsByDay` strips
// already-collapsed `stats:` rows from the bucket, so when a day
// was collapsed on an earlier run and fresh per-sample rows
// arrive late (a trailing watch sync after the day's total was
// already minted), this bucket holds ONLY those late samples.
// A blind `update: { value: reducedValue }` would replace the
// full day total with just the partial late sum — and the
// original contributing samples were already hard-deleted, so
// the lost count is unrecoverable.
//
// The probe runs inside the same transaction as the upsert +
// delete so the read-modify-write of the total is atomic against
// a concurrent drain. The late samples are genuine additional
// readings (unlike the legacy-step pass, where the existing
// total is HealthKit's own aggregate and the legacy rows would
// double-count — so that sibling deliberately does NOT fold),
// therefore the correct merge is `existing + late sum`. The
// per-sample rows are hard-deleted either way so a re-run cannot
// re-accumulate them.
const existing = await tx.measurement.findUnique({
where: {
userId_type_source_externalId: {
userId,
type,
source: "APPLE_HEALTH",
externalId,
},
},
select: { value: true },
});
const mergedValue =
existing !== null ? existing.value + reducedValue : reducedValue;
// Upsert the daily-aggregated row first, then drop the
// per-sample rows. The unique index
// (userId, type, source, externalId) makes the upsert
@@ -257,7 +291,7 @@ export async function drainPerSampleCumulative(
externalId,
},
update: {
value: reducedValue,
value: mergedValue,
measuredAt: canonicalTimestamp,
},
});
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from "vitest";
import { complianceChips } from "../compliance";
import { complianceChips, streaksFromTimeline } from "../compliance";
import { buildCadenceTimeline } from "../cadence";
import type {
CadenceEngineContext,
@@ -357,3 +357,76 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)",
});
});
describe("streaksFromTimeline — DST-correct day walk", () => {
const DAILY_8AM: ScheduleLike = {
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
};
it("does not drop a day across a spring-forward (23h) boundary", () => {
// Europe/Berlin spring-forward is 2025-03-30 (23h-wide local day).
// Take every dose for a window spanning the transition; the streak
// must count every local calendar day, not skip the 23h day. The
// prior fixed +24h step landed past the 23h day and the windowDays
// iteration ran short.
const tz = "Europe/Berlin";
// NOW = local noon on 2025-04-02 (CEST, UTC+2 → 10:00 UTC).
const NOW = new Date("2025-04-02T10:00:00Z");
const windowDays = 7; // covers 2025-03-27 .. 2025-04-02 inclusive.
const events: IntakeEventLike[] = [];
for (
let day = new Date("2025-03-27T06:30:00Z");
day.getTime() <= NOW.getTime();
day = new Date(day.getTime() + 24 * 60 * 60 * 1000)
) {
events.push({
scheduledFor: new Date(day),
takenAt: new Date(day.getTime() + 30 * 60_000),
skipped: false,
});
}
const timeline = buildCadenceTimeline(
[DAILY_8AM],
events,
NOW,
windowDays,
undefined,
tz,
);
const { current } = streaksFromTimeline(timeline, NOW, windowDays, tz);
// Every one of the 7 local days (including the 23h spring-forward
// day) was taken → an unbroken 7-day streak.
expect(current).toBe(7);
});
it("visits each local day once across a fall-back (25h) boundary", () => {
// Europe/Berlin fall-back is 2025-10-26 (25h-wide local day).
const tz = "Europe/Berlin";
const NOW = new Date("2025-10-29T11:00:00Z"); // local noon CET.
const windowDays = 7;
const events: IntakeEventLike[] = [];
for (
let day = new Date("2025-10-23T07:30:00Z");
day.getTime() <= NOW.getTime();
day = new Date(day.getTime() + 24 * 60 * 60 * 1000)
) {
events.push({
scheduledFor: new Date(day),
takenAt: new Date(day.getTime() + 30 * 60_000),
skipped: false,
});
}
const timeline = buildCadenceTimeline(
[DAILY_8AM],
events,
NOW,
windowDays,
undefined,
tz,
);
const { current } = streaksFromTimeline(timeline, NOW, windowDays, tz);
expect(current).toBe(7);
});
});
+1 -1
View File
@@ -274,7 +274,7 @@ function hhmmToMinutes(hhmm: string): number | null {
}
/** Snap a Date down to the user-local midnight. */
function startOfLocalDay(d: Date, tz: string | undefined): Date {
export function startOfLocalDay(d: Date, tz: string | undefined): Date {
const parts = wallClockInTz(d, tz);
return instantInTz(parts.year, parts.month, parts.day, 0, 0, tz);
}
+35 -20
View File
@@ -21,6 +21,7 @@
import {
buildCadenceTimeline,
startOfLocalDay,
type CadenceEngineContext,
type IntakeEventLike,
type PairedDose,
@@ -77,8 +78,13 @@ function localDayKey(d: Date, tz: string | undefined): string {
* (inclusive of today). The streak does not reset on a day that had
* no expected doses — the user gets credit for not breaking on
* non-scheduled days.
*
* Exported so `src/lib/analytics/compliance.ts` can share this exact
* timezone-aware streak math instead of duplicating it with host-tz
* day keys — the two call sites stay byte-for-byte identical on the
* streak rule.
*/
function streaksFromTimeline(
export function streaksFromTimeline(
timeline: PairedDose[],
asOf: Date,
windowDays: number,
@@ -100,26 +106,35 @@ function streaksFromTimeline(
let longest = 0;
let run = 0;
// Walk the window day-by-day from oldest to newest. Stepping by
// 25 h then re-keying via `localDayKey` keeps the cursor inside
// the right calendar day across DST boundaries — the absolute
// distance between two consecutive local midnights is 23/24/25 h
// around spring-forward / fall-back.
let cursor = new Date(asOf.getTime() - (windowDays - 1) * DAY_MS);
let lastKey = "";
for (let i = 0; i < windowDays; i++) {
const key = localDayKey(cursor, timeZone);
if (key !== lastKey) {
const state = byDay.get(key);
if (state === "bad") {
if (run > longest) longest = run;
run = 0;
} else {
run++;
}
lastKey = key;
// Walk the window day-by-day from oldest to newest. Snap each cursor
// to the user-local midnight and advance by +25 h then re-snap — the
// distance between two consecutive local midnights is 23 / 24 / 25 h
// around spring-forward / fall-back, and +25 h always clears the
// shortest (23 h) day while the re-snap collapses the longest (25 h)
// day back to its own midnight. This visits every local calendar day
// in the window exactly once instead of the prior fixed +24 h step
// that could skip a day on a 23 h spring-forward boundary (its
// comment already claimed 25 h while the code stepped 24 h).
const startDay = startOfLocalDay(
new Date(asOf.getTime() - (windowDays - 1) * DAY_MS),
timeZone,
);
const endDay = startOfLocalDay(asOf, timeZone);
for (
let cursor = startDay;
cursor.getTime() <= endDay.getTime();
cursor = startOfLocalDay(
new Date(cursor.getTime() + 25 * 60 * 60 * 1000),
timeZone,
)
) {
const state = byDay.get(localDayKey(cursor, timeZone));
if (state === "bad") {
if (run > longest) longest = run;
run = 0;
} else {
run++;
}
cursor = new Date(cursor.getTime() + DAY_MS);
}
if (run > longest) longest = run;
current = run;