Merge pull request #236 from MBombeck/release/v1.8.5

v1.8.5 — Insights reference panels, mood depth, injection tracking
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-01 21:42:03 +02:00
commit 6c44d4a2c1
131 files changed
+9870 -720

No files matched your search

@@ -0,0 +1,82 @@
# v1.8.5 — server → iOS: injection-site capture on intake
**Direction:** server → iOS
**Status:** additive, non-breaking. iOS adoption is optional.
## What changed
Injection-site tracking is now real on the server. The `injectionSite`
column on the intake event existed but was never written; v1.8.5 makes
the capture + rotation path end-to-end.
### 1. New optional request field: `injectionSite`
`POST /api/medications/{id}/intake`, `POST /api/medications/intake`
(status toggle), and `POST /api/medications/intake/bulk` now accept an
optional `injectionSite` on a **taken** write. Enum values:
```
ABDOMEN_LEFT, ABDOMEN_RIGHT, ABDOMEN_UPPER_LEFT, ABDOMEN_UPPER_RIGHT,
THIGH_LEFT, THIGH_RIGHT, UPPER_ARM_LEFT, UPPER_ARM_RIGHT
```
The field is **optional everywhere** — omit it and the dose records
exactly as before. There is no behaviour change for any existing client
that never sends it. (It is the same enum the response already echoes via
the GLP-1 detail endpoint, so the type is already known to the client.)
### 2. Server-side gating + validation (important for iOS UX)
The server only persists `injectionSite` when ALL hold:
- the dose is being recorded **taken** (never on a skip),
- the medication is `deliveryForm === "INJECTION"`,
- the medication has `trackInjectionSites === true`,
- the submitted site is in the medication's **effective allowed set**.
The effective allowed set = the per-medication `allowedInjectionSites`
(empty = all eight) **minus** the user-level
`globalExcludedInjectionSites` deny-list. **Deny always wins**: a site the
user globally excluded is never valid even if a medication lists it as
preferred.
A submitted site outside the effective set:
- per-medication + status-toggle routes → **422**
`errorCode: "medications.intake.injection_site.disallowed"`.
- bulk route → that entry is reported `status: "skipped"`,
`reason: "injection_site_not_allowed"` (the batch still succeeds).
A site sent for a non-injection / tracking-off medication, or on a skip,
is silently dropped (the dose still records, no error).
iOS should only offer the picker when the medication is an injection with
tracking enabled, and restrict the choices to the effective allowed set,
so the 422 path stays an edge case (stale UI / race).
### 3. New medication fields (read + write)
`GET /api/medications` and `GET /api/medications/{id}` now echo:
- `trackInjectionSites: boolean` (default `false`)
- `allowedInjectionSites: InjectionSite[]` (default `[]` = no restriction)
`POST /api/medications` + `PUT /api/medications/{id}` accept both as
optional fields (field-by-field; `false` / `[]` are valid explicit
values — `false` deactivates tracking, `[]` clears the per-med list).
### 4. New user-level endpoint
`GET` / `PATCH /api/auth/me/injection-site-prefs`
```
{ "globalExcludedInjectionSites": InjectionSite[] }
```
The PATCH hard-sets the deny-list (empty array clears it). 60/min rate
limit, same envelope as `unit-preference`.
## Contract source
All of the above is in `docs/api/openapi.yaml` (regenerated this
release). No field was removed or renamed; every addition is optional /
defaulted. Existing iOS builds keep working untouched.
@@ -0,0 +1,67 @@
# v1.8.5 — server folds high-frequency mean metrics nightly (informational, no iOS change)
Direction: server → iOS. Status: informational + optional. No breaking
change. iOS needs zero changes for correctness.
## What changed on the server
The server now consolidates the high-frequency HealthKit "spot" metrics
into one canonical daily row per metric per day, server-side, on a nightly
cron — the same treatment cumulative metrics (steps, energy, distance)
already get. The daily reduction is the **MEAN** of the day's samples,
written at local-noon under the existing
`stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` externalId shape, and the
per-sample rows are soft-deleted (tombstoned, pruned later).
v1.8.5 extends the consolidated set to cover the gait/mobility metrics that
previously piled up at sampling granularity:
- `WalkingAsymmetryPercentage` (WALKING_ASYMMETRY)
- `WalkingDoubleSupportPercentage` (WALKING_DOUBLE_SUPPORT)
- `AppleWalkingSteadiness` (WALKING_STEADINESS)
- `WalkingHeartRateAverage` (WALKING_HEART_RATE_AVERAGE)
joining the metrics already folded since v1.7.0: `RespiratoryRate`,
`EnvironmentalAudioExposure`, `HeadphoneAudioExposure`, `WalkingSpeed`,
`WalkingStepLength`.
The fold now runs on the nightly maintenance tick (alongside the cumulative
drain), not just at worker boot. A 36-hour grace window keeps the current
day's in-flight watch syncs raw so the live "today" view stays current.
## What this means for iOS
**Nothing is required.** Keep sending these metrics as per-sample rows with
opaque UUID externalIds exactly as today. The server folds them nightly;
the daily read path averages over the single consolidated row, so the
metric pages and detail history read consistently with no client change.
Existing accumulated per-sample rows are back-consolidated automatically on
the first deploy by the boot-time discovery pass — no migration, no manual
backfill.
## Pulse is unchanged — still raw
Regular heart-rate / `PULSE` is deliberately NOT consolidated. It is a
discrete clinical reading and feeds correlation + scatter analytics, which
read raw PULSE rows. Keep posting PULSE per-sample as today. Its display
already daily-averages via the read-path AVG; storage stays raw.
## Optional future optimisation (not required, not yet scheduled)
If iOS later wants to cut upload volume for these mean metrics, it MAY send
them pre-aggregated as a daily value under the same `stats:` shape it
already uses for cumulative metrics — e.g.
`stats:HKQuantityTypeIdentifierWalkingSpeed:2026-06-01`. The server's batch
route already routes any `stats:*` externalId to the idempotent overwrite
path, so no server change is needed to accept it.
One caveat if iOS adopts this: for a mean metric the daily `stats:` value
must carry the **average**, not the sum. Compute it on-device with
`HKStatisticsCollectionQuery` using the `discreteAverage` option (NOT
`cumulativeSum`, which is correct only for the cumulative metrics). The
server-side nightly fold remains the safety net for any client not on that
contract, so this stays a pure bandwidth optimisation rather than a
correctness dependency.
No `docs/api/openapi.yaml` change — the batch contract is unchanged.
+27
View File
@@ -1,5 +1,32 @@
# Changelog
## [1.8.5] — 2026-06-01 — Insights reference panels, mood depth, injection tracking
### Added
- **A target reference panel on every Insights category page.** Each metric now shows the target band, where the latest reading sits inside it, a plain-language status, the guideline source behind the range, the 30-day average and a seven-day consistency strip — the same reference the Targets page carries, now on the page where you read the trend. Blood glucose surfaces one panel per context (fasting, post-meal, random, bedtime).
- **A statistics strip per metric** — minimum, maximum, median and mean — plus a dedicated page that lists every underlying reading with edit and delete, reachable from each Insights category.
- **Injection-site tracking and rotation.** Opt-in per medication (injections only): record the site after a dose, see the history on a body map, and get a rotation suggestion. Sites can be restricted per medication, and an account-level exclusion always wins.
- **Structured mood tags and a per-entry note**, with a notes timeline so the context behind a mood is kept alongside the score.
- **A richer mood page** — calendar heatmap, score distribution, weekday pattern, and correlations with sleep and activity.
- **A measurement-diversity nudge** that suggests logging at other times or days when readings cluster, for a fuller picture.
### Changed
- **The medication compliance display adapts to how often a dose is due** — a percentage for frequent schedules, a dose-by-dose adherence timeline for sparse ones, so an every-few-weeks medication reads sensibly.
- **The Trends section now follows the daily briefing**, charting the metrics the briefing actually flags rather than a fixed set.
- **Tightened the Insights category pages and rebalanced the overview** for a denser, calmer layout.
- **Gait and mobility metrics from Apple Health are consolidated to a daily mean overnight**, while discrete clinical readings (weight, blood pressure, glucose, mood, pulse) stay raw.
- **The health-record FHIR export codes HealthKit-only metrics under a dedicated code system** instead of the LOINC namespace, for FHIR R4 conformance and alignment with the native client.
### Fixed
- **A rolling-interval medication now surfaces its first dose at the start date** — including when that date is already in the past — so the dashboard and reminders no longer skip the opening dose.
- **The medication wizard no longer shows each time-of-day suggestion twice.**
- **Deleting a medication no longer hangs on a spinner.**
- **Mood entries with tags save atomically** — the entry and its tags commit together, and create and update return the saved tags immediately rather than after a refresh.
- **Accessibility:** the injection-site picker has a visible focus state and screen-reader labels; mood tag chips and the status pill meet the contrast and minimum tap-target guidelines.
## [1.8.4] — 2026-06-01 — Medication card accuracy + Insights polish
### Fixed
+59 -2
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.8.4
version: 1.8.5
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -2123,6 +2123,26 @@ components:
- ORAL
- INJECTION
- OTHER
trackInjectionSites:
description: Per-medication injection-site tracking opt-in. Default false. Only meaningful for an INJECTION delivery
form; when true the client prompts (skippably) for the site after a taken dose.
type: boolean
allowedInjectionSites:
description: Per-medication allowed / preferred injection sites. Empty array = no per-medication restriction (every site
offered). The effective pickable set is this list minus the user's global exclusion.
maxItems: 8
type: array
items:
type: string
enum:
- ABDOMEN_LEFT
- ABDOMEN_RIGHT
- ABDOMEN_UPPER_LEFT
- ABDOMEN_UPPER_RIGHT
- THIGH_LEFT
- THIGH_RIGHT
- UPPER_ARM_LEFT
- UPPER_ARM_RIGHT
notificationsEnabled:
type: boolean
liveActivityEnabled:
@@ -2303,6 +2323,26 @@ components:
- ORAL
- INJECTION
- OTHER
trackInjectionSites:
description: Per-medication injection-site tracking opt-in. Only meaningful for an INJECTION delivery form. Set false to
deactivate tracking.
type: boolean
allowedInjectionSites:
description: Per-medication allowed / preferred injection sites. Empty array clears the restriction. The effective
pickable set subtracts the user's global exclusion (deny wins).
maxItems: 8
type: array
items:
type: string
enum:
- ABDOMEN_LEFT
- ABDOMEN_RIGHT
- ABDOMEN_UPPER_LEFT
- ABDOMEN_UPPER_RIGHT
- THIGH_LEFT
- THIGH_RIGHT
- UPPER_ARM_LEFT
- UPPER_ARM_RIGHT
active:
type: boolean
notificationsEnabled:
@@ -2381,11 +2421,28 @@ components:
row.
type: string
maxLength: 128
injectionSite:
description: Optional injection site for a taken dose. Honoured only when the medication is an INJECTION with
site-tracking enabled; validated against the medication's effective allowed set (per-medication allowed
sites minus the user's global exclusion). A disallowed site returns 422. Omit to record the dose without a
site.
type: string
enum:
- ABDOMEN_LEFT
- ABDOMEN_RIGHT
- ABDOMEN_UPPER_LEFT
- ABDOMEN_UPPER_RIGHT
- THIGH_LEFT
- THIGH_RIGHT
- UPPER_ARM_LEFT
- UPPER_ARM_RIGHT
required:
- medicationId
description: Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second
sliding window when the key is absent. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh
the per-day compliance rollup, and — for one-shot medications — flip `active` to false.
the per-day compliance rollup, and — for one-shot medications — flip `active` to false. The optional
`injectionSite` is persisted only for an INJECTION medication with site-tracking enabled and is validated
against the medication's effective allowed set (422 on a disallowed value).
MedicationExtractRequest:
type: object
properties:
+1
View File
@@ -37,6 +37,7 @@ function summary(partial: Partial<DataSummary>): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
+133 -6
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "Eintrag löschen?",
"deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.",
"pageInfo": "Seite {page} von {total}",
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen."
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.",
"note": "Notiz",
"notePlaceholder": "Notiz dazu, wie du dich fühlst…",
"tagPicker": "Tags",
"tagCategory": {
"feelings": "Gefühle",
"sleep": "Schlaf",
"health": "Gesundheit",
"social": "Soziales",
"work": "Arbeit"
},
"tag": {
"happy": "glücklich",
"excited": "aufgeregt",
"grateful": "dankbar",
"relaxed": "entspannt",
"content": "zufrieden",
"tired": "müde",
"unsure": "unsicher",
"bored": "gelangweilt",
"tense": "angespannt",
"angry": "wütend",
"stressed": "gestresst",
"sad": "traurig",
"sleptWell": "gut geschlafen",
"sleptOk": "mäßig geschlafen",
"sleptPoorly": "schlecht geschlafen",
"earlyNight": "früh im Bett",
"workedOut": "trainiert",
"ateWell": "gesund gegessen",
"hydrated": "Wasser getrunken",
"walked": "spazieren",
"alcohol": "Alkohol",
"family": "Familie",
"friends": "Freunde",
"party": "Party",
"alone": "allein",
"productive": "produktiv",
"overtime": "Überstunden",
"dayOff": "Feierabend",
"travel": "Reise",
"sickDay": "Krankheitstag"
}
},
"medications": {
"title": "Medikamente",
@@ -547,6 +589,13 @@
"lastIntake": "Letzte Einnahme:",
"nextIntake": "Nächste Einnahme:",
"dayStreak": "Tage Serie",
"lastNDoses": "Letzte {total} Dosen: {taken} von {total}",
"doseStreak": "{count} in Folge",
"doseTimelineAriaLabel": "Jüngste Einnahmetreue",
"doseTaken": "Genommen",
"doseMissed": "Verpasst",
"doseSkipped": "Übersprungen",
"doseUpcoming": "Anstehend",
"taken": "Genommen",
"skipped": "Übersprungen",
"intakeToastTaken": "{name} als genommen erfasst",
@@ -945,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"injectionSiteRecommendedAriaLabel": "{site} – empfohlene nächste Stelle",
"siteAbdomenLeft": "Bauch, unten links",
"siteAbdomenRight": "Bauch, unten rechts",
"siteAbdomenUpperLeft": "Bauch, oben links",
@@ -1264,7 +1314,16 @@
"plannedSuffix": "geplant",
"drugLevelDisclosure": "Geschätzte Wirkstoffkurve anzeigen"
}
}
},
"logInjectionSiteTitle": "Injektionsstelle erfassen",
"logInjectionSiteDescription": "Wo hast du {name} gespritzt? Optional — du kannst dies überspringen.",
"logInjectionSiteSkip": "Überspringen",
"logInjectionSiteConfirm": "Stelle speichern",
"logInjectionSiteNoneAvailable": "Keine Injektionsstelle verfügbar — alle Stellen sind in deinen Einstellungen ausgeschlossen.",
"trackInjectionSitesToggle": "Injektionsstellen erfassen",
"trackInjectionSitesHint": "Nach einer eingenommenen Dosis nach der verwendeten Stelle fragen und die nächste Rotation vorschlagen.",
"allowedSitesLabel": "Erlaubte Stellen",
"allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten."
},
"charts": {
"loadingLabel": "Diagramm wird geladen…",
@@ -1275,6 +1334,13 @@
"bodyFat": "Körperfett",
"sleep": "Schlaf",
"steps": "Schritte",
"hrv": "HRV",
"restingHeartRate": "Ruhepuls",
"activeEnergy": "Aktive Energie",
"flights": "Treppen",
"distance": "Geh- + Laufstrecke",
"vo2Max": "VO₂max",
"bodyTemperature": "Körpertemperatur",
"noData": "Keine Daten im gewählten Zeitraum",
"bucketWeekly": "Wochendurchschnitt",
"bucketMonthly": "Monatsdurchschnitt",
@@ -1339,7 +1405,17 @@
"personalBaseline": "Dein Mittel",
"deltaVsBaseline": "{delta} vs. dein Mittel",
"deltaUnchanged": "entspricht deinem Mittel",
"deltaVsTarget": "{delta} vs. Ziel"
"deltaVsTarget": "{delta} vs. Ziel",
"weekdaysFull": {
"mon": "Montag",
"tue": "Dienstag",
"wed": "Mittwoch",
"thu": "Donnerstag",
"fri": "Freitag",
"sat": "Samstag",
"sun": "Sonntag"
},
"rangeBand": "Bereich"
},
"chart": {
"overlay": {
@@ -1887,7 +1963,22 @@
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.",
"skinTemperatureTitle": "Hauttemperatur",
"skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln."
}
},
"stats": {
"label": "Zusammenfassende Statistik",
"min": "Min",
"max": "Max",
"median": "Median",
"mean": "Mittelwert"
},
"showAllValues": "Alle Werte anzeigen",
"valuesPageTitle": "{metric} — alle Werte",
"valuesPageDescription": "Jeder einzelne Wert hinter dieser Kennzahl. Einträge lassen sich direkt bearbeiten oder entfernen.",
"diversity": {
"weekday": "Die meisten deiner {metric}-Werte fallen auf einen Wochentag. Messungen an anderen Tagen ergeben ein vollständigeres Bild.",
"timeOfDay": "Die meisten deiner {metric}-Werte fallen auf eine ähnliche Tageszeit. Unterschiedliche Zeiten ergeben ein vollständigeres Bild."
},
"valuesBack": "Zurück zu Insights"
},
"sleep": {
"title": "Schlaf",
@@ -2268,7 +2359,41 @@
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navWalkingSpeed": "Gehgeschwindigkeit"
"navWalkingSpeed": "Gehgeschwindigkeit",
"mood": {
"heatmapTitle": "Stimmungskalender",
"heatmapNoEntry": "Kein Eintrag",
"legendGreat": "Super",
"legendGood": "Gut",
"legendOkay": "Okay",
"legendLow": "Niedrig",
"distributionTitle": "Stimmungsverteilung",
"distributionDaysUnit": "Tage",
"weekdayTitle": "Durchschnitt nach Wochentag",
"weekdayNoData": "Keine Daten",
"weekdayBestWorst": "Bester Tag: {best} · Schwerster Tag: {worst}",
"tagsTitle": "Tags und Auslöser",
"tagAvgMood": "Durchschnittliche Stimmung mit diesem Tag",
"correlationsTitle": "Stimmung und andere Werte",
"correlationsDescription": "Wie sich deine Stimmung am selben Tag mit Schlaf, Aktivität und Puls bewegt.",
"correlation": {
"sleepTitle": "Stimmung vs. Schlaf",
"stepsTitle": "Stimmung vs. Aktivität",
"pulseTitle": "Stimmung vs. Puls",
"sleepAxis": "Schlaf (Min.)",
"stepsAxis": "Schritte",
"pulseAxis": "Puls (bpm)",
"strengthStrong": "Stark",
"strengthModerate": "Moderat",
"strengthWeak": "Schwach",
"strengthNone": "Keine",
"source": "{n} gepaarte Tage · r = {r}",
"emptyTitle": "Noch nicht genug gepaarte Daten",
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen."
},
"structuredTagsTitle": "Tag-Auswertung",
"notesTimelineTitle": "Notizen"
}
},
"thresholds": {
"defaultLabel": "Default",
@@ -3026,7 +3151,9 @@
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
"generate": "Erstellen",
"error": "Export fehlgeschlagen ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt."
},
"admin": {
"title": "Administration",
+133 -6
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "Delete entry?",
"deleteConfirmDescription": "This mood entry will be permanently deleted.",
"pageInfo": "Page {page} of {total}",
"loginRequired": "Please sign in to view mood entries."
"loginRequired": "Please sign in to view mood entries.",
"note": "Note",
"notePlaceholder": "Add a note about how you're feeling…",
"tagPicker": "Tags",
"tagCategory": {
"feelings": "Feelings",
"sleep": "Sleep",
"health": "Health",
"social": "Social",
"work": "Work"
},
"tag": {
"happy": "happy",
"excited": "excited",
"grateful": "grateful",
"relaxed": "relaxed",
"content": "content",
"tired": "tired",
"unsure": "unsure",
"bored": "bored",
"tense": "tense",
"angry": "angry",
"stressed": "stressed",
"sad": "sad",
"sleptWell": "slept well",
"sleptOk": "slept okay",
"sleptPoorly": "slept poorly",
"earlyNight": "early night",
"workedOut": "worked out",
"ateWell": "ate well",
"hydrated": "hydrated",
"walked": "walked",
"alcohol": "alcohol",
"family": "family",
"friends": "friends",
"party": "party",
"alone": "alone",
"productive": "productive",
"overtime": "overtime",
"dayOff": "day off",
"travel": "travel",
"sickDay": "sick day"
}
},
"medications": {
"title": "Medications",
@@ -547,6 +589,13 @@
"lastIntake": "Last intake:",
"nextIntake": "Next intake:",
"dayStreak": "day streak",
"lastNDoses": "Last {total} doses: {taken} of {total}",
"doseStreak": "{count} in a row",
"doseTimelineAriaLabel": "Recent dose adherence",
"doseTaken": "Taken",
"doseMissed": "Missed",
"doseSkipped": "Skipped",
"doseUpcoming": "Upcoming",
"taken": "Taken",
"skipped": "Skipped",
"intakeToastTaken": "{name} recorded as taken",
@@ -945,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
"injectionSiteRecommendedAriaLabel": "{site} — recommended next site",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
@@ -1264,7 +1314,16 @@
"plannedSuffix": "planned",
"drugLevelDisclosure": "Show estimated drug-level curve"
}
}
},
"logInjectionSiteTitle": "Log injection site",
"logInjectionSiteDescription": "Where did you inject {name}? Optional — you can skip this.",
"logInjectionSiteSkip": "Skip",
"logInjectionSiteConfirm": "Save site",
"logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.",
"trackInjectionSitesToggle": "Track injection sites",
"trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.",
"allowedSitesLabel": "Allowed sites",
"allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered."
},
"charts": {
"loadingLabel": "Loading chart…",
@@ -1275,6 +1334,13 @@
"bodyFat": "Body Fat",
"sleep": "Sleep",
"steps": "Steps",
"hrv": "HRV",
"restingHeartRate": "Resting heart rate",
"activeEnergy": "Active energy",
"flights": "Flights climbed",
"distance": "Walking + running distance",
"vo2Max": "VO₂ max",
"bodyTemperature": "Body temperature",
"noData": "No data in selected time range",
"bucketWeekly": "Weekly avg",
"bucketMonthly": "Monthly avg",
@@ -1339,7 +1405,17 @@
"personalBaseline": "Your normal",
"deltaVsBaseline": "{delta} vs. your normal",
"deltaUnchanged": "matches your normal",
"deltaVsTarget": "{delta} vs. target"
"deltaVsTarget": "{delta} vs. target",
"weekdaysFull": {
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday",
"sun": "Sunday"
},
"rangeBand": "Range"
},
"chart": {
"overlay": {
@@ -1887,7 +1963,22 @@
"bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals; readings that stay high over time can signal diabetes or prediabetes.",
"skinTemperatureTitle": "Skin temperature",
"skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase."
}
},
"stats": {
"label": "Summary statistics",
"min": "Min",
"max": "Max",
"median": "Median",
"mean": "Mean"
},
"showAllValues": "Show all readings",
"valuesPageTitle": "{metric} — all readings",
"valuesPageDescription": "Every reading behind this metric. Edit or remove an entry directly.",
"diversity": {
"weekday": "Most of your {metric} readings land on one weekday. Logging on other days gives a fuller picture.",
"timeOfDay": "Most of your {metric} readings land at a similar time of day. Varying the time gives a fuller picture."
},
"valuesBack": "Back to insights"
},
"sleep": {
"title": "Sleep",
@@ -2268,7 +2359,41 @@
"description": "Walking-speed readings appear here once Apple Health syncs."
}
},
"navWalkingSpeed": "Walking Speed"
"navWalkingSpeed": "Walking Speed",
"mood": {
"heatmapTitle": "Mood calendar",
"heatmapNoEntry": "No entry",
"legendGreat": "Great",
"legendGood": "Good",
"legendOkay": "Okay",
"legendLow": "Low",
"distributionTitle": "Mood distribution",
"distributionDaysUnit": "days",
"weekdayTitle": "Average by weekday",
"weekdayNoData": "No data",
"weekdayBestWorst": "Best day: {best} · Toughest day: {worst}",
"tagsTitle": "Tags and triggers",
"tagAvgMood": "Average mood when tagged",
"correlationsTitle": "Mood and other metrics",
"correlationsDescription": "How your mood moves with sleep, activity, and pulse on the same day.",
"correlation": {
"sleepTitle": "Mood vs. sleep",
"stepsTitle": "Mood vs. activity",
"pulseTitle": "Mood vs. pulse",
"sleepAxis": "Sleep (min)",
"stepsAxis": "Steps",
"pulseAxis": "Pulse (bpm)",
"strengthStrong": "Strong",
"strengthModerate": "Moderate",
"strengthWeak": "Weak",
"strengthNone": "None",
"source": "{n} paired days · r = {r}",
"emptyTitle": "Not enough paired data yet",
"emptyDescription": "Log mood and this metric on the same days to see a correlation."
},
"structuredTagsTitle": "Tag breakdown",
"notesTimelineTitle": "Notes"
}
},
"thresholds": {
"defaultLabel": "Default",
@@ -3026,7 +3151,9 @@
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
"generate": "Generate",
"error": "Export failed ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them."
},
"admin": {
"title": "Administration",
+133 -6
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "¿Eliminar entrada?",
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.",
"pageInfo": "Página {page} de {total}",
"loginRequired": "Inicia sesión para ver tus estados de ánimo."
"loginRequired": "Inicia sesión para ver tus estados de ánimo.",
"note": "Nota",
"notePlaceholder": "Añade una nota sobre cómo te sientes…",
"tagPicker": "Etiquetas",
"tagCategory": {
"feelings": "Sentimientos",
"sleep": "Sueño",
"health": "Salud",
"social": "Social",
"work": "Trabajo"
},
"tag": {
"happy": "feliz",
"excited": "emocionado",
"grateful": "agradecido",
"relaxed": "relajado",
"content": "satisfecho",
"tired": "cansado",
"unsure": "inseguro",
"bored": "aburrido",
"tense": "tenso",
"angry": "enfadado",
"stressed": "estresado",
"sad": "triste",
"sleptWell": "dormí bien",
"sleptOk": "dormí regular",
"sleptPoorly": "dormí mal",
"earlyNight": "me acosté pronto",
"workedOut": "entrené",
"ateWell": "comí bien",
"hydrated": "bebí agua",
"walked": "caminé",
"alcohol": "alcohol",
"family": "familia",
"friends": "amigos",
"party": "fiesta",
"alone": "solo",
"productive": "productivo",
"overtime": "horas extra",
"dayOff": "día libre",
"travel": "viaje",
"sickDay": "día de baja"
}
},
"medications": {
"title": "Medicamentos",
@@ -547,6 +589,13 @@
"lastIntake": "Última toma:",
"nextIntake": "Próxima toma:",
"dayStreak": "días de racha",
"lastNDoses": "Últimas {total} dosis: {taken} de {total}",
"doseStreak": "{count} seguidas",
"doseTimelineAriaLabel": "Adherencia reciente a las dosis",
"doseTaken": "Tomada",
"doseMissed": "Omitida",
"doseSkipped": "Saltada",
"doseUpcoming": "Próxima",
"taken": "Tomada",
"skipped": "Omitida",
"intakeToastTaken": "{name} registrada como tomada",
@@ -945,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
"injectionSiteRecommendedAriaLabel": "{site}: sitio siguiente recomendado",
"siteAbdomenLeft": "Abdomen, inferior izquierdo",
"siteAbdomenRight": "Abdomen, inferior derecho",
"siteAbdomenUpperLeft": "Abdomen, superior izquierdo",
@@ -1264,7 +1314,16 @@
"plannedSuffix": "planificado",
"drugLevelDisclosure": "Mostrar curva estimada del principio activo"
}
}
},
"logInjectionSiteTitle": "Registrar sitio de inyección",
"logInjectionSiteDescription": "¿Dónde te inyectaste {name}? Opcional: puedes omitirlo.",
"logInjectionSiteSkip": "Omitir",
"logInjectionSiteConfirm": "Guardar sitio",
"logInjectionSiteNoneAvailable": "No hay ningún sitio de inyección disponible: todos los sitios están excluidos en tu configuración.",
"trackInjectionSitesToggle": "Registrar sitios de inyección",
"trackInjectionSitesHint": "Tras administrar una dosis, pregunta qué sitio se usó y sugiere la siguiente rotación.",
"allowedSitesLabel": "Sitios permitidos",
"allowedSitesHint": "Deja todos seleccionados para no aplicar restricciones. Los sitios que excluiste globalmente nunca se ofrecen."
},
"charts": {
"loadingLabel": "Cargando la gráfica…",
@@ -1275,6 +1334,13 @@
"bodyFat": "Grasa corporal",
"sleep": "Sueño",
"steps": "Pasos",
"hrv": "VFC",
"restingHeartRate": "FC en reposo",
"activeEnergy": "Energía activa",
"flights": "Pisos subidos",
"distance": "Distancia a pie + corriendo",
"vo2Max": "VO₂ máx",
"bodyTemperature": "Temperatura corporal",
"noData": "Sin datos en el periodo seleccionado",
"bucketWeekly": "Media semanal",
"bucketMonthly": "Media mensual",
@@ -1339,7 +1405,17 @@
"personalBaseline": "Tu media",
"deltaVsBaseline": "{delta} vs. tu media",
"deltaUnchanged": "coincide con tu media",
"deltaVsTarget": "{delta} vs. objetivo"
"deltaVsTarget": "{delta} vs. objetivo",
"weekdaysFull": {
"mon": "Lunes",
"tue": "Martes",
"wed": "Miércoles",
"thu": "Jueves",
"fri": "Viernes",
"sat": "Sábado",
"sun": "Domingo"
},
"rangeBand": "Rango"
},
"chart": {
"overlay": {
@@ -1887,7 +1963,22 @@
"bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.",
"skinTemperatureTitle": "Temperatura de la piel",
"skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual."
}
},
"stats": {
"label": "Estadísticas resumidas",
"min": "Mín",
"max": "Máx",
"median": "Mediana",
"mean": "Media"
},
"showAllValues": "Mostrar todas las mediciones",
"valuesPageTitle": "{metric} — todas las mediciones",
"valuesPageDescription": "Cada medición detrás de esta métrica. Edita o elimina una entrada directamente.",
"diversity": {
"weekday": "La mayoría de tus mediciones de {metric} caen en un mismo día de la semana. Registrar en otros días ofrece una imagen más completa.",
"timeOfDay": "La mayoría de tus mediciones de {metric} caen a una hora similar del día. Variar la hora ofrece una imagen más completa."
},
"valuesBack": "Volver a Insights"
},
"sleep": {
"title": "Sueño",
@@ -2268,7 +2359,41 @@
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
}
},
"navWalkingSpeed": "Velocidad al andar"
"navWalkingSpeed": "Velocidad al andar",
"mood": {
"heatmapTitle": "Calendario de ánimo",
"heatmapNoEntry": "Sin registro",
"legendGreat": "Genial",
"legendGood": "Bien",
"legendOkay": "Regular",
"legendLow": "Bajo",
"distributionTitle": "Distribución del ánimo",
"distributionDaysUnit": "días",
"weekdayTitle": "Promedio por día de la semana",
"weekdayNoData": "Sin datos",
"weekdayBestWorst": "Mejor día: {best} · Día más difícil: {worst}",
"tagsTitle": "Etiquetas y desencadenantes",
"tagAvgMood": "Ánimo promedio con esta etiqueta",
"correlationsTitle": "Ánimo y otras métricas",
"correlationsDescription": "Cómo se mueve tu ánimo con el sueño, la actividad y el pulso el mismo día.",
"correlation": {
"sleepTitle": "Ánimo vs. sueño",
"stepsTitle": "Ánimo vs. actividad",
"pulseTitle": "Ánimo vs. pulso",
"sleepAxis": "Sueño (min)",
"stepsAxis": "Pasos",
"pulseAxis": "Pulso (lpm)",
"strengthStrong": "Fuerte",
"strengthModerate": "Moderada",
"strengthWeak": "Débil",
"strengthNone": "Ninguna",
"source": "{n} días emparejados · r = {r}",
"emptyTitle": "Aún no hay suficientes datos emparejados",
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación."
},
"structuredTagsTitle": "Desglose de etiquetas",
"notesTimelineTitle": "Notas"
}
},
"thresholds": {
"defaultLabel": "Por defecto",
@@ -3026,7 +3151,9 @@
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
"generate": "Generar",
"error": "Error en la exportación ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera."
},
"admin": {
"title": "Administration",
+133 -6
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "Supprimer l’entrée ?",
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.",
"pageInfo": "Page {page} sur {total}",
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur."
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur.",
"note": "Note",
"notePlaceholder": "Ajoutez une note sur votre ressenti…",
"tagPicker": "Étiquettes",
"tagCategory": {
"feelings": "Émotions",
"sleep": "Sommeil",
"health": "Santé",
"social": "Social",
"work": "Travail"
},
"tag": {
"happy": "heureux",
"excited": "enthousiaste",
"grateful": "reconnaissant",
"relaxed": "détendu",
"content": "satisfait",
"tired": "fatigué",
"unsure": "incertain",
"bored": "ennuyé",
"tense": "tendu",
"angry": "en colère",
"stressed": "stressé",
"sad": "triste",
"sleptWell": "bien dormi",
"sleptOk": "sommeil moyen",
"sleptPoorly": "mal dormi",
"earlyNight": "couché tôt",
"workedOut": "fait du sport",
"ateWell": "bien mangé",
"hydrated": "bu de l'eau",
"walked": "marché",
"alcohol": "alcool",
"family": "famille",
"friends": "amis",
"party": "fête",
"alone": "seul",
"productive": "productif",
"overtime": "heures sup",
"dayOff": "jour de repos",
"travel": "voyage",
"sickDay": "jour de maladie"
}
},
"medications": {
"title": "Médicaments",
@@ -547,6 +589,13 @@
"lastIntake": "Dernière prise :",
"nextIntake": "Prochaine prise :",
"dayStreak": "jours de série",
"lastNDoses": "{total} dernières doses : {taken} sur {total}",
"doseStreak": "{count} d'affilée",
"doseTimelineAriaLabel": "Observance récente des doses",
"doseTaken": "Pris",
"doseMissed": "Manqué",
"doseSkipped": "Ignoré",
"doseUpcoming": "À venir",
"taken": "Pris",
"skipped": "Sauté",
"intakeToastTaken": "{name} enregistré comme pris",
@@ -945,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
"injectionSiteRecommendedAriaLabel": "{site} — prochain site recommandé",
"siteAbdomenLeft": "Abdomen, en bas à gauche",
"siteAbdomenRight": "Abdomen, en bas à droite",
"siteAbdomenUpperLeft": "Abdomen, en haut à gauche",
@@ -1264,7 +1314,16 @@
"plannedSuffix": "prévu",
"drugLevelDisclosure": "Afficher la courbe estimée du principe actif"
}
}
},
"logInjectionSiteTitle": "Enregistrer le site d'injection",
"logInjectionSiteDescription": "Où avez-vous injecté {name} ? Facultatif — vous pouvez l'ignorer.",
"logInjectionSiteSkip": "Ignorer",
"logInjectionSiteConfirm": "Enregistrer le site",
"logInjectionSiteNoneAvailable": "Aucun site d'injection disponible — tous les sites sont exclus dans vos réglages.",
"trackInjectionSitesToggle": "Suivre les sites d'injection",
"trackInjectionSitesHint": "Après la prise d'une dose, demander quel site a été utilisé et suggérer la prochaine rotation.",
"allowedSitesLabel": "Sites autorisés",
"allowedSitesHint": "Laissez tout sélectionné pour aucune restriction. Les sites exclus globalement ne sont jamais proposés."
},
"charts": {
"loadingLabel": "Chargement du graphique…",
@@ -1275,6 +1334,13 @@
"bodyFat": "Masse grasse",
"sleep": "Sommeil",
"steps": "Pas",
"hrv": "VFC",
"restingHeartRate": "FC au repos",
"activeEnergy": "Énergie active",
"flights": "Étages gravis",
"distance": "Distance marche + course",
"vo2Max": "VO₂ max",
"bodyTemperature": "Température corporelle",
"noData": "Aucune donnée sur la période sélectionnée",
"bucketWeekly": "Moyenne hebdomadaire",
"bucketMonthly": "Moyenne mensuelle",
@@ -1339,7 +1405,17 @@
"personalBaseline": "Ta moyenne",
"deltaVsBaseline": "{delta} vs. ta moyenne",
"deltaUnchanged": "correspond à ta moyenne",
"deltaVsTarget": "{delta} vs. cible"
"deltaVsTarget": "{delta} vs. cible",
"weekdaysFull": {
"mon": "Lundi",
"tue": "Mardi",
"wed": "Mercredi",
"thu": "Jeudi",
"fri": "Vendredi",
"sat": "Samedi",
"sun": "Dimanche"
},
"rangeBand": "Plage"
},
"chart": {
"overlay": {
@@ -1887,7 +1963,22 @@
"bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.",
"skinTemperatureTitle": "Température cutanée",
"skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel."
}
},
"stats": {
"label": "Statistiques récapitulatives",
"min": "Min",
"max": "Max",
"median": "Médiane",
"mean": "Moyenne"
},
"showAllValues": "Afficher toutes les mesures",
"valuesPageTitle": "{metric} — toutes les mesures",
"valuesPageDescription": "Chaque mesure derrière cette métrique. Modifiez ou supprimez une entrée directement.",
"diversity": {
"weekday": "La plupart de vos mesures de {metric} tombent le même jour de la semaine. Enregistrer d'autres jours donne une image plus complète.",
"timeOfDay": "La plupart de vos mesures de {metric} tombent à une heure similaire de la journée. Varier l'heure donne une image plus complète."
},
"valuesBack": "Retour aux Insights"
},
"sleep": {
"title": "Sommeil",
@@ -2268,7 +2359,41 @@
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navWalkingSpeed": "Vitesse de marche"
"navWalkingSpeed": "Vitesse de marche",
"mood": {
"heatmapTitle": "Calendrier d'humeur",
"heatmapNoEntry": "Aucune entrée",
"legendGreat": "Excellent",
"legendGood": "Bien",
"legendOkay": "Correct",
"legendLow": "Bas",
"distributionTitle": "Répartition de l'humeur",
"distributionDaysUnit": "jours",
"weekdayTitle": "Moyenne par jour de la semaine",
"weekdayNoData": "Aucune donnée",
"weekdayBestWorst": "Meilleur jour : {best} · Jour le plus difficile : {worst}",
"tagsTitle": "Étiquettes et déclencheurs",
"tagAvgMood": "Humeur moyenne avec cette étiquette",
"correlationsTitle": "Humeur et autres mesures",
"correlationsDescription": "Comment votre humeur évolue avec le sommeil, l'activité et le pouls le même jour.",
"correlation": {
"sleepTitle": "Humeur vs. sommeil",
"stepsTitle": "Humeur vs. activité",
"pulseTitle": "Humeur vs. pouls",
"sleepAxis": "Sommeil (min)",
"stepsAxis": "Pas",
"pulseAxis": "Pouls (bpm)",
"strengthStrong": "Forte",
"strengthModerate": "Modérée",
"strengthWeak": "Faible",
"strengthNone": "Aucune",
"source": "{n} jours appariés · r = {r}",
"emptyTitle": "Pas encore assez de données appariées",
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation."
},
"structuredTagsTitle": "Répartition des étiquettes",
"notesTimelineTitle": "Notes"
}
},
"thresholds": {
"defaultLabel": "Par défaut",
@@ -3026,7 +3151,9 @@
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
"generate": "Générer",
"error": "Échec de l’export ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie."
},
"admin": {
"title": "Administration",
+133 -6
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "Eliminare la voce?",
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.",
"pageInfo": "Pagina {page} di {total}",
"loginRequired": "Accedi per vedere le voci dell’umore."
"loginRequired": "Accedi per vedere le voci dell’umore.",
"note": "Nota",
"notePlaceholder": "Aggiungi una nota su come ti senti…",
"tagPicker": "Etichette",
"tagCategory": {
"feelings": "Emozioni",
"sleep": "Sonno",
"health": "Salute",
"social": "Sociale",
"work": "Lavoro"
},
"tag": {
"happy": "felice",
"excited": "entusiasta",
"grateful": "grato",
"relaxed": "rilassato",
"content": "soddisfatto",
"tired": "stanco",
"unsure": "incerto",
"bored": "annoiato",
"tense": "teso",
"angry": "arrabbiato",
"stressed": "stressato",
"sad": "triste",
"sleptWell": "dormito bene",
"sleptOk": "dormito così così",
"sleptPoorly": "dormito male",
"earlyNight": "a letto presto",
"workedOut": "allenato",
"ateWell": "mangiato bene",
"hydrated": "bevuto acqua",
"walked": "camminato",
"alcohol": "alcol",
"family": "famiglia",
"friends": "amici",
"party": "festa",
"alone": "da solo",
"productive": "produttivo",
"overtime": "straordinari",
"dayOff": "giorno libero",
"travel": "viaggio",
"sickDay": "giorno di malattia"
}
},
"medications": {
"title": "Farmaci",
@@ -547,6 +589,13 @@
"lastIntake": "Ultima assunzione:",
"nextIntake": "Prossima assunzione:",
"dayStreak": "giorni di serie",
"lastNDoses": "Ultime {total} dosi: {taken} su {total}",
"doseStreak": "{count} di fila",
"doseTimelineAriaLabel": "Aderenza recente alle dosi",
"doseTaken": "Assunta",
"doseMissed": "Mancata",
"doseSkipped": "Saltata",
"doseUpcoming": "In arrivo",
"taken": "Assunta",
"skipped": "Saltata",
"intakeToastTaken": "{name} registrata come assunta",
@@ -945,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
"injectionSiteRecommendedAriaLabel": "{site} — prossimo sito consigliato",
"siteAbdomenLeft": "Addome, basso a sinistra",
"siteAbdomenRight": "Addome, basso a destra",
"siteAbdomenUpperLeft": "Addome, alto a sinistra",
@@ -1264,7 +1314,16 @@
"plannedSuffix": "pianificato",
"drugLevelDisclosure": "Mostra la curva stimata del principio attivo"
}
}
},
"logInjectionSiteTitle": "Registra il sito di iniezione",
"logInjectionSiteDescription": "Dove hai iniettato {name}? Facoltativo — puoi saltarlo.",
"logInjectionSiteSkip": "Salta",
"logInjectionSiteConfirm": "Salva il sito",
"logInjectionSiteNoneAvailable": "Nessun sito di iniezione disponibile: tutti i siti sono esclusi nelle tue impostazioni.",
"trackInjectionSitesToggle": "Registra i siti di iniezione",
"trackInjectionSitesHint": "Dopo l'assunzione di una dose, chiedi quale sito è stato usato e suggerisci la rotazione successiva.",
"allowedSitesLabel": "Siti consentiti",
"allowedSitesHint": "Lascia tutti selezionati per nessuna restrizione. I siti esclusi globalmente non vengono mai proposti."
},
"charts": {
"loadingLabel": "Caricamento del grafico…",
@@ -1275,6 +1334,13 @@
"bodyFat": "Massa grassa",
"sleep": "Sonno",
"steps": "Passi",
"hrv": "HRV",
"restingHeartRate": "FC a riposo",
"activeEnergy": "Energia attiva",
"flights": "Piani saliti",
"distance": "Distanza camminata + corsa",
"vo2Max": "VO₂ max",
"bodyTemperature": "Temperatura corporea",
"noData": "Nessun dato nel periodo selezionato",
"bucketWeekly": "Media settimanale",
"bucketMonthly": "Media mensile",
@@ -1339,7 +1405,17 @@
"personalBaseline": "La tua media",
"deltaVsBaseline": "{delta} vs. la tua media",
"deltaUnchanged": "corrisponde alla tua media",
"deltaVsTarget": "{delta} vs. obiettivo"
"deltaVsTarget": "{delta} vs. obiettivo",
"weekdaysFull": {
"mon": "Lunedì",
"tue": "Martedì",
"wed": "Mercoledì",
"thu": "Giovedì",
"fri": "Venerdì",
"sat": "Sabato",
"sun": "Domenica"
},
"rangeBand": "Intervallo"
},
"chart": {
"overlay": {
@@ -1887,7 +1963,22 @@
"bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.",
"skinTemperatureTitle": "Temperatura cutanea",
"skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale."
}
},
"stats": {
"label": "Statistiche riepilogative",
"min": "Min",
"max": "Max",
"median": "Mediana",
"mean": "Media"
},
"showAllValues": "Mostra tutte le misurazioni",
"valuesPageTitle": "{metric} — tutte le misurazioni",
"valuesPageDescription": "Ogni misurazione dietro questa metrica. Modifica o rimuovi una voce direttamente.",
"diversity": {
"weekday": "La maggior parte delle tue misurazioni di {metric} cade in un solo giorno della settimana. Registrare in altri giorni offre un quadro più completo.",
"timeOfDay": "La maggior parte delle tue misurazioni di {metric} cade in un orario simile della giornata. Variare l'orario offre un quadro più completo."
},
"valuesBack": "Torna a Insights"
},
"sleep": {
"title": "Sonno",
@@ -2268,7 +2359,41 @@
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
}
},
"navWalkingSpeed": "Velocità di cammino"
"navWalkingSpeed": "Velocità di cammino",
"mood": {
"heatmapTitle": "Calendario dell'umore",
"heatmapNoEntry": "Nessun dato",
"legendGreat": "Ottimo",
"legendGood": "Buono",
"legendOkay": "Discreto",
"legendLow": "Basso",
"distributionTitle": "Distribuzione dell'umore",
"distributionDaysUnit": "giorni",
"weekdayTitle": "Media per giorno della settimana",
"weekdayNoData": "Nessun dato",
"weekdayBestWorst": "Giorno migliore: {best} · Giorno più difficile: {worst}",
"tagsTitle": "Tag e fattori scatenanti",
"tagAvgMood": "Umore medio con questo tag",
"correlationsTitle": "Umore e altre metriche",
"correlationsDescription": "Come il tuo umore si muove con sonno, attività e battito nello stesso giorno.",
"correlation": {
"sleepTitle": "Umore vs. sonno",
"stepsTitle": "Umore vs. attività",
"pulseTitle": "Umore vs. battito",
"sleepAxis": "Sonno (min)",
"stepsAxis": "Passi",
"pulseAxis": "Battito (bpm)",
"strengthStrong": "Forte",
"strengthModerate": "Moderata",
"strengthWeak": "Debole",
"strengthNone": "Nessuna",
"source": "{n} giorni appaiati · r = {r}",
"emptyTitle": "Non ci sono ancora abbastanza dati appaiati",
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione."
},
"structuredTagsTitle": "Analisi etichette",
"notesTimelineTitle": "Note"
}
},
"thresholds": {
"defaultLabel": "Predefinito",
@@ -3026,7 +3151,9 @@
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
"generate": "Genera",
"error": "Esportazione non riuscita ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce."
},
"admin": {
"title": "Administration",
+133 -6
View File
@@ -461,7 +461,49 @@
"deleteConfirmTitle": "Usunąć wpis?",
"deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.",
"pageInfo": "Strona {page} z {total}",
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju."
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.",
"note": "Notatka",
"notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…",
"tagPicker": "Tagi",
"tagCategory": {
"feelings": "Uczucia",
"sleep": "Sen",
"health": "Zdrowie",
"social": "Społeczne",
"work": "Praca"
},
"tag": {
"happy": "szczęśliwy",
"excited": "podekscytowany",
"grateful": "wdzięczny",
"relaxed": "zrelaksowany",
"content": "zadowolony",
"tired": "zmęczony",
"unsure": "niepewny",
"bored": "znudzony",
"tense": "spięty",
"angry": "zły",
"stressed": "zestresowany",
"sad": "smutny",
"sleptWell": "dobrze spałem",
"sleptOk": "sen średni",
"sleptPoorly": "źle spałem",
"earlyNight": "wcześnie spać",
"workedOut": "trening",
"ateWell": "zdrowo zjadłem",
"hydrated": "piłem wodę",
"walked": "spacer",
"alcohol": "alkohol",
"family": "rodzina",
"friends": "znajomi",
"party": "impreza",
"alone": "sam",
"productive": "produktywny",
"overtime": "nadgodziny",
"dayOff": "dzień wolny",
"travel": "podróż",
"sickDay": "dzień chorobowy"
}
},
"medications": {
"title": "Leki",
@@ -547,6 +589,13 @@
"lastIntake": "Ostatnie przyjęcie:",
"nextIntake": "Następne przyjęcie:",
"dayStreak": "dni serii",
"lastNDoses": "Ostatnie {total} dawek: {taken} z {total}",
"doseStreak": "{count} z rzędu",
"doseTimelineAriaLabel": "Ostatnie przestrzeganie dawek",
"doseTaken": "Przyjęte",
"doseMissed": "Pominięte",
"doseSkipped": "Opuszczone",
"doseUpcoming": "Nadchodzące",
"taken": "Przyjęte",
"skipped": "Pominięte",
"intakeToastTaken": "{name} oznaczone jako przyjęte",
@@ -945,6 +994,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
"injectionSiteRecommendedAriaLabel": "{site} — zalecane następne miejsce",
"siteAbdomenLeft": "Brzuch, dolny lewy",
"siteAbdomenRight": "Brzuch, dolny prawy",
"siteAbdomenUpperLeft": "Brzuch, górny lewy",
@@ -1264,7 +1314,16 @@
"plannedSuffix": "zaplanowane",
"drugLevelDisclosure": "Pokaż szacowaną krzywą stężenia leku"
}
}
},
"logInjectionSiteTitle": "Zarejestruj miejsce iniekcji",
"logInjectionSiteDescription": "Gdzie wstrzyknięto {name}? Opcjonalnie — możesz to pominąć.",
"logInjectionSiteSkip": "Pomiń",
"logInjectionSiteConfirm": "Zapisz miejsce",
"logInjectionSiteNoneAvailable": "Brak dostępnego miejsca iniekcji — wszystkie miejsca są wykluczone w Twoich ustawieniach.",
"trackInjectionSitesToggle": "Rejestruj miejsca iniekcji",
"trackInjectionSitesHint": "Po przyjęciu dawki zapytaj, którego miejsca użyto, i zaproponuj kolejną rotację.",
"allowedSitesLabel": "Dozwolone miejsca",
"allowedSitesHint": "Pozostaw wszystkie zaznaczone, aby nie stosować ograniczeń. Miejsca wykluczone globalnie nigdy nie są proponowane."
},
"charts": {
"loadingLabel": "Ładowanie wykresu…",
@@ -1275,6 +1334,13 @@
"bodyFat": "Tkanka tłuszczowa",
"sleep": "Sen",
"steps": "Kroki",
"hrv": "HRV",
"restingHeartRate": "Tętno spoczynkowe",
"activeEnergy": "Energia czynna",
"flights": "Pokonane piętra",
"distance": "Dystans marsz + bieg",
"vo2Max": "VO₂ max",
"bodyTemperature": "Temperatura ciała",
"noData": "Brak danych w wybranym okresie",
"bucketWeekly": "Średnia tygodniowa",
"bucketMonthly": "Średnia miesięczna",
@@ -1339,7 +1405,17 @@
"personalBaseline": "Twoja średnia",
"deltaVsBaseline": "{delta} vs. twoja średnia",
"deltaUnchanged": "odpowiada twojej średniej",
"deltaVsTarget": "{delta} vs. cel"
"deltaVsTarget": "{delta} vs. cel",
"weekdaysFull": {
"mon": "Poniedziałek",
"tue": "Wtorek",
"wed": "Środa",
"thu": "Czwartek",
"fri": "Piątek",
"sat": "Sobota",
"sun": "Niedziela"
},
"rangeBand": "Zakres"
},
"chart": {
"overlay": {
@@ -1887,7 +1963,22 @@
"bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.",
"skinTemperatureTitle": "Temperatura skóry",
"skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego."
}
},
"stats": {
"label": "Statystyki podsumowujące",
"min": "Min",
"max": "Maks",
"median": "Mediana",
"mean": "Średnia"
},
"showAllValues": "Pokaż wszystkie pomiary",
"valuesPageTitle": "{metric} — wszystkie pomiary",
"valuesPageDescription": "Każdy pomiar stojący za tą metryką. Edytuj lub usuń wpis bezpośrednio.",
"diversity": {
"weekday": "Większość Twoich pomiarów {metric} przypada na jeden dzień tygodnia. Pomiary w inne dni dają pełniejszy obraz.",
"timeOfDay": "Większość Twoich pomiarów {metric} przypada o podobnej porze dnia. Różne pory dają pełniejszy obraz."
},
"valuesBack": "Powrót do Insights"
},
"sleep": {
"title": "Sen",
@@ -2268,7 +2359,41 @@
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
}
},
"navWalkingSpeed": "Prędkość chodu"
"navWalkingSpeed": "Prędkość chodu",
"mood": {
"heatmapTitle": "Kalendarz nastroju",
"heatmapNoEntry": "Brak wpisu",
"legendGreat": "Świetnie",
"legendGood": "Dobrze",
"legendOkay": "W porządku",
"legendLow": "Nisko",
"distributionTitle": "Rozkład nastroju",
"distributionDaysUnit": "dni",
"weekdayTitle": "Średnia wg dnia tygodnia",
"weekdayNoData": "Brak danych",
"weekdayBestWorst": "Najlepszy dzień: {best} · Najtrudniejszy dzień: {worst}",
"tagsTitle": "Tagi i wyzwalacze",
"tagAvgMood": "Średni nastrój z tym tagiem",
"correlationsTitle": "Nastrój a inne wskaźniki",
"correlationsDescription": "Jak Twój nastrój zmienia się wraz ze snem, aktywnością i tętnem tego samego dnia.",
"correlation": {
"sleepTitle": "Nastrój a sen",
"stepsTitle": "Nastrój a aktywność",
"pulseTitle": "Nastrój a tętno",
"sleepAxis": "Sen (min)",
"stepsAxis": "Kroki",
"pulseAxis": "Tętno (bpm)",
"strengthStrong": "Silna",
"strengthModerate": "Umiarkowana",
"strengthWeak": "Słaba",
"strengthNone": "Brak",
"source": "{n} sparowanych dni · r = {r}",
"emptyTitle": "Za mało sparowanych danych",
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację."
},
"structuredTagsTitle": "Podział tagów",
"notesTimelineTitle": "Notatki"
}
},
"thresholds": {
"defaultLabel": "Domyślne",
@@ -3026,7 +3151,9 @@
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
"generate": "Generuj",
"error": "Eksport nie powiódł się ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje."
},
"admin": {
"title": "Administration",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.8.4",
"version": "1.8.5",
"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",
@@ -0,0 +1,34 @@
-- v1.8.5 — per-medication injection-site tracking.
--
-- Makes injection-site tracking + rotation real. Prior to this release
-- the `injection_site` column on `medication_intake_events` was read in
-- several surfaces but never written; tracking was effectively dead.
--
-- Two additive, defaulted columns on `medications`:
--
-- - `track_injection_sites` — per-medication opt-in. Only meaningful
-- (and only offered in the UI) when `delivery_form = 'INJECTION'`.
-- Default false: the post-dose site prompt fires only when this is
-- true, and the prompt is always skippable. Deactivatable any time.
--
-- - `allowed_injection_sites` — per-medication allowed / preferred
-- sites. Empty = no per-medication restriction (every site offered).
-- The effective pickable set is this list MINUS the user-level
-- `users.global_excluded_injection_sites` deny-list (deny wins). The
-- intake write path validates a submitted site against the effective
-- set and rejects (422) a disallowed value.
--
-- Both columns are additive + defaulted; no backfill is required. The
-- `injection_site` enum type already exists (migration 0046).
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe.
--
-- Reversibility:
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "track_injection_sites";
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "allowed_injection_sites";
-- A roll-back loses the opt-in flag + the per-medication site list; the
-- application reads tolerate a missing column / default-empty value.
ALTER TABLE "medications"
ADD COLUMN IF NOT EXISTS "track_injection_sites" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "allowed_injection_sites" "injection_site"[] NOT NULL DEFAULT ARRAY[]::"injection_site"[];
@@ -0,0 +1,30 @@
-- v1.8.5 — user-level global injection-site exclusion.
--
-- A per-user deny-list of injection sites. Sites listed here are never
-- offered for ANY medication's injection-site picker, and the intake
-- write path rejects (422) a submitted site that lands on this list —
-- even when the per-medication `medications.allowed_injection_sites`
-- lists it as preferred. Deny always wins.
--
-- Typical use: a user develops lipohypertrophy at one site and wants it
-- excluded across every injectable they track, without editing each
-- medication's allowed list individually.
--
-- Single additive, defaulted enum-array column on `users`. Default
-- `'{}'` = no global exclusion (the pre-v1.8.5 behaviour). No backfill.
-- The `injection_site` enum type already exists (migration 0046).
--
-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe.
--
-- Reversibility:
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "global_excluded_injection_sites";
-- A roll-back loses the global deny-list (every site becomes pickable
-- again, subject to each medication's own allowed list); the
-- application reads tolerate a missing column / default-empty value.
--
-- No index — reads are per-user at request time (`WHERE id = $1`),
-- matching `insights_exclude_metrics` and the other display-pref
-- columns on the same table.
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "global_excluded_injection_sites" "injection_site"[] NOT NULL DEFAULT ARRAY[]::"injection_site"[];
@@ -0,0 +1,252 @@
-- v1.8.5 — structured mood-tag taxonomy.
--
-- Adds a global Category -> Tag catalog plus a per-entry join, modelled
-- on the standalone mood diary's structured taxonomy. Purely additive:
-- the legacy flat `mood_entries.tags` JSON column is untouched and keeps
-- working for every existing row, the GLP-1 quick-tag chips, and the
-- Telegram / MoodLog sync. The structured taxonomy is a second, richer
-- capture surface; the tag-frequency / lift breakdown folds the two
-- axes together.
--
-- `label_key` columns carry an i18n message key (resolved client-side
-- against all six locales) rather than a hard-coded label, so the seeded
-- catalog renders localised everywhere. The seed at the bottom uses
-- deterministic ids + ON CONFLICT upsert so re-running is idempotent.
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "mood_entry_tag_links";
-- DROP TABLE IF EXISTS "mood_tags";
-- DROP TABLE IF EXISTS "mood_tag_categories";
-- CreateTable
CREATE TABLE "mood_tag_categories" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label_key" TEXT NOT NULL,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "mood_tag_categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mood_tags" (
"id" TEXT NOT NULL,
"category_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label_key" TEXT NOT NULL,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "mood_tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mood_entry_tag_links" (
"mood_entry_id" TEXT NOT NULL,
"mood_tag_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "mood_entry_tag_links_pkey" PRIMARY KEY ("mood_entry_id","mood_tag_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "mood_tag_categories_key_key" ON "mood_tag_categories"("key");
-- CreateIndex
CREATE UNIQUE INDEX "mood_tags_key_key" ON "mood_tags"("key");
-- CreateIndex
CREATE INDEX "mood_tags_category_id_sort_order_idx" ON "mood_tags"("category_id", "sort_order");
-- CreateIndex
CREATE INDEX "mood_entry_tag_links_mood_tag_id_idx" ON "mood_entry_tag_links"("mood_tag_id");
-- AddForeignKey
ALTER TABLE "mood_tags" ADD CONSTRAINT "mood_tags_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "mood_tag_categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mood_entry_tag_links" ADD CONSTRAINT "mood_entry_tag_links_mood_entry_id_fkey" FOREIGN KEY ("mood_entry_id") REFERENCES "mood_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mood_entry_tag_links" ADD CONSTRAINT "mood_entry_tag_links_mood_tag_id_fkey" FOREIGN KEY ("mood_tag_id") REFERENCES "mood_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Seed the default taxonomy (idempotent upsert by `key`). Mirrors
-- `src/lib/mood/tag-taxonomy.ts` — keep the two in sync if either
-- changes.
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_feelings', 'feelings', 'mood.tagCategory.feelings', 'Heart', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_happy', 'mtc_feelings', 'happy', 'mood.tag.happy', 'Smile', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_excited', 'mtc_feelings', 'excited', 'mood.tag.excited', 'Zap', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_grateful', 'mtc_feelings', 'grateful', 'mood.tag.grateful', 'HandHeart', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_relaxed', 'mtc_feelings', 'relaxed', 'mood.tag.relaxed', 'CloudSun', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_content', 'mtc_feelings', 'content', 'mood.tag.content', 'ThumbsUp', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_tired', 'mtc_feelings', 'tired', 'mood.tag.tired', 'Moon', 5, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_unsure', 'mtc_feelings', 'unsure', 'mood.tag.unsure', 'HelpCircle', 6, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_bored', 'mtc_feelings', 'bored', 'mood.tag.bored', 'Meh', 7, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_tense', 'mtc_feelings', 'tense', 'mood.tag.tense', 'AlertTriangle', 8, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_angry', 'mtc_feelings', 'angry', 'mood.tag.angry', 'Flame', 9, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_stressed', 'mtc_feelings', 'stressed', 'mood.tag.stressed', 'Brain', 10, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_sad', 'mtc_feelings', 'sad', 'mood.tag.sad', 'Frown', 11, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_sleep', 'sleep', 'mood.tagCategory.sleep', 'BedDouble', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_slept_well', 'mtc_sleep', 'slept_well', 'mood.tag.sleptWell', 'Moon', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_slept_ok', 'mtc_sleep', 'slept_ok', 'mood.tag.sleptOk', 'CloudMoon', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_slept_poorly', 'mtc_sleep', 'slept_poorly', 'mood.tag.sleptPoorly', 'MoonStar', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_early_night', 'mtc_sleep', 'early_night', 'mood.tag.earlyNight', 'Clock', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_health', 'health', 'mood.tagCategory.health', 'HeartPulse', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_worked_out', 'mtc_health', 'worked_out', 'mood.tag.workedOut', 'Dumbbell', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_ate_well', 'mtc_health', 'ate_well', 'mood.tag.ateWell', 'Apple', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_hydrated', 'mtc_health', 'hydrated', 'mood.tag.hydrated', 'GlassWater', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_walked', 'mtc_health', 'walked', 'mood.tag.walked', 'Footprints', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_alcohol', 'mtc_health', 'alcohol', 'mood.tag.alcohol', 'Wine', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_social', 'social', 'mood.tagCategory.social', 'Users', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_family', 'mtc_social', 'family', 'mood.tag.family', 'Home', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_friends', 'mtc_social', 'friends', 'mood.tag.friends', 'Users', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_party', 'mtc_social', 'party', 'mood.tag.party', 'PartyPopper', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_alone', 'mtc_social', 'alone', 'mood.tag.alone', 'User', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_work', 'work', 'mood.tagCategory.work', 'Briefcase', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_productive', 'mtc_work', 'productive', 'mood.tag.productive', 'CheckCircle', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_overtime', 'mtc_work', 'overtime', 'mood.tag.overtime', 'Clock', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_day_off', 'mtc_work', 'day_off', 'mood.tag.dayOff', 'LogOut', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_travel', 'mtc_work', 'travel', 'mood.tag.travel', 'Plane', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_sick_day', 'mtc_work', 'sick_day', 'mood.tag.sickDay', 'Thermometer', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
+100
View File
@@ -43,6 +43,13 @@ model User {
// behaviour for every existing row.
insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics")
// v1.8.5 — user-level injection-site deny-list. Sites listed here are
// never offered for ANY medication's injection-site picker and the
// intake write path 422s a submitted site that lands on this list,
// even when the per-medication `allowedInjectionSites` lists it as
// preferred (deny always wins). Default `[]` = no global exclusion.
globalExcludedInjectionSites InjectionSite[] @default([]) @map("global_excluded_injection_sites")
// Telegram notifications (per-user)
telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted
telegramChatId String? @map("telegram_chat_id")
@@ -917,6 +924,19 @@ model Medication {
/// A one-time injection is `oneShot = true` + `deliveryForm =
/// INJECTION`. The migration backfills ORAL for every existing row.
deliveryForm MedicationDeliveryForm @default(ORAL) @map("delivery_form")
/// v1.8.5 — per-medication injection-site tracking opt-in. Only
/// meaningful (and only offered in the UI) when `deliveryForm ===
/// INJECTION`. Default false: after marking a dose taken the client
/// prompts for the site only when this is true, and the prompt is
/// always skippable (never blocking). Deactivatable at any time.
trackInjectionSites Boolean @default(false) @map("track_injection_sites")
/// v1.8.5 — per-medication allowed / preferred injection sites. Empty
/// = no per-medication restriction (every site is offered). The
/// effective pickable set is this list MINUS the user-level
/// `globalExcludedInjectionSites` deny-list (deny always wins). The
/// intake write path validates a submitted site against the effective
/// set and 422s on a disallowed value.
allowedInjectionSites InjectionSite[] @default([]) @map("allowed_injection_sites")
/// v1.7.0 — iOS Live Activity opt-in for this medication's reminders.
/// Default false; existing rows backfill false. The server stores +
/// echoes the flag; the iOS client owns the ActivityKit lifecycle. No
@@ -1540,6 +1560,13 @@ model MoodEntry {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// v1.8.5 — structured-tag links. Additive alongside the legacy flat
// `tags` JSON string: an entry can carry both (a free-text tag the user
// typed AND a structured tag they picked from the taxonomy). The
// aggregates read whichever is present; existing rows keep their flat
// `tags` untouched.
tagLinks MoodEntryTagLink[]
@@unique([userId, date, moodLoggedAt])
@@index([userId, date])
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
@@ -1548,6 +1575,79 @@ model MoodEntry {
@@map("mood_entries")
}
// ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ────────────────────────
//
// Structured, icon-bearing tag catalog modelled on the standalone mood
// diary's Category → TagItem shape. Global reference data (one shared
// catalog for the deployment, not per-user): a self-hoster's account
// inherits the seeded categories the same way the metric-type enum is
// shared. `MoodEntryTagLink` is the per-entry join.
//
// Backward-compat: this is purely ADDITIVE. The legacy flat
// `MoodEntry.tags` (a JSON string array of free-text keys) keeps working
// unchanged — the GLP-1 quick-tag chips, the Telegram/MoodLog sync, and
// every existing row stay on the flat axis. The structured taxonomy is a
// second, richer capture surface that the tag-frequency / lift breakdown
// folds in next to the flat tags (`mood-aggregates.ts`).
//
// `labelKey` carries an i18n key (resolved client-side against all six
// locales) rather than a hard-coded German label, so the seeded catalog
// renders localised everywhere. `icon` is a Lucide icon name rendered by
// the capture UI + breakdown.
model MoodTagCategory {
id String @id @default(cuid())
/// Stable machine key (e.g. `feelings`). Unique across the catalog so
/// the seed migration can upsert idempotently.
key String @unique
/// i18n message key for the category label (`mood.tagCategory.<key>`).
labelKey String @map("label_key")
/// Lucide icon name for the category header.
icon String?
sortOrder Int @default(0) @map("sort_order")
/// Soft-hide a category without deleting its historical links.
isActive Boolean @default(true) @map("is_active")
tags MoodTag[]
@@map("mood_tag_categories")
}
model MoodTag {
id String @id @default(cuid())
categoryId String @map("category_id")
/// Stable machine key (e.g. `happy`). Unique across the catalog.
key String @unique
/// i18n message key for the tag label (`mood.tag.<key>`).
labelKey String @map("label_key")
/// Lucide icon name for the tag chip.
icon String?
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
category MoodTagCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
links MoodEntryTagLink[]
@@index([categoryId, sortOrder])
@@map("mood_tags")
}
// Join: which structured tags a mood entry carries. Composite PK keeps
// the pairing unique; cascade on both sides so deleting an entry (or a
// catalog tag) cleans up its links. Distinct from the flat `tags` JSON
// column on `MoodEntry`.
model MoodEntryTagLink {
moodEntryId String @map("mood_entry_id")
moodTagId String @map("mood_tag_id")
createdAt DateTime @default(now()) @map("created_at")
moodEntry MoodEntry @relation(fields: [moodEntryId], references: [id], onDelete: Cascade)
moodTag MoodTag @relation(fields: [moodTagId], references: [id], onDelete: Cascade)
@@id([moodEntryId, moodTagId])
@@index([moodTagId])
@@map("mood_entry_tag_links")
}
// ─── Mood Reminder Dispatch Ledger (v0.5.4 ios-coord) ─────
//
// Idempotency guard for the daily 22:00 MOOD_REMINDER push. The
@@ -0,0 +1,129 @@
/**
* v1.8.5 — user-level injection-site preferences endpoint.
*
* GET /api/auth/me/injection-site-prefs
* → `{ globalExcludedInjectionSites: InjectionSite[] }`
*
* PATCH /api/auth/me/injection-site-prefs
* Body: `{ globalExcludedInjectionSites: InjectionSite[] }`
* → the resolved next state (idempotent hard-set).
*
* The global exclusion is a per-user deny-list. Sites listed here are
* never offered for ANY medication's injection-site picker, and the
* intake write path rejects (422) a submitted site that lands on this
* list — even when a medication's `allowedInjectionSites` lists it as
* preferred (deny always wins).
*
* Mirrors the `unit-preference` per-user-scalar pattern: 60/min rate
* limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
* field-by-field write (no mass assignment).
*/
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
injectionSiteEnum,
INJECTION_SITE_VALUES,
type InjectionSiteValue,
} from "@/lib/validations/medication";
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
const patchBodySchema = z.object({
// Dedup at write time; cap at the full enum size. An empty array
// clears the exclusion.
globalExcludedInjectionSites: z
.array(injectionSiteEnum)
.max(INJECTION_SITE_VALUES.length)
.meta({
description:
"User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion.",
}),
});
interface InjectionSitePrefsResponse {
globalExcludedInjectionSites: InjectionSiteValue[];
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.injection-site-prefs.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { globalExcludedInjectionSites: true },
});
const payload: InjectionSitePrefsResponse = {
globalExcludedInjectionSites: (row?.globalExcludedInjectionSites ??
[]) as InjectionSiteValue[],
};
return apiSuccess(payload);
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`injection-site-prefs:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.injection-site-prefs.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
// Dedup while preserving canonical order from the enum tuple.
const submitted = new Set(parsed.data.globalExcludedInjectionSites);
const next = INJECTION_SITE_VALUES.filter((s) => submitted.has(s));
await prisma.user.update({
where: { id: user.id },
data: { globalExcludedInjectionSites: next },
});
await auditLog("user.injection-site-prefs.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { count: next.length },
});
annotate({
action: { name: "auth.me.injection-site-prefs.patch" },
meta: { excluded_count: next.length },
});
const payload: InjectionSitePrefsResponse = {
globalExcludedInjectionSites: next,
};
return apiSuccess(payload);
});
+11
View File
@@ -328,6 +328,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
// legacy fallback below covers the boot-backfill convergence
// window when an existing bucket pre-dates v1.4.39.
sumValue: true,
minValue: true,
maxValue: true,
},
});
// v1.4.39.2 — coverage-mismatch fallback. v1.4.39.1 wired the
@@ -417,6 +419,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
mean: true,
count: true,
sumValue: true,
minValue: true,
maxValue: true,
},
});
} catch (err) {
@@ -454,6 +458,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
: r.mean,
measuredAt: r.bucketStart.toISOString(),
count: r.count,
// v1.8.5 — per-day min / max so the chart can shade an
// Apple-Health-style range band around the daily mean line.
// Omitted for cumulative metrics (steps, distance, …) where the
// daily value is a SUM, not a central tendency, so an intra-day
// spread band carries no meaning.
minValue: useSum ? undefined : r.minValue,
maxValue: useSum ? undefined : r.maxValue,
}));
annotate({
action: { name: "measurement.list" },
@@ -115,6 +115,13 @@ async function callRoute(): Promise<Record<string, unknown>> {
return body.data.dailyCompliance as Record<string, unknown>;
}
async function callRouteData(): Promise<Record<string, unknown>> {
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(200);
const body = await res.json();
return body.data as Record<string, unknown>;
}
describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
it("classifies both doses of a single-row twice-daily schedule as on_time (green cell)", async () => {
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
@@ -239,3 +246,91 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
expect(entry!.onTime).toBe(1);
});
});
// v1.8.5 — the additive `complianceDisplay` block. The server decides the
// render mode from expected-dose density: a daily med keeps the percentage
// bars (`"percent"`), a sparse rolling/long-interval med swaps to the
// per-dose timeline (`"timeline"`). The existing `compliance7` /
// `compliance30` fields stay untouched.
describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
it("daily med → mode: percent", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
medication([
{
id: "sched-1",
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
]) as never,
);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
const data = await callRouteData();
const display = data.complianceDisplay as {
mode: string;
expected30: number;
minStableDoses: number;
};
expect(display.mode).toBe("percent");
expect(display.expected30).toBeGreaterThanOrEqual(display.minStableDoses);
// The legacy fields stay on the wire.
expect(data.compliance7).toBeDefined();
expect(data.compliance30).toBeDefined();
});
it("35-day-interval med → mode: timeline", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
{
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000),
startsOn: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
endsOn: null,
oneShot: false,
schedules: [
{
id: "sched-1",
windowStart: "10:00",
windowEnd: "11:00",
timesOfDay: ["10:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: 35,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never,
);
// One past intake re-anchors the rolling cadence ~25 days ago, so the
// next slot lands beyond the 30-day window → <4 expected → timeline.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
skipped: false,
scheduledFor: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
},
] as never);
const data = await callRouteData();
const display = data.complianceDisplay as {
mode: string;
expected30: number;
minStableDoses: number;
};
expect(display.mode).toBe("timeline");
expect(display.expected30).toBeLessThan(display.minStableDoses);
});
});
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
buildComplianceDisplay,
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
@@ -141,6 +142,18 @@ export const GET = apiHandler(
{ medicationContext },
);
// v1.8.5 — the additive display block. `mode` is server-decided from
// expected-dose density: dense cadences keep the 7-/30-day percentage
// bars (`"percent"`), sparse cadences (weekly, bi-/tri-weekly, rolling
// 35-day injections) swap to the per-dose uptime strip (`"timeline"`).
// `compliance7` / `compliance30` above are untouched — iOS + the Health
// Score read them verbatim.
const complianceDisplay = buildComplianceDisplay(
mapped,
medication.schedules,
medicationContext,
);
// Build daily compliance map for heatmap/line chart (90 days)
const now = new Date();
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
@@ -273,9 +286,18 @@ export const GET = apiHandler(
entity_type: "medication",
entity_id: id,
},
meta: { compliance7: compliance7.rate, compliance30: compliance30.rate },
meta: {
compliance7: compliance7.rate,
compliance30: compliance30.rate,
displayMode: complianceDisplay.mode,
},
});
return apiSuccess({ compliance7, compliance30, dailyCompliance });
return apiSuccess({
compliance7,
compliance30,
dailyCompliance,
complianceDisplay,
});
},
);
+55 -1
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -13,6 +14,8 @@ import {
intakeSchema,
listIntakeEventsSchema,
} from "@/lib/validations/medication";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { withIdempotency } from "@/lib/idempotency";
import { consumeOneDose } from "@/lib/medications/inventory/service";
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
@@ -76,7 +79,51 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
return returnAllZodIssues(parsed.error, 422);
}
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
const { scheduledFor, takenAt, skipped, idempotencyKey, injectionSite } =
parsed.data;
// v1.8.5 — resolve + server-validate the optional injection site. Load
// the medication's delivery form + tracking opt-in + per-medication
// allowed sites, plus the user's global exclusion deny-list. A site
// outside the effective allowed set is a hard 422; a site on a
// non-injection / tracking-off med (or a skip) is silently dropped.
let resolvedInjectionSite: InjectionSiteKey | null = null;
if (injectionSite !== undefined) {
const [med, userRow] = await Promise.all([
prisma.medication.findUnique({
where: { id },
select: {
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
},
}),
prisma.user.findUnique({
where: { id: user.id },
select: { globalExcludedInjectionSites: true },
}),
]);
const resolution = resolveInjectionSiteForWrite({
submitted: injectionSite,
taken: !skipped,
deliveryForm: med?.deliveryForm ?? "ORAL",
trackInjectionSites: med?.trackInjectionSites ?? false,
allowedInjectionSites: (med?.allowedInjectionSites ??
[]) as InjectionSiteKey[],
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteKey[],
});
if (resolution.kind === "disallowed") {
annotate({
action: { name: "medication.intake.injection_site.disallowed" },
meta: { medication_id: id, site: resolution.site },
});
return apiError("Injection site is not allowed for this medication", 422, {
errorCode: "medications.intake.injection_site.disallowed",
});
}
resolvedInjectionSite = resolution.site;
}
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
@@ -163,6 +210,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
isExplicitSkip,
idempotencyKey: idempotencyKey ?? null,
createSource: "WEB",
// v1.8.5 — resolved + validated site (null unless a tracking-on
// injection taken write supplied an allowed site).
injectionSite: resolvedInjectionSite,
});
event = applied.row;
consumedTransition = applied.consumedTransition;
@@ -186,6 +236,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
skipped,
source: "WEB",
idempotencyKey: idempotencyKey ?? null,
// v1.8.5 — site only on a resolved taken-injection write.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
},
}),
// Reset snooze when medication is taken
+7
View File
@@ -130,6 +130,8 @@ export const PUT = apiHandler(
treatmentClass,
dosesPerUnit,
deliveryForm,
trackInjectionSites,
allowedInjectionSites,
active,
notificationsEnabled,
liveActivityEnabled,
@@ -222,6 +224,11 @@ export const PUT = apiHandler(
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites. Field-by-field; `false` / `[]` are valid explicit values
// (deactivate tracking / clear the per-med restriction).
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.7.0 — iOS reminder flags, field-by-field.
+68 -1
View File
@@ -51,6 +51,12 @@ import {
applyCanonicalSlotWrite,
resolveSlotInstantForWrite,
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -68,6 +74,11 @@ const bulkEntrySchema = z.object({
.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
// v1.8.5 — optional per-entry injection site. Validated against the
// medication's effective allowed set; a disallowed site marks the
// entry skipped (`injection_site_not_allowed`) without failing the
// whole batch, matching the bulk endpoint's per-entry contract.
injectionSite: injectionSiteEnum.optional(),
});
const bulkPayloadSchema = z.object({
@@ -157,9 +168,35 @@ async function postBulk(request: NextRequest): Promise<Response> {
const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId)));
const ownedMedications = await prisma.medication.findMany({
where: { id: { in: medicationIds }, userId: user.id },
select: { id: true },
// v1.8.5 — pull the injection-tracking fields so a per-entry
// `injectionSite` can be resolved + validated without an extra
// per-entry read.
select: {
id: true,
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
},
});
const ownedSet = new Set(ownedMedications.map((m) => m.id));
const medById = new Map(ownedMedications.map((m) => [m.id, m]));
// v1.8.5 — the user's global exclusion deny-list, loaded once for the
// whole batch (it is user-scoped, not per-medication). Only read when
// at least one entry actually carries an `injectionSite`, so the hot
// sync path (no site) pays for zero extra round-trips.
const batchHasInjectionSite = entries.some(
(e) => e.injectionSite !== undefined,
);
let globalExcluded: InjectionSiteValue[] = [];
if (batchHasInjectionSite) {
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { globalExcludedInjectionSites: true },
});
globalExcluded = (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteValue[];
}
const results: EntryResult[] = [];
let inserted = 0;
@@ -209,6 +246,31 @@ async function postBulk(request: NextRequest): Promise<Response> {
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
const isExplicitSkip = entry.skipped === true;
// v1.8.5 — resolve + validate the optional per-entry injection
// site. A disallowed site marks the entry skipped without failing
// the batch (per-entry contract); a non-injection / tracking-off /
// non-taken entry silently drops it.
let resolvedInjectionSite: InjectionSiteKey | null = null;
if (entry.injectionSite !== undefined) {
const med = medById.get(entry.medicationId);
const resolution = resolveInjectionSiteForWrite({
submitted: entry.injectionSite,
taken: isExplicitTaken,
deliveryForm: med?.deliveryForm ?? "ORAL",
trackInjectionSites: med?.trackInjectionSites ?? false,
allowedInjectionSites: (med?.allowedInjectionSites ??
[]) as InjectionSiteKey[],
globalExcludedInjectionSites: globalExcluded as InjectionSiteKey[],
});
if (resolution.kind === "disallowed") {
const reason = "injection_site_not_allowed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
resolvedInjectionSite = resolution.site;
}
if (canonicalSlot) {
// Scheduled dose — converge onto the one canonical slot row through
// the shared upsert: H1 deterministic selection, C2 no-downgrade
@@ -226,6 +288,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
isExplicitSkip,
idempotencyKey: entry.idempotencyKey ?? null,
createSource: "API",
injectionSite: resolvedInjectionSite,
});
if (applied.noDowngradeNoOp) {
// C2 — pending echo onto an already-actioned slot. Report it as a
@@ -253,6 +316,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
skipped: entry.skipped,
source: "API",
idempotencyKey: entry.idempotencyKey ?? null,
// v1.8.5 — site only on a resolved taken-injection entry.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
},
});
inserted += 1;
+55 -1
View File
@@ -36,6 +36,11 @@ import {
type ComplianceBucket as RollupComplianceBucket,
} from "@/lib/rollups/medication-compliance-rollups";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -53,6 +58,10 @@ const updateSchema = z.object({
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.8.5 — optional injection site captured alongside a "taken"
// status toggle. Validated server-side against the medication's
// effective allowed set; ignored for skipped / snoozed.
injectionSite: injectionSiteEnum.optional(),
});
function startOfDayInTz(date: Date, tz: string): Date {
@@ -314,17 +323,58 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { intakeId, status, takenAt, snoozedUntil } = parsed.data;
const { intakeId, status, takenAt, snoozedUntil, injectionSite } =
parsed.data;
// v1.7.0 sync — a tombstoned intake 404s on a status toggle; the
// `deletedAt: null` filter refuses to mutate a soft-deleted row.
const existing = await prisma.medicationIntakeEvent.findFirst({
where: { id: intakeId, deletedAt: null },
include: {
medication: {
select: {
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
},
},
},
});
if (!existing || existing.userId !== user.id) {
return apiError("Intake event not found", 404);
}
// v1.8.5 — resolve + server-validate the optional injection site for a
// "taken" toggle. A site outside the medication's effective allowed
// set is a hard 422; non-injection / tracking-off / non-taken drops it.
let resolvedInjectionSite: InjectionSiteValue | null = null;
if (injectionSite !== undefined) {
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { globalExcludedInjectionSites: true },
});
const resolution = resolveInjectionSiteForWrite({
submitted: injectionSite,
taken: status === "taken",
deliveryForm: existing.medication.deliveryForm,
trackInjectionSites: existing.medication.trackInjectionSites,
allowedInjectionSites:
existing.medication.allowedInjectionSites as InjectionSiteValue[],
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteValue[],
});
if (resolution.kind === "disallowed") {
annotate({
action: { name: "medication.intake.injection_site.disallowed" },
meta: { medication_id: existing.medicationId, site: resolution.site },
});
return apiError("Injection site is not allowed for this medication", 422, {
errorCode: "medications.intake.injection_site.disallowed",
});
}
resolvedInjectionSite = resolution.site;
}
const userTzForHook = user.timezone ?? DEFAULT_TIMEZONE;
let updated;
@@ -338,6 +388,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
takenAt: takenAt ?? new Date(),
skipped: false,
syncVersion: { increment: 1 },
// v1.8.5 — persist the resolved site on the taken branch.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
},
}),
prisma.medication.update({
+6
View File
@@ -155,6 +155,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
treatmentClass,
dosesPerUnit,
deliveryForm,
trackInjectionSites,
allowedInjectionSites,
notificationsEnabled,
liveActivityEnabled,
criticalAlarmEnabled,
@@ -219,6 +221,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites; Prisma defaults to false / [] when omitted.
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
+41 -5
View File
@@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { replaceTagLinks } from "@/lib/mood/tag-links";
type RouteParams = { params: Promise<{ id: string }> };
@@ -133,10 +134,38 @@ export const PUT = apiHandler(
// monotonic value and paired clients reconcile higher-wins.
updateData.syncVersion = { increment: 1 };
const entry = await prisma.moodEntry.update({
where: { id },
data: updateData,
});
// v1.8.5 — update the entry and replace its structured-tag links in
// one transaction. A tag-link failure must roll the entry update back
// too — otherwise a client retry on the 5xx double-applies the edit
// (and the `syncVersion` increment). The tx client is threaded through
// the helper so both writes commit (or abort) together.
const { entry, persistedTagKeys } = await prisma.$transaction(
async (tx) => {
const updated = await tx.moodEntry.update({
where: { id },
data: updateData,
});
// Full replacement of the structured-tag link set when `tagKeys`
// is present in the body. `null` clears every link; an omitted
// field leaves the links untouched.
if (data.tagKeys !== undefined) {
await replaceTagLinks(id, data.tagKeys ?? [], tx);
}
// v1.8.5 — read the persisted link keys back so the update
// response mirrors the list GET shape exactly.
const links = await tx.moodEntryTagLink.findMany({
where: { moodEntryId: id },
select: { moodTag: { select: { key: true } } },
});
return {
entry: updated,
persistedTagKeys: links.map((link) => link.moodTag.key),
};
},
);
await auditLog("moodEntry.update", {
userId: user.id,
@@ -177,7 +206,14 @@ export const PUT = apiHandler(
});
}
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
return apiSuccess({
...entry,
tags: parseTags(entry.tags),
// v1.8.5 — surface the persisted structured-tag keys so a client
// hydrating from the update response renders the tag set without a
// refetch (shape-matches the list GET).
tagKeys: persistedTagKeys,
});
},
);
@@ -4,6 +4,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// v1.8.5 — the POST happy path writes the entry + its structured-tag
// links inside one `$transaction`. The mock hands the same client object
// to the interactive-transaction callback so the route's tx-scoped writes
// (entry create + tag-link read-back) resolve against the same fakes.
const txClient = {
moodEntry: {
create: vi.fn(),
},
moodEntryTagLink: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
@@ -11,10 +24,20 @@ vi.mock("@/lib/db", () => ({
count: vi.fn().mockResolvedValue(0),
create: vi.fn(),
},
moodEntryTagLink: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(
async (fn: (tx: typeof txClient) => unknown) => fn(txClient),
),
},
}));
vi.mock("@/lib/mood/tag-links", () => ({
createTagLinks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -31,6 +54,9 @@ vi.mock("@/lib/cache/invalidate", () => ({
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/moodlog/push", () => ({
pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -47,6 +73,9 @@ vi.mock("next/headers", () => ({
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { createTagLinks } from "@/lib/mood/tag-links";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -69,6 +98,20 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// Re-establish the interactive-transaction passthrough after the reset.
vi.mocked(prisma.$transaction).mockImplementation(
async (fn: unknown) => (fn as (tx: typeof txClient) => unknown)(txClient),
);
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
vi.mocked(createTagLinks).mockResolvedValue(undefined);
// `reset` blanks the post-commit best-effort mocks; restore the promise
// returns so `recompute(...)` awaits and `push(...).catch()` is callable.
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined);
vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({
pushed: 0,
skipped: 0,
status: "ok",
});
});
describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
@@ -174,3 +217,79 @@ describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
expect(res.status).toBe(422);
});
});
describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", () => {
const VALID_BODY = {
mood: "GUT",
moodLoggedAt: "2026-06-01T08:00:00.000Z",
tagKeys: ["happy"],
};
it("writes the entry + tag links through the transaction client", async () => {
txClient.moodEntry.create.mockResolvedValue({
id: "mood-1",
tags: null,
moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
mood: "GUT",
note: null,
source: "MANUAL",
date: "2026-06-01",
});
const res = await POST(postReq(VALID_BODY));
expect(res.status).toBe(201);
// The tag-link write runs against the same `$transaction` client that
// created the entry, so both commit (or roll back) together.
expect(createTagLinks).toHaveBeenCalledWith("mood-1", ["happy"], txClient);
});
it("returns the persisted tagKeys in the create response", async () => {
txClient.moodEntry.create.mockResolvedValue({
id: "mood-1",
tags: null,
moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
mood: "GUT",
note: null,
source: "MANUAL",
date: "2026-06-01",
});
txClient.moodEntryTagLink.findMany.mockResolvedValue([
{ moodTag: { key: "happy" } },
]);
const res = await POST(postReq(VALID_BODY));
expect(res.status).toBe(201);
const body = (await res.json()) as {
data: { id: string; tagKeys: string[] };
};
// Read-after-write parity: the create response carries the same
// `tagKeys` the list GET surfaces.
expect(body.data.tagKeys).toEqual(["happy"]);
});
it("rolls the entry back when the tag-link write fails (no commit)", async () => {
txClient.moodEntry.create.mockResolvedValue({
id: "mood-2",
tags: null,
moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
mood: "GUT",
note: null,
source: "MANUAL",
date: "2026-06-01",
});
// A failing tag-link write must propagate out of the transaction so
// Prisma aborts the entry create — the route surfaces it as a 5xx and
// the entry is never committed (no audit row, no rollup recompute).
vi.mocked(createTagLinks).mockRejectedValueOnce(new Error("link write"));
const res = await POST(postReq(VALID_BODY));
expect(res.status).toBe(500);
const { auditLog } = await import("@/lib/auth/audit");
expect(auditLog).not.toHaveBeenCalled();
const { recomputeMoodBucketsForEntry } = await import(
"@/lib/rollups/mood-rollups"
);
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
});
});
+63 -15
View File
@@ -22,6 +22,7 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
import { createTagLinks } from "@/lib/mood/tag-links";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -90,6 +91,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
orderBy: { [sortBy]: sortDir },
take: limit,
skip: offset,
// v1.8.5 — include the structured-tag link keys so the edit form
// can pre-populate the taxonomy picker.
include: {
tagLinks: {
select: { moodTag: { select: { key: true } } },
},
},
}),
prisma.moodEntry.count({ where }),
]);
@@ -99,9 +107,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: { total, limit, offset },
});
const entriesWithParsedTags = entries.map((e) => ({
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
...e,
tags: parseTags(e.tags),
// v1.8.5 — flat list of structured-tag keys attached to the entry.
tagKeys: tagLinks.map((link) => link.moodTag.key),
}));
return apiSuccess({
@@ -147,7 +157,7 @@ async function postMoodEntry(request: NextRequest) {
return returnAllZodIssues(parsed.error, 422);
}
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
const { mood, tags, tagKeys, note, moodLoggedAt, source } = parsed.data;
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
@@ -157,19 +167,47 @@ async function postMoodEntry(request: NextRequest) {
const score = getScoreForMood(mood);
try {
const entry = await prisma.moodEntry.create({
data: {
userId: user.id,
date,
tz,
mood,
score,
tags: tags ? JSON.stringify(tags) : null,
note: note ?? null,
source: source ?? "MANUAL",
moodLoggedAt,
// v1.8.5 — write the entry and its structured-tag links in one
// transaction. The links are user-intended content, not a cache, so a
// tag-link failure must roll the entry back too — otherwise a client
// retry on the 5xx mints a duplicate entry. The tx client is threaded
// through the helper so both writes commit (or abort) together.
const { entry, persistedTagKeys } = await prisma.$transaction(
async (tx) => {
const created = await tx.moodEntry.create({
data: {
userId: user.id,
date,
tz,
mood,
score,
tags: tags ? JSON.stringify(tags) : null,
note: note ?? null,
source: source ?? "MANUAL",
moodLoggedAt,
},
});
if (tagKeys && tagKeys.length > 0) {
// Unknown keys are dropped inside the helper (the catalog is the
// source of truth).
await createTagLinks(created.id, tagKeys, tx);
}
// v1.8.5 — read the persisted link keys back so the create
// response mirrors the list GET shape exactly (unknown keys
// already filtered out).
const links = await tx.moodEntryTagLink.findMany({
where: { moodEntryId: created.id },
select: { moodTag: { select: { key: true } } },
});
return {
entry: created,
persistedTagKeys: links.map((link) => link.moodTag.key),
};
},
});
);
await auditLog("moodEntry.create", {
userId: user.id,
@@ -225,7 +263,17 @@ async function postMoodEntry(request: NextRequest) {
// rejection in the Next.js runtime.
});
return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201);
return apiSuccess(
{
...entry,
tags: parseTags(entry.tags),
// v1.8.5 — surface the persisted structured-tag keys so a client
// hydrating from the create response renders the tag set without a
// refetch (shape-matches the list GET).
tagKeys: persistedTagKeys,
},
201,
);
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
+43
View File
@@ -0,0 +1,43 @@
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
fetchMoodAggregates,
type MoodAggregates,
} from "@/lib/insights/mood-aggregates";
export const dynamic = "force-dynamic";
/**
* v1.8.5 — pre-computed mood-insights aggregates for the Mood Insights
* page (heatmap, distribution, weekday pattern, tag breakdown,
* cross-metric correlations, summary headline).
*
* The page stays a client component for the interactive line chart but
* reads the heavy aggregates from here: the compute runs server-side
* once per 60-s cache window, the browser only ever receives pre-shaped
* data (no raw mood rows). This follows the v1.8.3 anti-freeze posture
* — a cheap cached read, never a synchronous LLM call.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const result = await cached(
caches.moodInsights as ServerCache<MoodAggregates>,
user.id,
() => fetchMoodAggregates(user.id),
annotate,
);
annotate({
action: { name: "mood.insights.read" },
meta: {
total_entries: result.summary.totalEntries,
heatmap_window_days: result.heatmap.windowDays,
tag_count: result.tags.length,
},
});
return apiSuccess(result);
});
+43
View File
@@ -0,0 +1,43 @@
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
/**
* v1.8.5 — structured mood-tag taxonomy catalog.
*
* Returns the active Category -> Tag tree the mood-logging form renders
* as a pick-from-catalog capture surface. Global reference data (one
* shared catalog for the deployment, seeded by migration 0101), so the
* read carries no per-user filter and no encryption. The `labelKey`
* fields resolve client-side against the active locale.
*/
export const GET = apiHandler(async () => {
// Auth-gated like every app route — the catalog is not public, but it
// is identical for every authenticated user.
await requireAuth();
const categories = await prisma.moodTagCategory.findMany({
where: { isActive: true },
orderBy: { sortOrder: "asc" },
select: {
key: true,
labelKey: true,
icon: true,
tags: {
where: { isActive: true },
orderBy: { sortOrder: "asc" },
select: { key: true, labelKey: true, icon: true },
},
},
});
annotate({
action: { name: "mood.tags.catalog.read" },
meta: { category_count: categories.length },
});
return apiSuccess({ categories });
});
+33
View File
@@ -97,6 +97,18 @@
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
/*
* Status-pill text tokens. The target status pill paints coloured text
* on a 10% same-hue tint; the raw Dracula green/orange/red would fail
* WCAG AA (4.5:1) for the "out" tone on the muted reference panel in
* dark mode and for every tone on a white card in light mode. These
* theme-aware tokens land each tone at or above AA on both `bg-card`
* and `bg-muted/30` in both themes while keeping the Dracula feel.
*/
--color-status-pill-in: var(--status-pill-in);
--color-status-pill-near: var(--status-pill-near);
--color-status-pill-out: var(--status-pill-out);
}
/*
@@ -167,6 +179,16 @@
--sidebar-border: #44475a;
--sidebar-ring: #bd93f9;
/*
* Status-pill text in dark mode. "in" / "near" keep the Dracula
* green / orange (both clear AA on the muted panel); "out" lifts off
* the raw `#ff5555`, which lands at 3.99:1 on `bg-muted/30`, to a
* lighter Dracula red that clears 4.5:1 on both card and panel.
*/
--status-pill-in: #50fa7b;
--status-pill-near: #ffb86c;
--status-pill-out: #ff8a8a;
color-scheme: dark;
}
@@ -212,6 +234,17 @@
--sidebar-border: #cfcfde;
--sidebar-ring: #644ac9;
/*
* Status-pill text in light mode (Alucard). The bright Dracula
* green / orange / red read 1.2–2.7:1 on a near-white tint; these
* darker tones — the same family already used for `--success` /
* `--warning` / the light-mode reds — clear AA on both the white card
* and the muted reference panel.
*/
--status-pill-in: #14720a;
--status-pill-near: #a34d14;
--status-pill-out: #b32d1e;
color-scheme: light;
}
+1
View File
@@ -25,6 +25,7 @@ export default function InsightsBlutzuckerPage() {
yAxisUnit="mg/dL"
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
targetSummarySlug="blood-glucose"
coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
+3
View File
@@ -16,6 +16,7 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { MoodInsightsSections } from "@/components/insights/mood/mood-insights-sections";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -107,6 +108,8 @@ export default function InsightsStimmungPage() {
<MetricTargetSummary slug="mood" />
<MoodInsightsSections />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Smile className="h-5 w-5" />}
+1
View File
@@ -225,6 +225,7 @@ export default function InsightsPage() {
)}
<TrendsRow
briefing={briefingPayload}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
+16
View File
@@ -13,6 +13,8 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
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";
@@ -114,6 +116,20 @@ export default function InsightsPulsPage() {
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="pulse"
statStrip={
<MetricStatStrip
summary={analytics?.summaries?.PULSE ?? null}
unit="bpm"
/>
}
diversityNudge={
<MeasurementDiversityNudge
measurementType="PULSE"
metricLabel={t("insights.pulseSectionTitle")}
timeZone={user?.timezone ?? undefined}
/>
}
showAllValuesType="PULSE"
>
<HealthChartDynamic
chartKey="pulse"
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { Button } from "@/components/ui/button";
import { MeasurementList } from "@/components/measurements/measurement-list";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.8.5 — `/insights/values/[type]`.
*
* The "show all readings" subpage for a single metric. The insights
* category pages link here with their `MeasurementType` as the route
* segment (e.g. `/insights/values/WEIGHT`); this page pins the existing
* `<MeasurementList>` to that type via `lockedType`, giving the user the
* full paginated, inline-editable raw-readings view without rebuilding
* any of the list machinery.
*
* A single dynamic route serves every metric — the enum guard rejects an
* unknown segment with a 404 rather than rendering an empty list for a
* typo'd type.
*/
export default function InsightsMetricValuesPage({
params,
}: {
params: Promise<{ type: string }>;
}) {
const { t } = useTranslations();
const { type } = use(params);
// Reject any segment that is not a real MeasurementType so a typo'd
// deep-link 404s instead of mounting a blank list.
if (!measurementTypeEnum.options.includes(type as never)) {
notFound();
}
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
const metricLabel = labelKey ? t(labelKey) : type;
return (
<SubPageShell
title={t("insights.subPage.valuesPageTitle", { metric: metricLabel })}
description={t("insights.subPage.valuesPageDescription")}
>
<Button
asChild
variant="ghost"
size="sm"
data-slot="metric-values-back"
className="-ml-2 w-fit"
>
<Link href="/insights">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
{t("insights.subPage.valuesBack")}
</Link>
</Button>
<MeasurementList lockedType={type} />
</SubPageShell>
);
}
+13 -1
View File
@@ -13,6 +13,8 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
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 { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
@@ -37,7 +39,8 @@ export default function InsightsGewichtPage() {
const { data: status, isLoading: isStatusLoading } =
useInsightStatus("weight");
const { isEmpty } = useInsightsAnalytics("WEIGHT");
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
if (isEmpty) {
return (
@@ -75,6 +78,15 @@ export default function InsightsGewichtPage() {
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="weight"
statStrip={<MetricStatStrip summary={weightSummary} unit="kg" />}
diversityNudge={
<MeasurementDiversityNudge
measurementType="WEIGHT"
metricLabel={t("insights.weightSectionTitle")}
timeZone={user?.timezone ?? undefined}
/>
}
showAllValuesType="WEIGHT"
>
<HealthChartDynamic
chartKey="weight"
+5
View File
@@ -132,6 +132,11 @@ export default function MedicationDetailPage({
return (await res.json()).data as MedicationDetailSnapshot;
},
enabled: isAuthenticated,
// A deleted medication 404s here. Don't burn a retry/backoff cycle on
// a resource that no longer exists — the delete handler evicts this
// key on success, but `retry: false` hardens any caller that
// prefix-invalidates `["medications"]` while this page is mounted.
retry: false,
});
// v1.7.2 W3 — derive the wizard payload purely to feed the read-only
+6
View File
@@ -44,6 +44,10 @@ interface Medication {
dosesPerUnit?: number | null;
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
deliveryForm?: string;
/** v1.8.5 — per-medication injection-site tracking opt-in. */
trackInjectionSites?: boolean;
/** v1.8.5 — per-medication allowed / preferred injection sites. */
allowedInjectionSites?: string[];
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
@@ -374,6 +378,8 @@ function medicationToPayload(med: Medication): MedicationPayload {
treatmentClass: med.treatmentClass,
deliveryForm: med.deliveryForm,
dosesPerUnit: med.dosesPerUnit ?? null,
trackInjectionSites: med.trackInjectionSites ?? false,
allowedInjectionSites: med.allowedInjectionSites ?? [],
notificationsEnabled: med.notificationsEnabled,
startsOn: med.startsOn ? new Date(med.startsOn) : null,
endsOn: med.endsOn ? new Date(med.endsOn) : null,
+1 -35
View File
@@ -14,6 +14,7 @@ import {
type TargetCardData,
} from "@/components/targets/target-card";
import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header";
import { getTargetSourceLink } from "@/lib/targets/source-link";
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
import { useCoachHandoff } from "@/hooks/use-coach-handoff";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
@@ -92,41 +93,6 @@ function sortKey(target: TargetData): number {
return FIXED_TARGET_ORDER[target.type] ?? 100;
}
/**
* Source-citation URL for each target type. Kept on the page (rather
* than the card) so the card stays presentational and the routing
* stays declarative.
*/
function getTargetSourceLink(target: TargetData): string | null {
if (target.type === "WEIGHT" || target.type === "BMI") {
return "https://www.who.int/news-room/fact-sheets/detail/obesity-and-overweight";
}
if (
target.type === "BLOOD_PRESSURE" ||
target.type === "BLOOD_PRESSURE_IN_TARGET"
) {
return "https://academic.oup.com/eurheartj/article/39/33/3021/5079119";
}
if (target.type === "PULSE") {
if (target.source.includes("CDC/NCHS")) {
return "https://www.cdc.gov/nchs/data/nhsr/nhsr041.pdf";
}
if (target.source.includes("AHA")) {
return "https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse";
}
}
if (target.type === "SLEEP_DURATION") {
return "https://aasm.org/seven-or-more-hours-of-sleep-per-night-a-health-necessity-for-adults/";
}
if (target.type === "BODY_FAT") {
return "https://www.acefitness.org/resources/everyone/blog/6596/what-are-the-guidelines-for-percentage-of-body-fat-loss/";
}
if (target.type === "ACTIVITY_STEPS") {
return "https://www.who.int/publications/i/item/9789240015128";
}
return null;
}
export default function TargetsPage() {
const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
+79 -3
View File
@@ -7,6 +7,7 @@ import {
ResponsiveContainer,
ComposedChart,
Line,
Area,
XAxis,
YAxis,
CartesianGrid,
@@ -193,12 +194,20 @@ interface ChartDataPoint {
date: string;
timestamp: number;
pointIndex?: number;
[key: string]: string | number | undefined;
// v1.8.5 — `${type}__range` keys carry a `[min, max]` tuple for the
// range-band <Area>; every other key stays a scalar.
[key: string]: string | number | undefined | [number, number];
}
interface MeasurementApiRow {
measuredAt: string;
value: number;
// v1.8.5 — per-day min / max emitted by the rollup daily-aggregate
// path (non-cumulative metrics only). Drive the Apple-Health-style
// range band shaded around the mean line. Absent on the raw-row path
// (windows ≤ 7 days) and on cumulative metrics.
minValue?: number;
maxValue?: number;
// v1.4.43 W2-CHART-GATE — the rollup / daily-aggregate paths return
// the underlying raw-row count per bucket. Used downstream to gate
// the "more days needed" empty-state copy on the actual measurement
@@ -597,7 +606,19 @@ export function HealthChart({
string,
{
timestamp: number;
values: Record<string, { sum: number; count: number }>;
values: Record<
string,
{
sum: number;
count: number;
// v1.8.5 — per-bucket spread for the range band. Seeded
// from the rollup daily-aggregate's min / max columns;
// null when the API row carries no spread (raw-row path
// or cumulative metric).
min: number | null;
max: number | null;
}
>;
}
>();
@@ -674,9 +695,30 @@ export function HealthChart({
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values[type] ?? { sum: 0, count: 0 };
const current = bucket.values[type] ?? {
sum: 0,
count: 0,
min: null,
max: null,
};
current.sum += value;
current.count += 1;
// v1.8.5 — carry the rollup bucket's min / max through when the
// API supplies them (daily-aggregate path, non-cumulative).
// `valueScale` already folded into `value` above; apply the
// same scale to the spread so the band tracks the line.
if (
typeof measurement.minValue === "number" &&
typeof measurement.maxValue === "number" &&
valueMode !== "bmi"
) {
const scaledMin = measurement.minValue * valueScale;
const scaledMax = measurement.maxValue * valueScale;
current.min =
current.min === null ? scaledMin : Math.min(current.min, scaledMin);
current.max =
current.max === null ? scaledMax : Math.max(current.max, scaledMax);
}
bucket.values[type] = current;
dailyAggregates.set(dayKey, bucket);
}
@@ -702,6 +744,13 @@ export function HealthChart({
type as MeasurementType,
);
point[type] = isCumulative ? stats.sum : stats.sum / stats.count;
// v1.8.5 — emit the day's spread as a `[min, max]` tuple key
// so the range-band <Area> can shade it. Only when the
// rollup supplied both bounds and the metric is not
// cumulative (a SUM has no meaningful intra-day spread).
if (!isCumulative && stats.min !== null && stats.max !== null) {
point[`${type}__range`] = [stats.min, stats.max] as never;
}
}
return point;
@@ -1746,6 +1795,33 @@ export function HealthChart({
}}
/>
)}
{/* v1.8.5 — min–max range band. An Apple-Health-style
shaded area between each day's min and max, painted
behind the mean line so it reads as context, not a
second series. Gated on `!mini` (the dashboard
sparkline stays clean) and on the daily-aggregate
path supplying a `[min, max]` tuple per point — single
non-cumulative metrics with intra-day spread (pulse,
weight, glucose, BP). The toggle rides the existing
target-range pref so the user controls both bands
from one switch. */}
{!mini &&
showBands &&
types.map((type, i) => (
<Area
key={`${type}__range`}
type="monotone"
dataKey={`${type}__range`}
name={`${getTypeLabel(type, valueMode, t)} (${t("charts.rangeBand")})`}
stroke="none"
fill={colors[i % colors.length]}
fillOpacity={0.12}
connectNulls
isAnimationActive={animationsEnabled}
legendType="none"
tooltipType="none"
/>
))}
{types.map((type, i) => (
<Line
key={type}
+316
View File
@@ -0,0 +1,316 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
/**
* v1.8.5 — mood calendar heatmap.
*
* Forked from `compliance-heatmap.tsx`: same SVG cell-grid, Monday-aligned
* columns, month markers, tap-to-pin tooltip, and `stretch` adaptive cell
* sizing. The one swap is `getColor` — instead of an expected/taken
* compliance map, each cell is coloured by its daily-mean mood score band
* (matching the `mood-chart.tsx` VALUE_BANDS: 1–2 red, 2–3 orange, 3–5
* green). One cell per calendar day; days with no entry render in the
* neutral `--secondary` fill.
*/
interface MoodHeatmapCell {
/** YYYY-MM-DD day key. */
date: string;
/** Daily-mean mood score 1..5. */
score: number;
/** Number of entries that fed the mean. */
samples: number;
}
interface MoodHeatmapProps {
/** Per-day cells keyed by YYYY-MM-DD. */
cells: Record<string, MoodHeatmapCell>;
days?: number;
stretch?: boolean;
}
const CELL_SIZE = 18;
const GAP = 3;
const CELL_FLOOR_PX = 14;
/**
* Mood score band → Dracula colour. Mirrors `mood-chart.tsx` VALUE_BANDS
* (1–2 red, 2–3 orange, 3–5 green) and the green/orange thresholds in
* `mood-aggregates.ts`. No score (no entry that day) → neutral fill.
*/
function getColor(score: number | null): string {
if (score == null) return "var(--secondary)";
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
}
function formatDateDE(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00Z");
return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`;
}
export function MoodHeatmap({
cells: cellData,
days = 90,
stretch = false,
}: MoodHeatmapProps) {
const { t } = useTranslations();
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
text: string;
pinned?: boolean;
} | null>(null);
useEffect(() => {
if (!tooltip?.pinned) return;
const handlePointer = (event: PointerEvent) => {
const container = containerRef.current;
if (!container) return;
if (!container.contains(event.target as Node)) {
setTooltip(null);
}
};
document.addEventListener("pointerdown", handlePointer, true);
return () => {
document.removeEventListener("pointerdown", handlePointer, true);
};
}, [tooltip?.pinned]);
const WEEKDAY_LABELS = [
t("charts.weekdays.mon"),
"",
t("charts.weekdays.wed"),
"",
t("charts.weekdays.fri"),
"",
t("charts.weekdays.sun"),
];
useEffect(() => {
if (!stretch) return;
const element = containerRef.current;
if (!element) return;
const updateWidth = () => {
setContainerWidth(element.clientWidth);
};
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [stretch]);
const { cells, weeks, monthMarkers } = useMemo(() => {
const MONTH_LABELS = [
t("charts.months.jan"),
t("charts.months.feb"),
t("charts.months.mar"),
t("charts.months.apr"),
t("charts.months.may"),
t("charts.months.jun"),
t("charts.months.jul"),
t("charts.months.aug"),
t("charts.months.sep"),
t("charts.months.oct"),
t("charts.months.nov"),
t("charts.months.dec"),
];
const now = new Date();
const cellList: Array<{
dateKey: string;
col: number;
row: number;
color: string;
cell: MoodHeatmapCell | null;
}> = [];
const dates: Date[] = [];
for (let d = days - 1; d >= 0; d--) {
const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
dates.push(date);
}
// Monday-align + month markers read off the UTC accessors so the
// grid matches the UTC-anchored dateKey (mirrors compliance-heatmap).
const firstDate = dates[0];
const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0
let col = 0;
const markers: Array<{ col: number; label: string }> = [];
let lastMonth = -1;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
const dow = (date.getUTCDay() + 6) % 7; // Monday = 0
const currentCol = Math.floor((i + firstDow) / 7);
const row = dow;
const dateKey = date.toISOString().slice(0, 10);
const cell = cellData[dateKey] ?? null;
const month = date.getUTCMonth();
if (month !== lastMonth) {
markers.push({ col: currentCol, label: MONTH_LABELS[month] });
lastMonth = month;
}
cellList.push({
dateKey,
col: currentCol,
row,
color: getColor(cell?.score ?? null),
cell,
});
col = Math.max(col, currentCol);
}
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
}, [cellData, days, t]);
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
const cellSize =
stretch && containerWidth > 0
? Math.max(
CELL_FLOOR_PX,
(containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
Math.max(weeks, 1),
)
: CELL_SIZE;
const step = cellSize + GAP;
const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP;
const svgHeight = headerHeight + 7 * cellSize + 6 * GAP;
return (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
<div
className={
stretch
? "w-full sm:w-full overflow-x-auto sm:overflow-visible"
: "overflow-x-auto"
}
>
<svg
width={svgWidth}
height={svgHeight}
className={stretch ? "block w-full" : "block"}
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
>
{monthMarkers.map((m, i) => (
<text
key={i}
x={labelWidth + m.col * step}
y={11}
className="fill-muted-foreground"
fontSize={10}
>
{m.label}
</text>
))}
{!stretch &&
WEEKDAY_LABELS.map(
(label, i) =>
label && (
<text
key={i}
x={labelWidth - 6}
y={headerHeight + i * step + cellSize * 0.65}
textAnchor="end"
className="fill-muted-foreground"
fontSize={10}
>
{label}
</text>
),
)}
{cells.map((cell) => {
const buildText = (): string => {
if (!cell.cell) {
return `${formatDateDE(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`;
}
const labelKey = moodLabelKeyForScore(Math.round(cell.cell.score));
const moodLabel = labelKey ? t(labelKey) : "";
return `${formatDateDE(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
};
return (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
className="cursor-pointer"
onPointerEnter={(e) => {
if (e.pointerType === "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
});
}}
onPointerLeave={(e) => {
if (e.pointerType === "touch") return;
setTooltip((prev) => (prev?.pinned ? prev : null));
}}
onPointerDown={(e) => {
if (e.pointerType !== "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
pinned: true,
});
}}
/>
);
})}
</svg>
</div>
{tooltip && (
<div
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
>
{tooltip.text}
</div>
)}
<div
className="text-muted-foreground mt-2 flex flex-wrap items-center gap-3 text-xs"
style={{ marginLeft: stretch ? 0 : labelWidth }}
>
{[
{ color: "var(--dracula-green)", label: t("insights.mood.legendGreat") },
{ color: "var(--dracula-yellow)", label: t("insights.mood.legendGood") },
{ color: "var(--dracula-orange)", label: t("insights.mood.legendOkay") },
{ color: "var(--dracula-red)", label: t("insights.mood.legendLow") },
].map(({ color, label }) => (
<span key={label} className="flex items-center gap-1">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: color }}
/>
{label}
</span>
))}
</div>
</div>
);
}
@@ -20,6 +20,7 @@ function fakeSummary(count: number): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -30,6 +30,7 @@ function fakeSummary(count: number): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
import { MetricStatStrip } from "../metric-stat-strip";
/**
* v1.8.5 — `<MetricStatStrip>` SSR coverage.
*
* The strip leads the insights category pages with the four central
* stats — min / max / median / mean — read from the already-fetched
* `summaries` slice (zero new network). Acceptance:
* 1. Renders four labelled cells: min, max, median, mean.
* 2. Each cell shows the formatted value + the metric unit.
* 3. A null summary (read in flight) renders nothing rather than a
* strip full of em-dashes.
* 4. A zero-count summary renders nothing (brand-new metric, no data).
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const populated: DataSummary = {
count: 42,
latest: 72,
min: 58,
max: 88,
mean: 70.4,
median: 71,
avg7: 71,
avg30: 70,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
};
describe("<MetricStatStrip>", () => {
it("renders the four stat cells with min / max / median / mean", () => {
const html = render(<MetricStatStrip summary={populated} unit="bpm" />);
expect(html).toContain('data-stat="min"');
expect(html).toContain('data-stat="max"');
expect(html).toContain('data-stat="median"');
expect(html).toContain('data-stat="mean"');
});
it("shows the formatted values and the unit", () => {
const html = render(<MetricStatStrip summary={populated} unit="bpm" />);
expect(html).toContain("58");
expect(html).toContain("88");
expect(html).toContain("71");
expect(html).toContain("bpm");
});
it("renders nothing while the summary is still loading", () => {
const html = render(<MetricStatStrip summary={null} unit="bpm" />);
expect(html).toBe("");
});
it("renders nothing for a zero-count metric", () => {
const empty: DataSummary = { ...populated, count: 0 };
const html = render(<MetricStatStrip summary={empty} unit="bpm" />);
expect(html).toBe("");
});
it("falls back to mean when median is absent (older payload)", () => {
const noMedian: DataSummary = { ...populated, median: null };
const html = render(<MetricStatStrip summary={noMedian} unit="bpm" />);
// Median cell still paints (em-dash) so the four-up grid stays
// balanced; min / max / mean keep their values.
expect(html).toContain('data-stat="median"');
expect(html).toContain('data-stat="mean"');
});
});
@@ -4,15 +4,18 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.8.0 — `<MetricTargetSummary>` unit tests.
* v1.8.0 → v1.8.5 — `<MetricTargetSummary>` unit tests.
*
* The card surfaces the numeric target range + the in-target share on
* each insights category page, reading the same `/api/insights/targets`
* payload that powers `/targets`. The component depends on `useAuth`
* (gate) and TanStack Query (`useQuery`) for the payload, so both are
* mocked here and the assertions run through SSR — the load-bearing
* behaviour is the range/share rendering + the slug→type mapping, not
* the live fetch.
* The panel surfaces the numeric target range, the verbal status pill,
* the guideline source, the in-target share, the 30-day average, the
* range bar and the 7-day consistency strip on each insights category
* page, reading the same `/api/insights/targets` payload that powers
* `/targets`. The component depends on `useAuth` (gate) and TanStack
* Query (`useQuery`) for the payload, so both are mocked here and the
* assertions run through SSR — the load-bearing behaviour is the
* range/share/source rendering + the slug→type mapping (including the
* blood-glucose per-context split + unit conversion), not the live
* fetch.
*/
vi.mock("@/hooks/use-auth", () => ({
@@ -26,17 +29,31 @@ vi.mock("@tanstack/react-query", () => ({
const { MetricTargetSummary } = await import("../metric-target-summary");
interface TargetFixtureItem {
type: string;
label: string;
current: number | null;
average30: number | null;
unit: string;
range: { min: number; max: number } | null;
classification: { category: string; color: string } | null;
source: string;
daysInRange7d: number;
daysLogged7d: number;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
consistency7d: ReadonlyArray<"in" | "near" | "out" | null>;
}
interface TargetsFixture {
targets: Array<{
type: string;
targets: TargetFixtureItem[];
bpDiastolic: {
current: number | null;
unit: string;
average30: number | null;
range: { min: number; max: number } | null;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
}>;
bpDiastolic: { current: number | null; range: { min: number; max: number } | null };
};
profile?: { glucoseUnit?: string | null };
}
function renderWith(slug: string, data: TargetsFixture | undefined) {
@@ -48,34 +65,87 @@ function renderWith(slug: string, data: TargetsFixture | undefined) {
);
}
const WEIGHT_ITEM: TargetFixtureItem = {
type: "WEIGHT",
label: "Weight",
current: 72,
average30: 71.4,
unit: "kg",
range: { min: 60.1, max: 80.9 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
daysInRange7d: 5,
daysLogged7d: 7,
daysInRange30d: 18,
daysLogged30d: 24,
insufficientData: false,
consistency7d: ["in", "in", "near", "in", "out", "in", "in"],
};
const WEIGHT_DATA: TargetsFixture = {
targets: [
{
type: "WEIGHT",
current: 72,
unit: "kg",
range: { min: 60.1, max: 80.9 },
daysInRange30d: 18,
daysLogged30d: 24,
insufficientData: false,
},
],
bpDiastolic: { current: null, range: null },
targets: [WEIGHT_ITEM],
bpDiastolic: { current: null, average30: null, range: null },
};
const BP_DATA: TargetsFixture = {
targets: [
{
type: "BLOOD_PRESSURE",
label: "Blood pressure",
current: 124,
average30: 122,
unit: "mmHg",
range: { min: 120, max: 129 },
classification: { category: "Optimal", color: "#50fa7b" },
source: "ESH 2023",
daysInRange7d: 3,
daysLogged7d: 6,
daysInRange30d: 10,
daysLogged30d: 20,
insufficientData: false,
consistency7d: ["in", "near", "in", "out", "in", "near", "in"],
},
],
bpDiastolic: { current: 78, range: { min: 70, max: 79 } },
bpDiastolic: { current: 78, average30: 76, range: { min: 70, max: 79 } },
};
const GLUCOSE_DATA: TargetsFixture = {
targets: [
{
type: "BLOOD_GLUCOSE_FASTING",
label: "targets.glucoseFasting",
current: 95,
average30: 92,
unit: "mg/dL",
range: { min: 70, max: 100 },
classification: { category: "Optimal", color: "#50fa7b" },
source: "ADA 2024 / DDG",
daysInRange7d: 4,
daysLogged7d: 5,
daysInRange30d: 12,
daysLogged30d: 15,
insufficientData: false,
consistency7d: ["in", "in", "near", "in", "in", "in", "in"],
},
{
type: "BLOOD_GLUCOSE_POSTPRANDIAL",
label: "targets.glucosePostprandial",
current: 140,
average30: 138,
unit: "mg/dL",
range: { min: 70, max: 140 },
classification: { category: "Optimal", color: "#50fa7b" },
source: "ADA 2024 / DDG",
daysInRange7d: 3,
daysLogged7d: 4,
daysInRange30d: 9,
daysLogged30d: 12,
insufficientData: false,
consistency7d: ["in", "near", "in", "in", null, "in", "in"],
},
],
bpDiastolic: { current: null, average30: null, range: null },
profile: { glucoseUnit: "mg/dL" },
};
describe("<MetricTargetSummary>", () => {
@@ -87,10 +157,35 @@ describe("<MetricTargetSummary>", () => {
expect(html).toContain("75% of logged days within target");
});
it("renders the verbal status pill + the guideline source", () => {
const html = renderWith("weight", WEIGHT_DATA);
expect(html).toContain('data-slot="target-status-pill"');
// EN label for the "Normal" classification category.
expect(html).toContain("Normal");
// Source citation + external-link.
expect(html).toContain("Source: WHO BMI");
// The WHO obesity fact-sheet URL resolves for WEIGHT.
expect(html).toContain(
"who.int/news-room/fact-sheets/detail/obesity-and-overweight",
);
});
it("renders the range bar + the 30-day average + the consistency strip", () => {
const html = renderWith("weight", WEIGHT_DATA);
expect(html).toContain('data-slot="target-range-bar"');
expect(html).toContain('data-slot="consistency-strip"');
expect(html).toContain("30-day average: 71.4 kg");
});
it("stitches the systolic + diastolic bands for blood pressure", () => {
const html = renderWith("blood-pressure", BP_DATA);
expect(html).toContain("Target: 120–129 / 70–79 mmHg");
expect(html).toContain("50% of logged days within target");
// The diastolic range bar renders alongside the systolic one.
const barCount = (html.match(/data-slot="target-range-bar"/g) ?? []).length;
expect(barCount).toBe(2);
// 30-day average stitches the diastolic pair.
expect(html).toContain("30-day average: 122/76 mmHg");
});
it("renders the adjust-target link to /targets", () => {
@@ -99,13 +194,14 @@ describe("<MetricTargetSummary>", () => {
expect(html).toContain("Adjust target range");
});
it("suppresses the share when the route flags insufficient data", () => {
it("suppresses the share + strip when the route flags insufficient data", () => {
const html = renderWith("weight", {
...WEIGHT_DATA,
targets: [{ ...WEIGHT_DATA.targets[0], insufficientData: true }],
targets: [{ ...WEIGHT_ITEM, insufficientData: true }],
});
expect(html).toContain("Target: 60.1–80.9 kg");
expect(html).not.toContain("of logged days within target");
expect(html).not.toContain('data-slot="consistency-strip"');
});
it("renders nothing for a slug without a numeric target", () => {
@@ -121,8 +217,48 @@ describe("<MetricTargetSummary>", () => {
it("renders nothing when the matching target has no range", () => {
const html = renderWith("weight", {
...WEIGHT_DATA,
targets: [{ ...WEIGHT_DATA.targets[0], range: null }],
targets: [{ ...WEIGHT_ITEM, range: null }],
});
expect(html).toBe("");
});
describe("blood-glucose per-context split", () => {
it("renders one panel per logged glucose context in mg/dL", () => {
const html = renderWith("blood-glucose", GLUCOSE_DATA);
const panelCount = (
html.match(/data-slot="metric-target-summary"/g) ?? []
).length;
expect(panelCount).toBe(2);
// mg/dL display unit keeps the canonical values unchanged.
expect(html).toContain("Target: 70–100 mg/dL");
expect(html).toContain("Target: 70–140 mg/dL");
// ADA / DDG source surfaces for each panel.
expect(html).toContain("Source: ADA 2024 / DDG");
// The i18n label keys resolve to EN context headings (rendered
// upper-cased by the heading style but kept as text content).
expect(html).toContain("Glucose — fasting");
expect(html).toContain("Glucose — post-meal");
});
it("converts the glucose bands to mmol/L when the profile prefers it", () => {
const html = renderWith("blood-glucose", {
...GLUCOSE_DATA,
profile: { glucoseUnit: "mmol/L" },
});
// 70 mg/dL → 3.9, 100 mg/dL → 5.5, 140 mg/dL → 7.8 mmol/L
// (the /targets converter rounds to one decimal).
expect(html).toContain("Target: 3.9–5.5 mmol/L");
expect(html).toContain("Target: 3.9–7.8 mmol/L");
expect(html).toContain("mmol/L");
});
it("renders nothing when no glucose context has a band", () => {
const html = renderWith("blood-glucose", {
targets: [],
bpDiastolic: { current: null, average30: null, range: null },
profile: { glucoseUnit: "mg/dL" },
});
expect(html).toBe("");
});
});
});
@@ -30,6 +30,23 @@ vi.mock("next/dynamic", () => ({
}));
import { TrendsRow } from "../trends-row";
import type { DailyBriefing } from "@/lib/ai/schema";
function briefing(
metrics: DailyBriefing["keyFindings"][number]["sourceMetric"][],
): DailyBriefing {
return {
paragraph: "Synthesised briefing paragraph.",
keyFindings: metrics.map((sourceMetric) => ({
tone: "watch" as const,
headline: `${sourceMetric} headline`,
detail: `${sourceMetric} detail`,
delta: null,
sourceWindow: "30d" as const,
sourceMetric,
})),
};
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const queryClient = new QueryClient({
@@ -165,6 +182,44 @@ describe("<TrendsRow>", () => {
expect(pending.length).toBe(3);
});
// ── v1.8.5 — dynamic chart set driven by the daily briefing ───────
it("charts the metrics the briefing flags, in briefing order", () => {
const html = render(
<TrendsRow briefing={briefing(["weight", "pulse", "sleep"])} />,
);
expect(html).toMatch(/data-metric="weight"/);
expect(html).toMatch(/data-metric="pulse"/);
expect(html).toMatch(/data-metric="sleep"/);
// The default BP / mood tiles drop out when the briefing flags
// other metrics.
expect(html).not.toMatch(/data-metric="bp"/);
expect(html).not.toMatch(/data-metric="mood"/);
});
it("caps the briefing-driven set at three tiles", () => {
const html = render(
<TrendsRow
briefing={briefing(["weight", "pulse", "sleep", "hrv", "steps"])}
/>,
);
const cards = html.match(/data-slot="trends-row-card"/g) ?? [];
expect(cards.length).toBe(3);
});
it("falls back to BP / weight / mood when the briefing is empty", () => {
const html = render(<TrendsRow briefing={briefing([])} />);
expect(html).toMatch(/data-metric="bp"/);
expect(html).toMatch(/data-metric="weight"/);
expect(html).toMatch(/data-metric="mood"/);
});
it("renders additive metrics chart-only (no annotation slot)", () => {
const html = render(<TrendsRow briefing={briefing(["pulse"])} />);
expect(html).toMatch(/data-metric="pulse"/);
// No TrendAnnotation empty/pending slot for a chart-only tile.
expect(html).not.toMatch(/data-slot="trend-annotation-empty"/);
});
it("propagates per-metric confidence chips", () => {
const html = render(
<TrendsRow
@@ -41,6 +41,7 @@ const populatedSummary: DataSummary = {
min: 38.1,
max: 45.7,
mean: 41.5,
median: 41.4,
avg7: 42.0,
avg30: 41.8,
slope7: null,
@@ -57,6 +58,7 @@ const emptySummary: DataSummary = {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
+10 -4
View File
@@ -265,7 +265,7 @@ export function HealthScoreCard({
the bottom, and the card fills the row down to the
"Wirkt mein Medikament?" chip — closing Marc's "Karte hört
bei der Trennlinie auf" report against v1.4.36. */}
<div className="grid flex-1 grid-rows-[auto_auto_auto_auto_auto_1fr_auto] gap-3">
<div className="grid flex-1 grid-rows-[auto_auto_auto_auto_auto_1fr_auto] gap-3.5">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<p
@@ -382,14 +382,20 @@ export function HealthScoreCard({
<ul
data-slot="health-score-card-components"
className="space-y-1.5 border-t pt-3"
// v1.8.5 W4a — the always-visible "Zusammensetzung" breakdown is
// the natural place to add weight to the Health-Score card so it
// fills the hero-row height instead of leaving the right column
// short and bottom-empty. Loosen the row rhythm (`space-y-2.5` vs
// `space-y-1.5`) and the divider stride (`pt-3.5`) so the four
// component bars read as a richer block; no new data, no new row.
className="space-y-2.5 border-t pt-3.5"
>
{componentEntries.map(({ key, label, value }) => (
<li
key={key}
data-slot="health-score-card-component-row"
data-component={key}
className="flex items-center gap-2 text-[11px]"
className="flex items-center gap-2 text-xs"
>
{/* v1.4.25 W3 — widened label column from w-16 (64px) to
w-24 (96px) so the longest German label
@@ -399,7 +405,7 @@ export function HealthScoreCard({
{label}
</span>
<div
className="bg-muted/50 h-1 flex-1 overflow-hidden rounded-full"
className="bg-muted/50 h-1.5 flex-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
@@ -13,6 +13,9 @@ import { Button } from "@/components/ui/button";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -86,6 +89,13 @@ export interface HealthKitMetricPageProps {
* Resolves `insights.subPage.explainer.<explainerMetric>{Title,Body}`.
*/
explainerMetric?: string;
/**
* v1.8.5 W5 — when set, renders `<MetricTargetSummary slug=…>` beneath
* the chart. Used by blood glucose, whose per-context ADA / DDG bands
* live on the targets wire but whose page rides this generic scaffold
* rather than a bespoke module. Omit it for metrics without a target.
*/
targetSummarySlug?: string;
}
export function HealthKitMetricPage({
@@ -102,12 +112,32 @@ export function HealthKitMetricPage({
coachPrefill,
valueScale,
explainerMetric,
targetSummarySlug,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { isEmpty } = useInsightsAnalytics(insightMetric);
const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric);
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
// The stat strip renders display-unit values. The summary holds stored
// values, so when the page renders a scaled unit (e.g. WALKING_SPEED
// stores m/s but displays km/h via `valueScale`), fold the same scale
// into the strip's min / max / median / mean so the numbers and the
// unit agree. `valueScale` defaults to 1 (identity) → byte-identical
// for every non-scaled metric.
const scale = valueScale ?? 1;
const summary =
rawSummary && scale !== 1
? {
...rawSummary,
min: rawSummary.min === null ? null : rawSummary.min * scale,
max: rawSummary.max === null ? null : rawSummary.max * scale,
mean: rawSummary.mean === null ? null : rawSummary.mean * scale,
median:
rawSummary.median === null ? null : rawSummary.median * scale,
}
: rawSummary;
const title = t(`${i18nPrefix}.title`);
const description = t(`${i18nPrefix}.description`);
@@ -143,6 +173,15 @@ export function HealthKitMetricPage({
title={title}
description={description}
explainerMetric={explainerMetric}
statStrip={<MetricStatStrip summary={summary} unit={yAxisUnit ?? unit} />}
diversityNudge={
<MeasurementDiversityNudge
measurementType={measurementType}
metricLabel={title}
timeZone={user?.timezone ?? undefined}
/>
}
showAllValuesType={measurementType}
>
<HealthChartDynamic
chartKey={chartKey}
@@ -156,6 +195,9 @@ export function HealthKitMetricPage({
userTimezone={user?.timezone}
valueScale={valueScale}
/>
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
<CoachLaunchButton />
</SubPageShell>
);
+7 -1
View File
@@ -262,7 +262,13 @@ export function HeroStrip({
{coachEnabled && (
<div
data-slot="insights-hero-strip-prompts"
className="border-border/50 border-t pt-4"
// v1.8.5 W4a — condense the "Coach fragen" band so the left
// column stops out-running the Health-Score card on the
// right. Tighter top padding (`pt-3` vs `pt-4`) plus a
// `mt-auto` push pins the chip strip to the bottom of the
// left column, so the recovered slack lands between the meta
// row and the prompts instead of stacking the column tall.
className="border-border/50 mt-auto border-t pt-3"
>
{/*
* v1.4.20 phase B2b — chip clicks open the Coach drawer
@@ -57,6 +57,7 @@ export function InsightStatusCard({
aria-busy="true"
aria-live="polite"
data-testid="insight-status-card-preparing"
className="gap-2 py-4 md:gap-3 md:py-5"
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
@@ -88,7 +89,12 @@ export function InsightStatusCard({
// (`text-sm` → 0.875 rem) so the post-load swap reflows by less
// than a row.
return (
<Card aria-busy="true" aria-live="polite" data-testid="insight-status-card-loading">
<Card
aria-busy="true"
aria-live="polite"
data-testid="insight-status-card-loading"
className="gap-2 py-4 md:gap-3 md:py-5"
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
@@ -108,7 +114,7 @@ export function InsightStatusCard({
if (!hasProvider) {
return (
<Card className="opacity-60">
<Card className="gap-2 py-4 opacity-60 md:gap-3 md:py-5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
@@ -126,7 +132,7 @@ export function InsightStatusCard({
if (!text) {
return (
<Card>
<Card className="gap-2 py-4 md:gap-3 md:py-5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
@@ -150,7 +156,14 @@ export function InsightStatusCard({
// on the success card closes the load→loaded transition signal.
<Card
aria-live="polite"
className="animate-insight-in border-l-dracula-purple border-l-2"
// v1.8.5 W4a — the `Card` primitive ships `gap-4 md:gap-6` as the flex
// gap between header and content, which floated the "Einschätzung"
// heading ~16-24 px above its prose. Override to `gap-2 md:gap-3` (the
// `CardHeader`'s own `pb-2` does not touch the flex gap) and trim the
// shell padding to `py-4 md:py-5` so the assessment block reads tight
// in the denser sub-page rhythm. The same override lands on every
// status variant below so the gap is consistent across states.
className="animate-insight-in border-l-dracula-purple gap-2 border-l-2 py-4 md:gap-3 md:py-5"
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
@@ -0,0 +1,92 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Lightbulb } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { detectMeasurementDiversity } from "@/lib/insights/measurement-diversity";
/**
* v1.8.5 — measurement-diversity nudge for the insights category pages.
*
* When a metric's readings cluster on a single weekday or a narrow
* time-of-day band, the trend reads a biased slice of reality. This
* component fetches a bounded recent window of raw timestamps for the
* metric, runs the pure `detectMeasurementDiversity` check, and — only
* when the spread is genuinely lopsided — surfaces a gentle hint to
* measure on other days / times.
*
* v1.8.3 anti-freeze posture: the read is a bounded `limit`-capped query
* with a 5-minute `staleTime`, fires only after the page has mounted
* (no SSR blocking), and renders nothing while in flight or when the
* spread is healthy. No new server route — it rides the existing
* `/api/measurements` list endpoint.
*/
interface MeasurementDiversityNudgeProps {
/** The page's `MeasurementType`. */
measurementType: string;
/** Localised metric name woven into the hint copy. */
metricLabel: string;
/** User's IANA timezone so weekday / hour resolve in the wall clock. */
timeZone?: string;
}
/** Bounded window of recent rows — enough to read a pattern, never heavy. */
const WINDOW_SIZE = 120;
export function MeasurementDiversityNudge({
measurementType,
metricLabel,
timeZone,
}: MeasurementDiversityNudgeProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data: timestamps } = useQuery({
queryKey: queryKeys.measurementDiversity(measurementType),
queryFn: async (): Promise<string[]> => {
const params = new URLSearchParams();
params.set("type", measurementType);
params.set("limit", String(WINDOW_SIZE));
params.set("sortBy", "measuredAt");
params.set("sortDir", "desc");
const res = await fetch(`/api/measurements?${params}`);
if (!res.ok) return [];
const json = await res.json();
const rows = (json.data?.measurements ?? []) as Array<{
measuredAt: string;
}>;
return rows.map((r) => r.measuredAt);
},
enabled: isAuthenticated,
// The pattern shifts slowly — a 5-minute cache keeps the nudge calm
// and shares the read budget with the rest of the page.
staleTime: 5 * 60 * 1000,
});
if (!timestamps) return null;
const signal = detectMeasurementDiversity(timestamps, timeZone);
if (!signal) return null;
const message = t(`insights.subPage.diversity.${signal.kind}`, {
metric: metricLabel,
});
return (
<div
data-slot="measurement-diversity-nudge"
role="note"
className="bg-muted/40 text-muted-foreground flex items-start gap-2.5 rounded-lg px-3 py-2.5 text-sm"
>
<Lightbulb
className="text-dracula-yellow mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p className="leading-relaxed">{message}</p>
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.8.5 — at-a-glance stat strip for the insights category pages.
*
* Leads each metric page with the four central statistics — min / max /
* median / mean — read straight from the already-fetched `summaries`
* slice (`useInsightsAnalytics`). Zero new network: the layout shell and
* the empty-state gate already warm the `["analytics", "summaries"]`
* cache, so the strip is a pure render of cached numbers. This is the
* numbers-first header Apple Health / Withings / Oura lead their detail
* screens with — the single biggest "data-rich" lift per unit effort.
*
* Median sits alongside mean on purpose: a series with a handful of very
* high or very low readings drags the mean away from the typical value,
* and showing both makes that skew legible rather than hidden.
*
* 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.
*/
interface MetricStatStripProps {
/**
* 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.
*/
summary: DataSummary | null;
/** Unit suffix rendered next to each value (e.g. `bpm`, `kg`). */
unit: string;
/**
* Decimal precision for the formatted values. Defaults to 1 — enough
* for weight (78.4) and HRV (41.5) without trailing noise on integer
* metrics, which `Intl.NumberFormat` drops anyway.
*/
fractionDigits?: number;
}
export function MetricStatStrip({
summary,
unit,
fractionDigits = 1,
}: MetricStatStripProps) {
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.
if (!summary || summary.count <= 0) return null;
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: "median",
label: t("insights.subPage.stats.median"),
value: summary.median,
},
{
key: "mean",
label: t("insights.subPage.stats.mean"),
value: summary.mean,
},
];
return (
<section
data-slot="metric-stat-strip"
aria-label={t("insights.subPage.stats.label")}
className="bg-card border-border grid grid-cols-2 gap-3 rounded-xl border p-4 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>
))}
</section>
);
}
+267 -38
View File
@@ -2,15 +2,19 @@
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Target } from "lucide-react";
import { ExternalLink, Target } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent } from "@/components/ui/card";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { RangeBar } from "@/components/targets/range-bar";
import { ConsistencyStrip } from "@/components/targets/consistency-strip";
import { TargetStatusPill } from "@/components/targets/target-status-pill";
import { getTargetSourceLink } from "@/lib/targets/source-link";
/**
* v1.8.0 — surface the numeric target range + in-target share on each
* v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each
* insights category page, directly beneath the chart / assessment.
*
* The data is the same `/api/insights/targets` payload that powers the
@@ -20,22 +24,24 @@ import { Card, CardContent } from "@/components/ui/card";
* cache the `/targets` page then reuses. We never recompute the ranges
* here: the route is the single source of truth for the age-based ESH
* blood-pressure band, the WHO weight / BMI band, the Karvonen pulse
* band, the AASM sleep band, and the medication / mood / step targets.
* band, the AASM sleep band, the ADA / DDG glucose bands, and the
* medication / mood targets.
*
* Each insights slug maps to one target `type`. Blood pressure is the one
* composite case: the route emits the systolic band on the
* `BLOOD_PRESSURE` target and the diastolic band on the top-level
* `bpDiastolic` slot, so we stitch them back into a `S/D` range string.
* v1.8.5 W5 — the panel grew from a one-line range card into a compact
* reference panel that ports the context the `/targets` card carries:
*
* The "in target" share reads `daysInRange30d / daysLogged30d` — the
* fraction of logged days over the last 30 whose mean reading landed in
* the green band. We hide the share alone when the route flags
* `insufficientData`; the range stays visible so the band is always
* legible. This matches the `/targets` consistency strip so the two
* surfaces never disagree.
* • Row 1: target range string + `<TargetStatusPill>` + source link
* (the "what is the band / what is it based on" answer).
* • Row 2: `<RangeBar>` — where today's value sits inside the band
* (the spatial answer). BP renders the second diastolic bar.
* • Row 3: in-target share % + the 30-day average.
* • Row 4: `<ConsistencyStrip>` — the last seven days at a glance.
* • Footer: the "Adjust target range" link to `/targets`.
*
* A discreet "Adjust target range" link routes to `/targets`, which
* stays the canonical place to view / tune the bands.
* Each insights slug maps to one target `type`, except blood glucose
* which maps to up to four per-context cards (fasting / postprandial /
* random / bedtime) — those carry mg/dL canonical values that we convert
* to the user's display unit exactly like the `/targets` page does.
*/
interface TargetRange {
@@ -43,22 +49,38 @@ interface TargetRange {
max: number;
}
interface TargetClassification {
category: string;
color: string;
}
interface TargetItem {
type: string;
label: string;
current: number | null;
average30: number | null;
unit: string;
range: TargetRange | null;
classification: TargetClassification | null;
source: string;
daysInRange7d: number;
daysLogged7d: number;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
consistency7d: ReadonlyArray<"in" | "near" | "out" | null>;
}
interface TargetsResponse {
targets: TargetItem[];
bpDiastolic: {
current: number | null;
average30: number | null;
range: TargetRange | null;
};
profile?: {
glucoseUnit?: string | null;
};
}
/**
@@ -66,6 +88,10 @@ interface TargetsResponse {
* Slugs without a numeric target (HRV, oxygen saturation, body
* temperature, active energy, workouts, resting HR) are absent — the
* component renders nothing for them, so the sub-page collapses cleanly.
*
* Blood glucose is the one slug that fans out to several target types:
* the route emits one card per logged context, so the summary renders a
* reference panel for each.
*/
const SLUG_TO_TARGET_TYPE: Record<string, string> = {
"blood-pressure": "BLOOD_PRESSURE",
@@ -77,6 +103,14 @@ const SLUG_TO_TARGET_TYPE: Record<string, string> = {
medications: "MEDICATION_COMPLIANCE",
};
/** Glucose context target types, in the route's emission order. */
const GLUCOSE_TARGET_TYPES = [
"BLOOD_GLUCOSE_FASTING",
"BLOOD_GLUCOSE_POSTPRANDIAL",
"BLOOD_GLUCOSE_RANDOM",
"BLOOD_GLUCOSE_BEDTIME",
];
interface MetricTargetSummaryProps {
/** Insights category slug, e.g. `"blood-pressure"`. */
slug: string;
@@ -91,6 +125,7 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const isGlucose = slug === "blood-glucose";
const targetType = SLUG_TO_TARGET_TYPE[slug];
const { data } = useQuery({
@@ -101,23 +136,125 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
const json = await res.json();
return json.data as TargetsResponse;
},
enabled: isAuthenticated && targetType != null,
enabled: isAuthenticated && (targetType != null || isGlucose),
});
// No numeric target for this slug, or the payload hasn't resolved yet.
if (!targetType || !data) return null;
if ((!targetType && !isGlucose) || !data) return null;
// Glucose: one panel per logged context, converting the canonical
// mg/dL values to the user's display unit exactly like the /targets
// page. The route's per-context labels are i18n keys, so resolve them.
if (isGlucose) {
const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
const convert = (v: number | null) =>
v == null ? null : convertGlucose(v, displayUnit);
const panels = GLUCOSE_TARGET_TYPES.map((type) =>
data.targets.find((entry) => entry.type === type),
)
.filter((entry): entry is TargetItem => entry != null && entry.range != null)
.map((entry) => ({
...entry,
label: t(entry.label),
unit: displayUnit,
current: convert(entry.current),
average30: convert(entry.average30),
range: entry.range
? {
min: convertGlucose(entry.range.min, displayUnit),
max: convertGlucose(entry.range.max, displayUnit),
}
: null,
}));
if (panels.length === 0) return null;
// Glucose fans out to up to four per-context panels. The
// "Adjust target range" link is identical on every one, so render
// it once for the whole group rather than repeating it per panel.
return (
<div className="space-y-2" data-slot="metric-target-summary-group">
{panels.map((panel) => (
<TargetReferencePanel
key={panel.type}
target={panel}
heading={panel.label}
hideAdjustLink
/>
))}
<div className="flex justify-end px-1">
<AdjustTargetLink />
</div>
</div>
);
}
const target = data.targets.find((entry) => entry.type === targetType);
if (!target || !target.range) return null;
return (
<TargetReferencePanel
target={target}
bpDiastolic={
targetType === "BLOOD_PRESSURE" ? data.bpDiastolic : undefined
}
/>
);
}
interface TargetReferencePanelProps {
target: TargetItem;
bpDiastolic?: TargetsResponse["bpDiastolic"];
/** Optional sub-heading shown above the panel (used for glucose contexts). */
heading?: string;
/**
* Suppress the per-panel "Adjust target range" link. Glucose stacks
* up to four panels and lifts a single link to the group wrapper, so
* each panel omits its own to avoid repeating the same link.
*/
hideAdjustLink?: boolean;
}
/** The shared "Adjust target range" link to the `/targets` editing surface. */
function AdjustTargetLink() {
const { t } = useTranslations();
return (
<Link
href="/targets"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.subPage.target.adjustLink")}
</Link>
);
}
/**
* One compact reference panel: range + status pill + source, a range
* bar (plus a diastolic bar for BP), the in-target share + 30-day
* average, the 7-day consistency strip, and the adjust-target link.
* All fields come from the `/api/insights/targets` payload — nothing is
* recomputed here.
*/
function TargetReferencePanel({
target,
bpDiastolic,
heading,
hideAdjustLink,
}: TargetReferencePanelProps) {
const { t } = useTranslations();
const { range, unit } = target;
if (!range) return null;
const isBp = target.type === "BLOOD_PRESSURE";
const isMedicationCompliance = target.type === "MEDICATION_COMPLIANCE";
// Compose the range string. Blood pressure stitches the systolic band
// (on this target) with the diastolic band (on `bpDiastolic`) so the
// user reads the familiar `S/D` pair.
let rangeLabel: string;
if (targetType === "BLOOD_PRESSURE" && data.bpDiastolic.range) {
const dia = data.bpDiastolic.range;
if (isBp && bpDiastolic?.range) {
const dia = bpDiastolic.range;
rangeLabel = t("insights.subPage.target.bpRange", {
sysMin: fmt(range.min),
sysMax: fmt(range.max),
@@ -133,41 +270,133 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
});
}
const sourceLink = getTargetSourceLink(target);
// In-target share: fraction of logged days in the green band over the
// last 30. Suppressed when the route flagged insufficient data so the
// sub-page mirrors the `/targets` strip exactly.
const showShare =
!target.insufficientData && target.daysLogged30d > 0;
const showShare = !target.insufficientData && target.daysLogged30d > 0;
const sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
: null;
// 30-day average. BP stitches the diastolic average into the familiar
// `S/D` pair; everything else rounds to one decimal.
let averageLabel: string | null = null;
if (target.average30 != null) {
const avgValue =
isBp && bpDiastolic?.average30 != null
? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}`
: String(Math.round(target.average30 * 10) / 10);
averageLabel = `${t("targets.average30d")} ${avgValue} ${unit}`;
}
const showConsistency =
!target.insufficientData &&
target.consistency7d != null &&
target.consistency7d.length > 0;
return (
<Card data-slot="metric-target-summary">
<CardContent className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-3">
<div
data-slot="metric-target-summary"
data-target-type={target.type}
className="bg-muted/30 space-y-3 rounded-lg px-3 py-2.5"
>
{/* Row 1: target range string + status pill + source link. */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
<div className="flex items-center gap-2.5">
<Target
className="text-dracula-green size-4 shrink-0"
aria-hidden="true"
/>
<div className="space-y-0.5">
<p className="text-sm font-medium">{rangeLabel}</p>
{sharePct != null ? (
<p className="text-muted-foreground text-xs">
{t("insights.subPage.target.inTargetShare", {
pct: sharePct,
})}
{heading ? (
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
{heading}
</p>
) : null}
<p className="text-sm font-medium">{rangeLabel}</p>
</div>
</div>
<Link
href="/targets"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.subPage.target.adjustLink")}
</Link>
</CardContent>
</Card>
<div className="flex items-center gap-2">
{target.classification ? (
<TargetStatusPill
classification={target.classification}
range={range}
unit={unit}
source={target.source}
/>
) : null}
</div>
</div>
{/* Row 2: range bar — where today's value sits inside the band. */}
{target.current != null ? (
<RangeBar
value={target.current}
min={range.min}
max={range.max}
unit={unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
) : null}
{/* Row 2b: diastolic range bar for blood pressure. */}
{isBp && bpDiastolic?.range && bpDiastolic.current != null ? (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">
{t("targets.diastolic")}
</p>
<RangeBar
value={bpDiastolic.current}
min={bpDiastolic.range.min}
max={bpDiastolic.range.max}
unit="mmHg"
/>
</div>
) : null}
{/* Row 3: in-target share + 30-day average. */}
{sharePct != null || averageLabel ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
{sharePct != null ? (
<span>
{t("insights.subPage.target.inTargetShare", { pct: sharePct })}
</span>
) : null}
{averageLabel ? <span>{averageLabel}</span> : null}
</div>
) : null}
{/* Row 4: 7-day consistency strip. */}
{showConsistency && target.consistency7d ? (
<ConsistencyStrip
days={target.consistency7d}
daysInRange={target.daysInRange7d ?? 0}
daysLogged={target.daysLogged7d ?? 0}
/>
) : null}
{/* Footer: source link + adjust-target link. */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 pt-0.5">
{sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<span>{t("targets.sourceLabel", { source: target.source })}</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground text-xs">
{t("targets.sourceLabel", { source: target.source })}
</span>
)}
{hideAdjustLink ? null : <AdjustTargetLink />}
</div>
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import dynamic from "next/dynamic";
import { Activity } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import type { CorrelationResult } from "@/lib/analytics/correlations";
/**
* v1.8.5 — mood × metric correlation cards (Apple "Life Factors" parity).
*
* Reuses the shared `<ScatterCorrelationChart>` (same wrapper the
* mother-page correlation cards ride) for each mood × (sleep / steps /
* pulse) pair. The Pearson math comes pre-computed from
* `/api/mood/insights` (single source of truth with the LLM snapshot).
* Below-threshold pairs render an EmptyState rather than a misleading
* near-zero scatter.
*/
const ScatterCorrelationChart = dynamic(
() =>
import("@/components/charts/scatter-correlation-chart").then((mod) => ({
default: mod.ScatterCorrelationChart,
})),
{
ssr: false,
loading: () => (
<div className="bg-muted/40 aspect-square min-h-[180px] w-full animate-pulse rounded-md motion-reduce:animate-none sm:aspect-[3/2] sm:h-auto" />
),
},
);
export interface MoodCorrelationPoint {
x: number;
y: number;
[key: string]: number;
}
export interface MoodMetricCorrelationData {
result: CorrelationResult | null;
points: MoodCorrelationPoint[];
n: number;
}
type MetricKind = "sleep" | "steps" | "pulse";
const TITLE_KEY: Record<MetricKind, string> = {
sleep: "insights.mood.correlation.sleepTitle",
steps: "insights.mood.correlation.stepsTitle",
pulse: "insights.mood.correlation.pulseTitle",
};
const Y_LABEL_KEY: Record<MetricKind, string> = {
sleep: "insights.mood.correlation.sleepAxis",
steps: "insights.mood.correlation.stepsAxis",
pulse: "insights.mood.correlation.pulseAxis",
};
const FILL_BY_KIND: Record<MetricKind, string> = {
sleep: "var(--dracula-purple)",
steps: "var(--dracula-cyan)",
pulse: "var(--dracula-pink)",
};
const STRENGTH_KEY: Record<CorrelationResult["strength"], string> = {
stark: "insights.mood.correlation.strengthStrong",
moderat: "insights.mood.correlation.strengthModerate",
schwach: "insights.mood.correlation.strengthWeak",
keine: "insights.mood.correlation.strengthNone",
};
function MoodCorrelationCard({
kind,
data,
}: {
kind: MetricKind;
data: MoodMetricCorrelationData;
}) {
const { t } = useTranslations();
const hasResult = data.result != null && data.n >= 5;
return (
<Card data-slot="mood-correlation-card" data-kind={kind}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-semibold">
{t(TITLE_KEY[kind])}
</CardTitle>
{hasResult && data.result && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
{hasResult && data.result ? (
<>
<ScatterCorrelationChart
data={data.points}
fill={FILL_BY_KIND[kind]}
xAxis={{
dataKey: "x",
name: t("insights.moodSectionTitle"),
domain: [1, 5],
ticks: [1, 2, 3, 4, 5],
}}
yAxis={{ dataKey: "y", name: t(Y_LABEL_KEY[kind]) }}
height={180}
/>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
})}
</p>
</>
) : (
<EmptyState
variant="plain"
size="compact"
icon={<Activity className="size-5" />}
title={t("insights.mood.correlation.emptyTitle")}
description={t("insights.mood.correlation.emptyDescription")}
/>
)}
</CardContent>
</Card>
);
}
export function MoodCorrelationCards({
sleep,
steps,
pulse,
}: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
}) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<MoodCorrelationCard kind="sleep" data={sleep} />
<MoodCorrelationCard kind="steps" data={steps} />
<MoodCorrelationCard kind="pulse" data={pulse} />
</div>
);
}
@@ -0,0 +1,108 @@
"use client";
import {
Bar,
BarChart,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
/**
* v1.8.5 — mood distribution (share of days per level).
*
* One bar per discrete mood level 1..5, coloured by the same band map
* as the heatmap + line chart. Counts are days (daily means rounded to
* the nearest level) so multi-entry days never over-weight the
* histogram — consistent with the rest of the surface's daily-mean
* convention.
*/
export interface MoodDistributionRow {
score: number;
count: number;
}
const BAR_COLOR_BY_SCORE: Record<number, string> = {
1: "var(--dracula-red)",
2: "var(--dracula-orange)",
3: "var(--dracula-yellow)",
4: "var(--dracula-green)",
5: "var(--dracula-green)",
};
export function MoodDistributionChart({
distribution,
}: {
distribution: MoodDistributionRow[];
}) {
const { t } = useTranslations();
const data = distribution.map((row) => {
const labelKey = moodLabelKeyForScore(row.score);
return {
score: row.score,
count: row.count,
label: labelKey ? t(labelKey) : String(row.score),
};
});
return (
<div className="aspect-[3/2] min-h-[180px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 8, left: 0 }}>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
interval={0}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
width={28}
/>
<Tooltip
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={(value) =>
[
`${value ?? 0} ${t("insights.mood.distributionDaysUnit")}`,
t("insights.mood.distributionTitle"),
] as [string, string]
}
/>
<Bar
dataKey="count"
radius={[3, 3, 0, 0]}
// v1.8.5 — faint full-height track behind each bar so a
// zero-count level still reads as a present "0" bucket rather
// than a missing slot. The coloured bar paints over the track
// for non-empty levels.
background={{ fill: "var(--secondary)", opacity: 0.35, radius: 3 }}
>
{data.map((row) => (
<Cell
key={row.score}
fill={BAR_COLOR_BY_SCORE[row.score] ?? "var(--dracula-purple)"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,167 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
import {
MoodDistributionChart,
type MoodDistributionRow,
} from "./mood-distribution-chart";
import {
MoodWeekdayChart,
type MoodWeekdayRow,
} from "./mood-weekday-chart";
import {
MoodTagBreakdown,
type MoodTagRow,
} from "./mood-tag-breakdown";
import {
MoodCorrelationCards,
type MoodMetricCorrelationData,
} from "./mood-correlation-cards";
import {
MoodStructuredTagBreakdown,
type MoodStructuredTagRow,
} from "./mood-structured-tag-breakdown";
import {
MoodNotesTimeline,
type MoodNoteEntry,
} from "./mood-notes-timeline";
/**
* v1.8.5 — additional Mood Insights sections.
*
* Reads the pre-computed aggregate bundle from `/api/mood/insights`
* (cheap cached server read, no LLM) and paints the heatmap,
* distribution, weekday pattern, tag breakdown, and mood × metric
* correlation cards. Renders nothing while loading / on error / on an
* empty data set so the page degrades gracefully to the line chart.
*/
interface MoodInsightsResponse {
summary: {
totalEntries: number;
};
heatmap: {
windowDays: number;
cells: Array<{ date: string; score: number; samples: number }>;
};
distribution: MoodDistributionRow[];
weekday: MoodWeekdayRow[];
tags: MoodTagRow[];
structuredTags: MoodStructuredTagRow[];
notesTimeline: MoodNoteEntry[];
correlations: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
};
}
function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
export function MoodInsightsSections() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: queryKeys.moodInsights(),
queryFn: async () => {
const res = await fetch("/api/mood/insights");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as MoodInsightsResponse;
},
enabled: isAuthenticated,
staleTime: 60_000,
});
if (isLoading) {
return <Skeleton className="h-48 w-full rounded-lg" />;
}
if (!data || data.summary.totalEntries === 0) {
return null;
}
const heatmapCells = Object.fromEntries(
data.heatmap.cells.map((cell) => [cell.date, cell]),
);
const hasTags = data.tags.length > 0;
const hasStructuredTags = data.structuredTags.length > 0;
const hasNotes = data.notesTimeline.length > 0;
return (
<div className="space-y-4">
<SectionCard title={t("insights.mood.heatmapTitle")}>
<MoodHeatmap
cells={heatmapCells}
days={data.heatmap.windowDays}
stretch
/>
</SectionCard>
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard title={t("insights.mood.distributionTitle")}>
<MoodDistributionChart distribution={data.distribution} />
</SectionCard>
<SectionCard title={t("insights.mood.weekdayTitle")}>
<MoodWeekdayChart weekday={data.weekday} />
</SectionCard>
</div>
{hasStructuredTags && (
<SectionCard title={t("insights.mood.structuredTagsTitle")}>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
</SectionCard>
)}
{hasTags && (
<SectionCard title={t("insights.mood.tagsTitle")}>
<MoodTagBreakdown tags={data.tags} />
</SectionCard>
)}
<div className="space-y-2">
<h3 className="text-base font-semibold">
{t("insights.mood.correlationsTitle")}
</h3>
<p className="text-muted-foreground text-sm">
{t("insights.mood.correlationsDescription")}
</p>
<MoodCorrelationCards
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
/>
</div>
{hasNotes && (
<SectionCard title={t("insights.mood.notesTimelineTitle")}>
<MoodNotesTimeline notes={data.notesTimeline} />
</SectionCard>
)}
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { MOOD_LABEL_KEYS } from "@/lib/mood/labels";
/**
* v1.8.5 (C1) — notes timeline.
*
* Chronological feed of recent mood entries that carry a free-text note,
* each with its score, mood label, note text, and tag chips. Renders the
* note as a plain React text child (no markdown library — the project
* forbids one for XSS reasons).
*/
export interface MoodNoteEntry {
date: string;
loggedAt: string;
score: number;
mood: string | null;
note: string;
tags: string[];
structuredTagLabelKeys: string[];
}
export function MoodNotesTimeline({ notes }: { notes: MoodNoteEntry[] }) {
const { t } = useTranslations();
return (
<ul className="space-y-3" data-slot="mood-notes-timeline">
{notes.map((entry) => {
const moodLabel =
entry.mood && MOOD_LABEL_KEYS[entry.mood]
? t(MOOD_LABEL_KEYS[entry.mood])
: entry.mood;
return (
<li
key={`${entry.loggedAt}-${entry.date}`}
className="border-border/60 border-l-2 pl-3"
>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{entry.score}
</span>
{moodLabel && <span>({moodLabel})</span>}
<span>·</span>
<span>{formatDateTime(entry.loggedAt)}</span>
</div>
<p className="text-foreground/90 mt-0.5 text-sm whitespace-pre-wrap">
{entry.note}
</p>
{(entry.structuredTagLabelKeys.length > 0 ||
entry.tags.length > 0) && (
<div className="mt-1 flex flex-wrap gap-1">
{entry.structuredTagLabelKeys.map((labelKey) => (
<span
key={labelKey}
className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px]"
>
{t(labelKey)}
</span>
))}
{entry.tags.map((tag) => (
<span
key={tag}
className="border-border/70 text-muted-foreground rounded-full border px-2 py-0.5 text-[10px]"
>
{tag}
</span>
))}
</div>
)}
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,76 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
/**
* v1.8.5 — structured-tag breakdown.
*
* Ranked icon-bearing rows over the taxonomy tags the user picked,
* grouped under their category, with the per-tag average mood ("lift")
* as a coloured score. Distinct from the flat free-text breakdown:
* structured tags carry icons + categories from the curated catalog.
*/
export interface MoodStructuredTagRow {
key: string;
categoryKey: string;
labelKey: string;
icon: string | null;
count: number;
avgScore: number;
}
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
}
export function MoodStructuredTagBreakdown({
tags,
}: {
tags: MoodStructuredTagRow[];
}) {
const { t } = useTranslations();
const maxCount = tags.reduce((m, row) => Math.max(m, row.count), 0) || 1;
return (
<ul className="space-y-2" data-slot="mood-structured-tag-breakdown">
{tags.map((row) => {
const Icon = moodTagIcon(row.icon);
return (
<li key={row.key} className="flex items-center gap-2 text-sm">
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="text-foreground w-28 shrink-0 truncate">
{t(row.labelKey)}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${(row.count / maxCount) * 100}%`,
backgroundColor: colorForScore(row.avgScore),
}}
/>
</div>
<span className="text-muted-foreground w-6 shrink-0 text-right tabular-nums">
{row.count}
</span>
<span
className="w-10 shrink-0 text-right text-xs font-medium tabular-nums"
style={{ color: colorForScore(row.avgScore) }}
title={t("insights.mood.tagAvgMood")}
>
{row.avgScore.toFixed(1)}
</span>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.8.5 — tag / trigger breakdown.
*
* Ranked horizontal bars over tag frequency, with the per-tag average
* mood ("lift") rendered as a coloured score chip. The aggregation comes
* pre-computed from `/api/mood/insights` (shared with the LLM snapshot's
* tag block, so the prose and the chart agree). Works on the flat
* free-text tag array we hold today — the structured-tag taxonomy with
* icons is a separate later piece.
*/
export interface MoodTagRow {
tag: string;
count: number;
avgScore: number;
}
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
}
export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
const { t } = useTranslations();
const maxCount = tags.reduce((m, row) => Math.max(m, row.count), 0) || 1;
return (
<ul className="space-y-2" data-slot="mood-tag-breakdown">
{tags.map((row) => (
<li key={row.tag} className="flex items-center gap-2 text-sm">
<span className="text-foreground w-28 shrink-0 truncate" title={row.tag}>
{row.tag}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${(row.count / maxCount) * 100}%`,
backgroundColor: colorForScore(row.avgScore),
}}
/>
</div>
<span className="text-muted-foreground w-6 shrink-0 text-right tabular-nums">
{row.count}
</span>
<span
className="w-10 shrink-0 text-right text-xs font-medium tabular-nums"
style={{ color: colorForScore(row.avgScore) }}
title={t("insights.mood.tagAvgMood")}
>
{row.avgScore.toFixed(1)}
</span>
</li>
))}
</ul>
);
}
@@ -0,0 +1,144 @@
"use client";
import { useMemo } from "react";
import {
Bar,
BarChart,
Cell,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.8.5 — average mood per weekday (Monday-aligned).
*
* One bar per weekday over the average daily-mean mood, with the
* best/worst day called out below the chart. Empty weekdays (no data)
* render as a zero-height bar so the seven-column rhythm stays intact.
*/
export interface MoodWeekdayRow {
/** 0 = Monday … 6 = Sunday. */
weekday: number;
avgScore: number | null;
count: number;
}
const WEEKDAY_LABEL_KEYS = [
"charts.weekdaysFull.mon",
"charts.weekdaysFull.tue",
"charts.weekdaysFull.wed",
"charts.weekdaysFull.thu",
"charts.weekdaysFull.fri",
"charts.weekdaysFull.sat",
"charts.weekdaysFull.sun",
] as const;
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
}
export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
const { t } = useTranslations();
const data = weekday.map((row) => ({
weekday: row.weekday,
label: t(WEEKDAY_LABEL_KEYS[row.weekday]),
value: row.avgScore ?? 0,
avgScore: row.avgScore,
count: row.count,
}));
const { best, worst } = useMemo(() => {
const populated = weekday.filter(
(r): r is MoodWeekdayRow & { avgScore: number } => r.avgScore != null,
);
if (populated.length === 0) return { best: null, worst: null };
let bestRow = populated[0];
let worstRow = populated[0];
for (const row of populated) {
if (row.avgScore > bestRow.avgScore) bestRow = row;
if (row.avgScore < worstRow.avgScore) worstRow = row;
}
return { best: bestRow, worst: worstRow };
}, [weekday]);
return (
<div className="space-y-2">
<div className="aspect-[3/2] min-h-[160px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
interval={0}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
width={24}
/>
<ReferenceLine y={3.5} stroke="var(--dracula-green)" strokeDasharray="3 3" />
<Tooltip
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={(_value, _name, item) => {
const payload = item?.payload as
| { avgScore: number | null; count: number }
| undefined;
if (!payload || payload.avgScore == null) {
return [t("insights.mood.weekdayNoData"), ""];
}
return [
`${payload.avgScore.toFixed(1)} (${payload.count})`,
t("insights.mood.weekdayTitle"),
];
}}
/>
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
{data.map((row) => (
<Cell
key={row.weekday}
fill={
row.avgScore == null
? "var(--secondary)"
: colorForScore(row.avgScore)
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{best && worst && (
<p className="text-muted-foreground text-xs">
{t("insights.mood.weekdayBestWorst", {
best: t(WEEKDAY_LABEL_KEYS[best.weekday]),
worst: t(WEEKDAY_LABEL_KEYS[worst.weekday]),
})}
</p>
)}
</div>
);
}
+62 -6
View File
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import Link from "next/link";
import { ListOrdered } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MetricExplainer } from "@/components/insights/metric-explainer";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
@@ -66,6 +69,26 @@ export interface SubPageShellProps {
* affordance, not an a11y one.
*/
focusOnMount?: boolean;
/**
* v1.8.5 W4b — numbers-first stat strip rendered directly beneath the
* header, above the chart. Sub-pages pass `<MetricStatStrip metric=… />`
* so every metric (including the thin HealthKit pages) leads with
* min / max / median / mean. Omitted on the empty-state branch.
*/
statStrip?: ReactNode;
/**
* v1.8.5 W4b — measurement-diversity nudge mounted just under the stat
* strip. A gentle hint when readings cluster on one weekday / time.
* Self-gating: renders nothing when the spread is healthy.
*/
diversityNudge?: ReactNode;
/**
* v1.8.5 W4b — a "show all readings" entry rendered at the foot of the
* page, after the chart + cards. Sub-pages pass the metric's
* `MeasurementType`; the shell renders a button that links to the
* dedicated `/insights/values/<type>` subpage.
*/
showAllValuesType?: string;
children: ReactNode;
}
@@ -75,6 +98,9 @@ export function SubPageShell({
description,
explainerMetric,
focusOnMount = false,
statStrip,
diversityNudge,
showAllValuesType,
children,
}: SubPageShellProps) {
const { t } = useTranslations();
@@ -100,8 +126,8 @@ export function SubPageShell({
}, [focusOnMount]);
return (
<div data-slot="insights-subpage" className="space-y-6">
<header className="space-y-2">
<div data-slot="insights-subpage" className="space-y-4 md:space-y-5">
<header className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h1
ref={headingRef}
@@ -129,9 +155,16 @@ export function SubPageShell({
</div>
{explainerMetric ? (
// v1.8.4 — surface the explainer definition inline, reusing the
// exact body string the `?` popover reads. Muted caption styling
// matches the description paragraph below so the under-heading
// band reads as one calm block.
// exact body string the `?` popover reads.
//
// v1.8.5 W4a — render the explainer body as the *single* caption
// under the heading. Pre-v1.8.5 both this paragraph and the
// `description` below it stacked, so every metric page opened
// with two near-duplicative muted captions before any data — the
// root of the "airy / static under the heading" feel. The
// explainer body is the definition, so it wins; the description
// only renders when no explainer is set (the mother page and any
// future explainer-less sub-page).
<p
data-slot="metric-explainer-inline"
className="text-muted-foreground text-sm leading-relaxed"
@@ -139,11 +172,34 @@ export function SubPageShell({
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
</p>
) : null}
{description ? (
{description && !explainerMetric ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
{/* v1.8.5 W4b — numbers-first stat strip + diversity nudge sit
between the header and the chart so the page leads with data,
mirroring the Apple Health / Withings detail layout. Both are
self-gating (render nothing without data), so the spacing
rhythm holds on the empty-state and brand-new-metric paths. */}
{statStrip}
{diversityNudge}
{children}
{/* v1.8.5 W4b — "show all readings" entry at the foot, linking to
the dedicated per-metric values subpage. */}
{showAllValuesType ? (
<Button
asChild
variant="outline"
size="sm"
data-slot="metric-show-all-values"
className="w-full sm:w-auto"
>
<Link href={`/insights/values/${showAllValuesType}`}>
<ListOrdered className="mr-1.5 size-4" aria-hidden="true" />
{t("insights.subPage.showAllValues")}
</Link>
</Button>
) : null}
</div>
);
}
+11 -3
View File
@@ -70,7 +70,10 @@ export function SuggestedPrompts({
return (
<div
data-slot="insights-suggested-prompts"
className={cn("flex flex-wrap items-center gap-2", className)}
// v1.8.5 W4a — tighten the chip row gutter (`gap-1.5` vs `gap-2`) so
// the condensed Coach band reads as a dense, deliberate strip rather
// than an airy wrap. The chips themselves shrink their padding below.
className={cn("flex flex-wrap items-center gap-1.5", className)}
>
<span
data-slot="insights-suggested-prompts-label"
@@ -84,10 +87,15 @@ export function SuggestedPrompts({
type="button"
onClick={() => onPick(prompt)}
data-slot="insights-suggested-prompts-chip"
// v1.8.5 W4a — denser chips. The tap target stays comfortable
// (`min-h-9` ≈ 36 px, the WCAG/iOS floor) but the horizontal
// padding + type size drop a notch so five prompts pack into
// fewer rows and the left column stops towering over the
// Health-Score card.
className={cn(
"border-dracula-purple/18 hover:border-dracula-purple/40 hover:text-foreground",
"text-muted-foreground inline-flex min-h-11 items-center gap-1.5",
"rounded-full border bg-transparent px-3.5 py-2 text-[13px]",
"text-muted-foreground inline-flex min-h-9 items-center gap-1.5",
"rounded-full border bg-transparent px-3 py-1.5 text-xs",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/50",
)}
+102 -90
View File
@@ -5,6 +5,12 @@ import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import type { DailyBriefing } from "@/lib/ai/schema";
import {
selectTrendCharts,
type TrendAnnotationKey,
type TrendChartConfig,
} from "@/lib/insights/trend-chart-select";
import {
TrendAnnotation,
type TrendAnnotationConfidenceBand,
@@ -12,20 +18,30 @@ import {
} from "./trend-annotation";
/**
* Trends row — three small charts (BP / Weight / Mood) above a
* one-sentence assessment each. Recharts is ~108 KiB Brotli, so the
* three charts ride the shared lazy-import boundary
* (`<HealthChartDynamicDynamic>` for BP + weight; MoodChart still resolves
* locally because no shared re-export exists yet for it).
* Trends row — a small chart per metric above a one-sentence
* assessment. Recharts is ~108 KiB Brotli, so the charts ride the
* shared lazy-import boundary (`<HealthChartDynamicMini>` for every
* measurement series; `<MoodChart>` resolves locally because no shared
* re-export exists for it yet).
*
* v1.8.5 — the chart set is DYNAMIC. It mirrors the daily briefing:
* the row charts exactly the metrics the briefing flags, in the
* briefing's priority order, deduped and capped (top three). When no
* briefing is available — cold mount, web-only account with no
* findings, or a pre-briefing cached payload — `selectTrendCharts`
* falls back to the legacy BP / weight / mood triple so the row never
* paints empty. Selection lives in `@/lib/insights/trend-chart-select`
* (pure + unit-tested); this component is the renderer.
*
* Layout:
* - `<md`: single column, full width
* - `>=md`: 3-up grid, equal column tracks
*
* Annotations come from `trendAnnotations.{bp,weight,mood}` on the
* Insights payload. When a metric's annotation is null, the
* `<TrendAnnotation>` empty state hints at the gap without breaking
* the row's visual rhythm.
* Insights payload and only attach to those three slots (the advisor
* authors annotations for the legacy triple). Additive metrics render
* chart-only. When a slot's annotation is null, `<TrendAnnotation>`'s
* empty state hints at the gap without breaking the row's rhythm.
*/
const MoodChart = dynamic(
@@ -38,9 +54,16 @@ const MoodChart = dynamic(
interface TrendsRowProps {
/**
* Assistant-authored one-sentence annotations, keyed by metric. Optional —
* legacy advisor payloads (pre-PROMPT_VERSION 4.20.1) won't carry
* the field.
* Daily briefing payload. Drives the chart set: the row charts the
* metrics the briefing flags, in order, deduped + capped. `null` /
* omitted → the default BP / weight / mood triple.
*/
briefing?: DailyBriefing | null;
/**
* Assistant-authored one-sentence annotations, keyed by metric. Only
* the legacy triple (bp / weight / mood) carries annotations.
* Optional — legacy advisor payloads (pre-PROMPT_VERSION 4.20.1)
* won't carry the field.
*/
annotations?: {
bp?: string | null;
@@ -67,17 +90,38 @@ interface TrendsRowProps {
loading?: boolean;
}
function MetricChart({ config, title }: { config: TrendChartConfig; title: string }) {
const { user } = useAuth();
const userTimezone = user?.timezone;
if (config.kind === "mood") {
return <MoodChart title={title} mini userTimezone={userTimezone} />;
}
return (
<HealthChartDynamicMini
types={config.types}
title={title}
colors={config.colors}
unit={config.unit}
yAxisUnit={config.yAxisUnit}
mini
userTimezone={userTimezone}
/>
);
}
export function TrendsRow({
briefing,
annotations,
confidence,
loading = false,
}: TrendsRowProps) {
const { t } = useTranslations();
const { user } = useAuth();
const userTimezone = user?.timezone;
const bpAnnotation = annotations?.bp ?? null;
const weightAnnotation = annotations?.weight ?? null;
const moodAnnotation = annotations?.mood ?? null;
// v1.8.5 — derive the chart set from the briefing. No new fetch: the
// briefing payload is already on the page (advisor cache), so this is
// a pure read that respects the v1.8.3 anti-freeze contract.
const charts = selectTrendCharts(briefing);
// v1.4.36 W2 T3 — derive the tri-state status per metric from the
// advisor's loading flag + the annotation presence. Pending wins
// over needs_data so a mid-generation regenerate doesn't flash the
@@ -87,6 +131,9 @@ export function TrendsRow({
return annotation ? "generated" : "needs_data";
};
const annotationFor = (key: TrendAnnotationKey): string | null =>
annotations?.[key] ?? null;
return (
<section
data-slot="trends-row"
@@ -122,81 +169,46 @@ export function TrendsRow({
mobile single-column path picks up the same row contract
when the user expands the strip side-by-side via the
orientation-change. The chart slot is now wrapped in a
fixed-height shell so BP / weight / mood all paint the
series at the same vertical position; the annotation slot
clamps via `<TrendAnnotation>` so long captions can't pull
the row taller than the design constant. */}
fixed-height shell so the series paint at the same vertical
position; the annotation slot clamps via `<TrendAnnotation>`
so long captions can't pull the row taller than the design
constant.
v1.8.5 — the cards are now rendered from the dynamic chart
set instead of three hard-coded tiles. The grid + slot
contract is unchanged; the card count tracks the briefing
(capped at three so the 3-up template still holds). */}
<div className="grid auto-rows-fr grid-cols-1 gap-4 md:grid-cols-3 md:items-stretch">
<div
data-slot="trends-row-card"
data-metric="bp"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
{/* v1.4.28 R3c-Insights — fixed chart slot. `<HealthChartDynamic>`
mini paints its own 140 px chart band; this wrapper pins
the total chart-envelope height so the mood tile's Card
wrapper (which carries a heavier shell on a default
shadcn Card) lines up with the BP/weight tiles' lighter
shell. Both chart types ship the same data-slot now. */}
<div data-slot="trends-row-chart-slot" className="h-[180px] shrink-0">
<HealthChartDynamicMini
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
unit="mmHg"
yAxisUnit="mmHg"
mini
userTimezone={userTimezone}
/>
</div>
<TrendAnnotation
metric="bp"
annotation={bpAnnotation}
confidence={confidence?.bp}
status={statusFor(bpAnnotation)}
/>
</div>
<div
data-slot="trends-row-card"
data-metric="weight"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
<div data-slot="trends-row-chart-slot" className="h-[180px] shrink-0">
<HealthChartDynamicMini
types={["WEIGHT"]}
title={t("charts.weight")}
colors={["#bd93f9"]}
unit="kg"
mini
userTimezone={userTimezone}
/>
</div>
<TrendAnnotation
metric="weight"
annotation={weightAnnotation}
confidence={confidence?.weight}
status={statusFor(weightAnnotation)}
/>
</div>
<div
data-slot="trends-row-card"
data-metric="mood"
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
<div data-slot="trends-row-chart-slot" className="h-[180px] shrink-0">
<MoodChart
title={t("charts.mood")}
mini
userTimezone={userTimezone}
/>
</div>
<TrendAnnotation
metric="mood"
annotation={moodAnnotation}
confidence={confidence?.mood}
status={statusFor(moodAnnotation)}
/>
</div>
{charts.map((config) => {
const title = t(config.titleKey);
return (
<div
key={config.metric}
data-slot="trends-row-card"
data-metric={config.metric}
className="flex h-full flex-col gap-2 md:min-h-[300px]"
>
{/* v1.4.28 R3c-Insights — fixed chart slot. The mini
chart paints its own band; this wrapper pins the
total chart-envelope height so every tile lines up on
a single baseline regardless of the chart kind. */}
<div
data-slot="trends-row-chart-slot"
className="h-[180px] shrink-0"
>
<MetricChart config={config} title={title} />
</div>
{config.annotationKey ? (
<TrendAnnotation
metric={config.annotationKey}
annotation={annotationFor(config.annotationKey)}
confidence={confidence?.[config.annotationKey]}
status={statusFor(annotationFor(config.annotationKey))}
/>
) : null}
</div>
);
})}
</div>
</section>
);
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.8.5 — `<MeasurementList lockedType>` for the insights "all readings"
* subpage. When pinned to a type the global type selector is suppressed
* and every row belongs to that metric; the list still paginates and
* exposes inline edit/delete (unchanged from the unlocked path).
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights/values/WEIGHT",
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
measurements: [
{
id: "m1",
type: "WEIGHT",
value: 78.4,
unit: "kg",
source: "MANUAL",
measuredAt: "2026-05-30T08:00:00.000Z",
notes: null,
},
],
meta: { total: 1 },
},
isLoading: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "marc", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementList } from "../measurement-list";
function render(lockedType?: string) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementList lockedType={lockedType} />
</I18nProvider>,
);
}
describe("MeasurementList — lockedType", () => {
it("hides the type selector when locked to a metric", () => {
const locked = render("WEIGHT");
const unlocked = render();
// The unlocked list paints the filter trigger; the locked one does
// not (the metric is fixed by the route).
expect(unlocked).toContain("Filter by type");
expect(locked).not.toContain('role="combobox"');
});
it("loads the metric's readings", () => {
const html = render("WEIGHT");
// The single seeded WEIGHT row renders with its value + unit.
expect(html).toContain("78.4");
expect(html).toContain("kg");
});
});
@@ -103,6 +103,15 @@ interface MeasurementListProps {
* (fallback to the header CTA) instead of crashing.
*/
onAddFirst?: () => void;
/**
* v1.8.5 — pin the list to a single `MeasurementType` and hide the
* type selector. Used by the insights "all readings" subpage, which
* already knows the metric from the route and wants a focused list
* (pagination + inline edit/delete) rather than the global filter UI.
* When set, the type `Select` is not rendered and every fetch is
* scoped to this type; the count caption still shows.
*/
lockedType?: string;
}
const PAGE_SIZE = 25;
@@ -152,12 +161,20 @@ function sourceBadgeClass(source: string): string {
return "";
}
export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
export function MeasurementList({
onEdit,
onAddFirst,
lockedType,
}: MeasurementListProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [typeFilter, setTypeFilterRaw] = useState<string>("ALL");
// v1.8.5 — when `lockedType` is set the list is pinned to that metric
// and the type selector is hidden; the filter state seeds from it.
const [typeFilter, setTypeFilterRaw] = useState<string>(
lockedType ?? "ALL",
);
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("measuredAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
@@ -419,22 +436,31 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
Pixel 5 (375 px content width) left only ~120 px for the
count, which wrapped to 2 lines. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger
className="w-full sm:w-48"
aria-label={t("measurements.filterByType")}
>
<SelectValue placeholder={t("measurements.allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t("measurements.allTypes")}</SelectItem>
{Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => (
<SelectItem key={val} value={val}>
{t(labelKey)}
{lockedType ? (
// v1.8.5 — the insights "all readings" subpage knows the
// metric from the route, so the global type selector is
// suppressed; the count caption still anchors the column.
<span className="sr-only">{t("measurements.filterByType")}</span>
) : (
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger
className="w-full sm:w-48"
aria-label={t("measurements.filterByType")}
>
<SelectValue placeholder={t("measurements.allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("measurements.allTypes")}
</SelectItem>
))}
</SelectContent>
</Select>
{Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => (
<SelectItem key={val} value={val}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("measurements.measurementCount", {
@@ -457,17 +483,19 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
<EmptyState
icon={<Activity className="size-6" />}
title={
typeFilter === "ALL"
typeFilter === "ALL" || lockedType
? t("measurements.emptyTitle")
: t("measurements.emptyFilteredTitle")
}
description={
typeFilter === "ALL"
typeFilter === "ALL" || lockedType
? t("measurements.emptyDescription")
: t("measurements.emptyFilteredDescription")
}
action={
typeFilter !== "ALL" ? (
// v1.8.5 — a locked-type list has no "reset filter" path;
// the metric is fixed by the route.
typeFilter !== "ALL" && !lockedType ? (
<Button
variant="outline"
onClick={() => setTypeFilter("ALL")}
@@ -90,6 +90,25 @@ describe("<InjectionSitePicker>", () => {
expect(html).toContain("Recommended next:");
});
it("folds the recommendation into the recommended site's aria-label (SR parity)", () => {
// The "recommended next site" cue must not be purely visual (the
// dashed ring). The recommended site's interactive circle carries an
// aria-label that includes the recommendation, while every other
// site keeps its plain site name.
const html = render(
<InjectionSitePicker
value={null}
history={["ABDOMEN_LEFT", "ABDOMEN_UPPER_LEFT"]}
onChange={() => {}}
/>,
);
// Exactly one site is recommended → exactly one composed label.
const composed =
html.match(/aria-label="[^"]*— recommended next site"/g) ?? [];
expect(composed.length).toBe(1);
});
it("fires onChange(site) when the click handler is invoked", () => {
// SSR can't dispatch DOM events; smoke-check the contract by
// invoking the supplied handler with each enum value. The picker
@@ -130,8 +149,12 @@ describe("<InjectionSitePicker>", () => {
);
// The full set of EN labels must appear in the SSR string —
// verifies the t() lookup matches each enum value's i18n key.
expect(html).toContain('aria-label="Abdomen, lower left"');
// verifies the t() lookup matches each enum value's i18n key. With
// empty history the recommender lands on ABDOMEN_LEFT, so its label
// folds in the "recommended next site" cue.
expect(html).toContain(
'aria-label="Abdomen, lower left — recommended next site"',
);
expect(html).toContain('aria-label="Abdomen, lower right"');
expect(html).toContain('aria-label="Abdomen, upper left"');
expect(html).toContain('aria-label="Abdomen, upper right"');
@@ -149,7 +172,9 @@ describe("<InjectionSitePicker>", () => {
"de",
);
expect(html).toContain('aria-label="Bauch, unten links"');
expect(html).toContain(
'aria-label="Bauch, unten links – empfohlene nächste Stelle"',
);
expect(html).toContain('aria-label="Bauch, unten rechts"');
expect(html).toContain('aria-label="Bauch, oben links"');
expect(html).toContain('aria-label="Bauch, oben rechts"');
@@ -0,0 +1,173 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Flame } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
/**
* v1.8.5 — uptime-style per-dose adherence strip for sparse cadences.
*
* The medication cards keep the 7-/30-day percentage bars for dense
* cadences (daily / multi-daily / weekday meds) where the denominator is
* large enough for the percentage to be stable. For sparse cadences
* (weekly, bi-/tri-weekly, rolling 35-day injections) a rolling percentage
* swings violently off one sample and tells the user nothing; the server
* flips `complianceDisplay.mode` to `"timeline"` and this strip takes over.
*
* Each cell is one EXPECTED dose (not a calendar day), oldest → newest:
* taken=green / missed=red / skipped=grey / upcoming=outline. The color
* tokens and the tap-to-pin tooltip mirror the 90-day compliance heatmap
* (`src/components/charts/compliance-heatmap.tsx`) so the two surfaces read
* the same. Plain flex divs, not Recharts — a fixed-size status-cell row
* needs no chart container, axis, or tooltip plumbing.
*/
export type DoseStatus = "taken" | "missed" | "skipped" | "upcoming";
export type DoseTiming = "early" | "on_time" | "late" | "very_late" | "missed";
export interface DoseAdherenceCell {
scheduledFor: string;
status: DoseStatus;
takenAt: string | null;
timing: DoseTiming | null;
site: string | null;
}
interface DoseAdherenceTimelineProps {
doses: DoseAdherenceCell[];
summary: { taken: number; total: number; doseStreak: number };
}
// v1.4.27 MB7 / CF-10 precedent — clear the 14 px touch floor (with the
// gap the row stays tap-friendly on narrow viewports). Mirrors the
// heatmap's `CELL_FLOOR_PX`.
const CELL_PX = 14;
/** Token mirror of the heatmap's status palette. */
function cellColor(status: DoseStatus, timing: DoseTiming | null): string {
if (status === "upcoming") return "transparent";
if (status === "skipped") return "var(--secondary)";
if (status === "missed") return "var(--dracula-red)";
// taken — shade by punctuality, same ladder the heatmap uses.
if (timing === "very_late") return "var(--dracula-orange)";
if (timing === "late") return "var(--dracula-yellow)";
return "var(--dracula-green)";
}
export function DoseAdherenceTimeline({
doses,
summary,
}: DoseAdherenceTimelineProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const containerRef = useRef<HTMLDivElement | null>(null);
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
text: string;
pinned?: boolean;
} | null>(null);
// Mirror the heatmap: an outside tap clears a pinned tooltip so a touch
// user can dismiss the per-cell detail. Gated on `pinned` so the hover
// flow never pays the indirection.
useEffect(() => {
if (!tooltip?.pinned) return;
const handlePointer = (event: PointerEvent) => {
const container = containerRef.current;
if (!container) return;
if (!container.contains(event.target as Node)) setTooltip(null);
};
document.addEventListener("pointerdown", handlePointer, true);
return () => {
document.removeEventListener("pointerdown", handlePointer, true);
};
}, [tooltip?.pinned]);
function statusLabel(status: DoseStatus): string {
if (status === "taken") return t("medications.doseTaken");
if (status === "missed") return t("medications.doseMissed");
if (status === "skipped") return t("medications.doseSkipped");
return t("medications.doseUpcoming");
}
function cellText(cell: DoseAdherenceCell): string {
const when = fmt.dateTime(cell.scheduledFor);
return `${when} — ${statusLabel(cell.status)}`;
}
return (
<div className="space-y-2" ref={containerRef}>
<div className="relative">
<div
className="flex flex-wrap gap-1"
role="img"
aria-label={t("medications.doseTimelineAriaLabel")}
>
{doses.map((cell, i) => {
const isUpcoming = cell.status === "upcoming";
return (
<div
key={`${cell.scheduledFor}-${i}`}
className={`shrink-0 cursor-pointer rounded-sm ${
isUpcoming ? "border-border border border-dashed" : ""
}`}
style={{
width: CELL_PX,
height: CELL_PX,
backgroundColor: cellColor(cell.status, cell.timing),
}}
onPointerEnter={(e) => {
if (e.pointerType === "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: cellText(cell),
});
}}
onPointerLeave={(e) => {
if (e.pointerType === "touch") return;
setTooltip((prev) => (prev?.pinned ? prev : null));
}}
onPointerDown={(e) => {
if (e.pointerType !== "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: cellText(cell),
pinned: true,
});
}}
/>
);
})}
</div>
{tooltip && (
<div
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
>
{tooltip.text}
</div>
)}
</div>
<div className="text-muted-foreground flex items-center gap-3 text-xs">
<span>
{t("medications.lastNDoses", {
total: summary.total,
taken: summary.taken,
})}
</span>
{summary.doseStreak > 0 && (
<span className="text-dracula-orange flex items-center gap-1 font-medium">
<Flame className="h-3.5 w-3.5" />
{t("medications.doseStreak", { count: summary.doseStreak })}
</span>
)}
</div>
</div>
);
}
@@ -10,6 +10,10 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import {
DoseAdherenceTimeline,
type DoseAdherenceCell,
} from "@/components/medications/card-parts/dose-adherence-timeline";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
@@ -23,6 +27,8 @@ import {
nextInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import {
reduceCurrentWindowStatus,
toBerlinDate,
@@ -75,6 +81,12 @@ export interface Glp1Medication {
category: string;
treatmentClass?: string;
dosesPerUnit?: number | null;
/** v1.6.0 — route of administration (drives the injection-site prompt). */
deliveryForm?: string;
/** v1.8.5 — per-medication injection-site tracking opt-in. */
trackInjectionSites?: boolean;
/** v1.8.5 — per-medication allowed / preferred injection sites. */
allowedInjectionSites?: string[];
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
@@ -91,9 +103,24 @@ export interface Glp1Medication {
schedules: ScheduleLite[];
}
interface ComplianceDisplay {
mode: "percent" | "timeline";
expected7: number;
expected30: number;
minStableDoses: number;
doseTimeline: DoseAdherenceCell[];
recentDoseSummary: { taken: number; total: number; doseStreak: number };
}
interface ComplianceData {
compliance7: { rate: number; streak: number };
compliance30: { rate: number };
/**
* v1.8.5 — server-decided render mode. GLP-1 injections are commonly
* weekly, so this card flips to the per-dose timeline more often than
* the generic one. Additive — older mocks omit it.
*/
complianceDisplay?: ComplianceDisplay;
}
interface DetailsResponse {
@@ -172,6 +199,12 @@ export function Glp1MedicationCard({
const fmt = useFormatters();
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
// v1.8.5 — post-dose injection-site prompt (see medication-card.tsx).
const [siteIntakeId, setSiteIntakeId] = useState<string | null>(null);
const globalExcluded = useGlobalExcludedInjectionSites();
const tracksInjection =
medication.deliveryForm === "INJECTION" &&
medication.trackInjectionSites === true;
const { data: compliance } = useQuery({
queryKey: queryKeys.medicationCompliance(medication.id),
@@ -240,12 +273,39 @@ export function Glp1MedicationCard({
),
);
await invalidateKeys(queryClient, medicationDependentKeys);
// v1.8.5 — prompt (skippably) for the injection site on a taken
// dose when tracking is enabled.
if (!skipped && tracksInjection) {
try {
const json = await res.json();
const eventId = json?.data?.id as string | undefined;
if (eventId) setSiteIntakeId(eventId);
} catch {
/* dose recorded; the site prompt is best-effort */
}
}
}
} finally {
setIntakeLoading(null);
}
}
async function confirmInjectionSite(site: InjectionSiteKey) {
const intakeId = siteIntakeId;
setSiteIntakeId(null);
if (!intakeId) return;
const res = await fetch("/api/medications/intake", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ intakeId, status: "taken", injectionSite: site }),
});
if (res.ok) {
await invalidateKeys(queryClient, medicationDependentKeys);
} else {
toast.error(t("medications.logInjectionSiteNoneAvailable"));
}
}
const schedule = medication.schedules[0] ?? null;
const now = new Date();
const next = nextInjectionFromServer(medication.nextDueAt, now);
@@ -420,15 +480,24 @@ export function Glp1MedicationCard({
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
in the response shape; only the web mounts are gone. */}
{/* Compliance bars — shared with the generic card so the page
grid stays harmonious. */}
{medication.active && compliance && (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
/>
)}
{/* Compliance region — shared decision with the generic card.
Dense cadences keep the bars; sparse cadences (most weekly
GLP-1 injections) swap to the per-dose uptime strip. Falls back
to the bars when the older payload omits `complianceDisplay`. */}
{medication.active &&
compliance &&
(compliance.complianceDisplay?.mode === "timeline" ? (
<DoseAdherenceTimeline
doses={compliance.complianceDisplay.doseTimeline}
summary={compliance.complianceDisplay.recentDoseSummary}
/>
) : (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
/>
))}
{/* Primary actions row — shared with the generic medication
card. The GLP-1-specific side-effect quick-log lives in the
@@ -441,6 +510,25 @@ export function Glp1MedicationCard({
/>
)}
</CardContent>
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
{tracksInjection && (
<LogInjectionSiteDialog
open={siteIntakeId !== null}
medicationName={medication.name}
allowedInjectionSites={
(medication.allowedInjectionSites ?? []) as InjectionSiteKey[]
}
globalExcludedInjectionSites={globalExcluded}
history={
recentInjections
.map((r) => r.injectionSite)
.filter(Boolean) as InjectionSiteKey[]
}
onConfirm={confirmInjectionSite}
onSkip={() => setSiteIntakeId(null)}
/>
)}
</Card>
);
}
@@ -1,5 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "@/lib/i18n/context";
import {
INJECTION_SITE_KEYS,
@@ -32,6 +34,15 @@ interface InjectionSitePickerProps {
/** Recent rotation history (most recent first). Powers the
* "recommended next site" dashed-ring annotation. */
history?: ReadonlyArray<InjectionSiteKey>;
/**
* v1.8.5 — the effective allowed set (per-medication allowed sites
* minus the user's global exclusion). When supplied, sites outside it
* render disabled + non-interactive and the rotation recommendation is
* constrained to this set. `undefined` keeps the legacy all-sites
* picker (e.g. the wizard rotation preview, which has no medication
* context yet).
*/
allowed?: ReadonlyArray<InjectionSiteKey>;
/** Called when the user picks a site. */
onChange: (site: InjectionSiteKey) => void;
/** Optional class for the outer wrapper. */
@@ -41,11 +52,30 @@ interface InjectionSitePickerProps {
export function InjectionSitePicker({
value,
history = [],
allowed,
onChange,
className,
}: InjectionSitePickerProps) {
const { t } = useTranslations();
const recommended = nextInjectionSite(history);
const recommended = nextInjectionSite(history, 4, allowed);
const allowedSet = allowed ? new Set(allowed) : null;
const isAllowed = (site: InjectionSiteKey) =>
allowedSet === null || allowedSet.has(site);
// Track which site currently holds keyboard focus so we can paint a
// high-contrast focus halo on the filled dot itself — a transparent
// hit-target stroke alone is effectively invisible on the muted body
// map (WCAG 2.4.7).
const [focusedSite, setFocusedSite] = useState<InjectionSiteKey | null>(null);
// Accessible name: fold the rotation recommendation into the label so a
// screen-reader user gets the "recommended next site" cue that is
// otherwise conveyed only by the dashed ring.
const siteLabel = (site: InjectionSiteKey): string => {
const name = t(describeInjectionSite(site));
return site === recommended
? t("medications.injectionSiteRecommendedAriaLabel", { site: name })
: name;
};
return (
<div
@@ -138,9 +168,27 @@ export function InjectionSitePicker({
const coord = SITE_COORDS[site];
const isActive = site === value;
const isRecommended = site === recommended;
// v1.8.5 — a site outside the effective allowed set renders
// disabled (dimmed, non-interactive) so the user cannot pick a
// site the server would reject.
const disabled = !isAllowed(site);
const isFocused = site === focusedSite;
return (
<g key={site}>
{isRecommended && !isActive && (
{/* Keyboard focus halo — a contrasting ring on the filled dot
that only shows while this site holds focus. Painted under
the recommendation ring and the dot so both stay legible. */}
{isFocused && !disabled && (
<circle
cx={coord.x}
cy={coord.y}
r="10.5"
fill="none"
className="stroke-ring"
strokeWidth="2.5"
/>
)}
{isRecommended && !isActive && !disabled && (
<circle
cx={coord.x}
cy={coord.y}
@@ -156,9 +204,11 @@ export function InjectionSitePicker({
cy={coord.y}
r="6"
className={
isActive
? "fill-primary stroke-primary-foreground"
: "fill-muted stroke-foreground/60 hover:fill-accent"
disabled
? "fill-muted/30 stroke-foreground/20"
: isActive
? "fill-primary stroke-primary-foreground"
: "fill-muted stroke-foreground/60 hover:fill-accent"
}
strokeWidth="1.5"
/>
@@ -175,17 +225,35 @@ export function InjectionSitePicker({
r="12"
fill="transparent"
role="button"
tabIndex={0}
tabIndex={disabled ? -1 : 0}
aria-pressed={isActive}
aria-label={t(describeInjectionSite(site))}
onClick={() => onChange(site)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onChange(site);
}
}}
className="cursor-pointer focus:outline-none focus-visible:stroke-current focus-visible:stroke-2"
aria-disabled={disabled}
aria-label={siteLabel(site)}
onClick={disabled ? undefined : () => onChange(site)}
onFocus={disabled ? undefined : () => setFocusedSite(site)}
onBlur={
disabled
? undefined
: () =>
setFocusedSite((current) =>
current === site ? null : current,
)
}
onKeyDown={
disabled
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onChange(site);
}
}
}
className={
disabled
? "cursor-not-allowed focus:outline-none"
: "cursor-pointer focus:outline-none"
}
/>
</g>
);
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
import {
effectiveAllowedSites,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.8.5 — post-dose injection-site capture.
*
* Shown after a taken dose for a medication with
* `deliveryForm === "INJECTION"` and `trackInjectionSites === true`. The
* capture is OPTIONAL and always skippable — closing or "skip" records
* no site, the dose stays logged regardless. The picker is constrained
* to the medication's effective allowed set (per-medication allowed sites
* minus the user's global exclusion), and the rotation suggestion runs
* within that set.
*/
interface LogInjectionSiteDialogProps {
open: boolean;
medicationName: string;
/** Per-medication allowed / preferred sites ([] = no restriction). */
allowedInjectionSites: ReadonlyArray<InjectionSiteKey>;
/** User-level global exclusion deny-list. */
globalExcludedInjectionSites: ReadonlyArray<InjectionSiteKey>;
/** Recent rotation history (most recent first) for the dashed-ring hint. */
history: ReadonlyArray<InjectionSiteKey>;
/** Submit the chosen site (the parent PATCHes it onto the intake). */
onConfirm: (site: InjectionSiteKey) => void;
/** Dismiss without recording a site (the dose stays taken). */
onSkip: () => void;
}
export function LogInjectionSiteDialog({
open,
medicationName,
allowedInjectionSites,
globalExcludedInjectionSites,
history,
onConfirm,
onSkip,
}: LogInjectionSiteDialogProps) {
const { t } = useTranslations();
const [selected, setSelected] = useState<InjectionSiteKey | null>(null);
const allowed = effectiveAllowedSites(
allowedInjectionSites,
globalExcludedInjectionSites,
);
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) onSkip();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.logInjectionSiteTitle")}</DialogTitle>
<DialogDescription>
{t("medications.logInjectionSiteDescription", {
name: medicationName,
})}
</DialogDescription>
</DialogHeader>
{allowed.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("medications.logInjectionSiteNoneAvailable")}
</p>
) : (
<InjectionSitePicker
value={selected}
history={history}
allowed={allowed}
onChange={setSelected}
/>
)}
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={onSkip}>
{t("medications.logInjectionSiteSkip")}
</Button>
<Button
disabled={selected === null}
onClick={() => {
if (selected !== null) onConfirm(selected);
}}
>
{t("medications.logInjectionSiteConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+97 -8
View File
@@ -9,6 +9,10 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import {
DoseAdherenceTimeline,
type DoseAdherenceCell,
} from "@/components/medications/card-parts/dose-adherence-timeline";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { formatDateTime, formatTime } from "@/lib/format";
@@ -23,6 +27,9 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
interface Schedule {
id: string;
@@ -46,6 +53,12 @@ interface Medication {
*/
treatmentClass?: string;
dosesPerUnit?: number | null;
/** v1.6.0 — route of administration (drives the injection-site prompt). */
deliveryForm?: string;
/** v1.8.5 — per-medication injection-site tracking opt-in. */
trackInjectionSites?: boolean;
/** v1.8.5 — per-medication allowed / preferred injection sites. */
allowedInjectionSites?: string[];
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
@@ -62,6 +75,15 @@ interface Medication {
schedules: Schedule[];
}
interface ComplianceDisplay {
mode: "percent" | "timeline";
expected7: number;
expected30: number;
minStableDoses: number;
doseTimeline: DoseAdherenceCell[];
recentDoseSummary: { taken: number; total: number; doseStreak: number };
}
interface ComplianceData {
compliance7: {
totalExpected: number;
@@ -74,6 +96,12 @@ interface ComplianceData {
compliance30: {
rate: number;
};
/**
* v1.8.5 — server-decided render mode for the compliance region. Dense
* cadences keep the 7-/30-day bars (`"percent"`); sparse cadences swap to
* the per-dose uptime strip (`"timeline"`). Additive — older mocks omit it.
*/
complianceDisplay?: ComplianceDisplay;
}
interface MedicationCardProps {
@@ -103,6 +131,14 @@ export function MedicationCard({
const { t, locale } = useTranslations();
const fmt = useFormatters();
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
// v1.8.5 — post-dose injection-site prompt state. Holds the intake
// event id returned by the take POST so the confirm handler can PATCH
// the chosen site onto it. Null = dialog closed.
const [siteIntakeId, setSiteIntakeId] = useState<string | null>(null);
const globalExcluded = useGlobalExcludedInjectionSites();
const tracksInjection =
medication.deliveryForm === "INJECTION" &&
medication.trackInjectionSites === true;
const { data: compliance } = useQuery({
queryKey: queryKeys.medicationCompliance(medication.id),
@@ -152,12 +188,40 @@ export function MedicationCard({
),
);
await invalidateKeys(queryClient, medicationDependentKeys);
// v1.8.5 — after a TAKEN dose on a tracking-enabled injection,
// prompt (skippably) for the site. The dialog PATCHes it onto
// the just-created event via the status-toggle route.
if (!skipped && tracksInjection) {
try {
const json = await res.json();
const eventId = json?.data?.id as string | undefined;
if (eventId) setSiteIntakeId(eventId);
} catch {
/* dose recorded; the site prompt is best-effort */
}
}
}
} finally {
setIntakeLoading(null);
}
}
async function confirmInjectionSite(site: InjectionSiteKey) {
const intakeId = siteIntakeId;
setSiteIntakeId(null);
if (!intakeId) return;
const res = await fetch("/api/medications/intake", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ intakeId, status: "taken", injectionSite: site }),
});
if (res.ok) {
await invalidateKeys(queryClient, medicationDependentKeys);
} else {
toast.error(t("medications.logInjectionSiteNoneAvailable"));
}
}
const rate7 = compliance?.compliance7?.rate ?? 0;
const rate30 = compliance?.compliance30?.rate ?? 0;
const streak = compliance?.compliance7?.streak ?? 0;
@@ -327,14 +391,24 @@ export function MedicationCard({
</p>
)}
{/* Compliance bar */}
{medication.active && compliance && (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
/>
)}
{/* Compliance region — the server decides whether the percentage
bars stay (dense cadences) or the per-dose uptime strip takes
over (sparse cadences). Falls back to the bars when the older
payload omits `complianceDisplay`. */}
{medication.active &&
compliance &&
(compliance.complianceDisplay?.mode === "timeline" ? (
<DoseAdherenceTimeline
doses={compliance.complianceDisplay.doseTimeline}
summary={compliance.complianceDisplay.recentDoseSummary}
/>
) : (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
/>
))}
{/* Quick actions — primary buttons of the medication card. */}
{medication.active && (
@@ -344,6 +418,21 @@ export function MedicationCard({
/>
)}
</CardContent>
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
{tracksInjection && (
<LogInjectionSiteDialog
open={siteIntakeId !== null}
medicationName={medication.name}
allowedInjectionSites={
(medication.allowedInjectionSites ?? []) as InjectionSiteKey[]
}
globalExcludedInjectionSites={globalExcluded}
history={[]}
onConfirm={confirmInjectionSite}
onSkip={() => setSiteIntakeId(null)}
/>
)}
</Card>
);
}
@@ -46,6 +46,13 @@ export interface TimesOfDayChipsProps {
disabled?: boolean;
/** Translation namespace prefix. */
i18nPrefix?: string;
/**
* Render the built-in Morning / Noon / Evening / Night preset row.
* Default `true`. The wizard's Step 7 supplies its own icon-based
* preset row, so it passes `false` to avoid showing each suggested
* time twice (the icon chip from Step 7 and the labelled chip here).
*/
showPresets?: boolean;
}
/** Preset table — wall-clock HH:mm and i18n suffix key. */
@@ -106,6 +113,7 @@ export function TimesOfDayChips({
maxChips = 8,
disabled = false,
i18nPrefix = "medications.scheduling.timesOfDay",
showPresets = true,
}: TimesOfDayChipsProps) {
const { t } = useTranslations();
const inputId = useId();
@@ -165,38 +173,40 @@ export function TimesOfDayChips({
aria-label={t(`${i18nPrefix}.label`)}
>
{/* Preset row */}
<div
role="group"
aria-label={t(`${i18nPrefix}.label`)}
className="flex flex-wrap gap-1.5"
data-slot="times-of-day-presets"
>
{PRESETS.map(({ key, value: preset }) => {
const isOn = sortedValue.includes(preset);
const label = `${t(`${i18nPrefix}.presets.${key}`)} ${preset}`;
return (
<button
key={key}
type="button"
disabled={disabled || (!isOn && atCap)}
aria-pressed={isOn}
aria-label={label}
data-slot="times-of-day-preset"
data-preset={key}
data-active={isOn ? "true" : "false"}
onClick={() => onPresetToggle(preset)}
className={[
"focus-visible:ring-ring inline-flex h-11 items-center gap-1 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
].join(" ")}
>
{label}
</button>
);
})}
</div>
{showPresets && (
<div
role="group"
aria-label={t(`${i18nPrefix}.label`)}
className="flex flex-wrap gap-1.5"
data-slot="times-of-day-presets"
>
{PRESETS.map(({ key, value: preset }) => {
const isOn = sortedValue.includes(preset);
const label = `${t(`${i18nPrefix}.presets.${key}`)} ${preset}`;
return (
<button
key={key}
type="button"
disabled={disabled || (!isOn && atCap)}
aria-pressed={isOn}
aria-label={label}
data-slot="times-of-day-preset"
data-preset={key}
data-active={isOn ? "true" : "false"}
onClick={() => onPresetToggle(preset)}
className={[
"focus-visible:ring-ring inline-flex h-11 items-center gap-1 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
].join(" ")}
>
{label}
</button>
);
})}
</div>
)}
{/* Chip list */}
{sortedValue.length === 0 ? (
@@ -225,7 +235,7 @@ export function TimesOfDayChips({
disabled={disabled}
aria-label={`${t(`${i18nPrefix}.remove`)} ${time}`}
onClick={() => onRemove(time)}
className="focus-visible:ring-ring inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-background focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50"
className="focus-visible:ring-ring hover:bg-background inline-flex h-7 w-7 items-center justify-center rounded-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
data-slot="times-of-day-chip-remove"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
@@ -143,4 +143,22 @@ describe("<TimesOfDayChips> — SSR render", () => {
expect(html).not.toContain('data-slot="times-of-day-presets"');
expect(html).toMatch(/value="09:30"/);
});
it("suppresses the built-in preset row when showPresets is false", () => {
// The wizard's Step 7 supplies its own icon-based preset row; passing
// showPresets={false} keeps each suggested time from rendering twice.
const html = render(
<TimesOfDayChips value={["08:00"]} showPresets={false} onChange={NOOP} />,
);
expect(html).not.toContain('data-slot="times-of-day-presets"');
// The chip list (the actual selected values) still renders.
expect(html).toContain('data-slot="times-of-day-list"');
});
it("renders the preset row by default (showPresets defaults true)", () => {
const html = render(
<TimesOfDayChips value={["08:00"]} onChange={NOOP} />,
);
expect(html).toContain('data-slot="times-of-day-presets"');
});
});
@@ -47,7 +47,11 @@ import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
const PAUSE_TITLE_ID = "medication-detail-pause-title";
@@ -283,15 +287,29 @@ export function DangerZoneBody({
});
if (!res.ok) {
toast.error(t("medications.detail.zone.delete.failed"));
setTier3bBusy(false);
return;
}
// Cache cascade first so the list page paints without the
// medication, then route. The bundle includes
// `compliance-chart-inline` so the inline chart's stale cache
// evicts before the user lands on the list.
await invalidateKeys(queryClient, medicationDependentKeys);
// Drop the deleted medication's own detail query before the
// prefix-invalidate below. `medicationDependentKeys` carries
// `["medications"]`, which covers the active `["medications", id]`
// detail query on this page; awaiting that invalidation refetched
// a now-404 resource, retried, and hung the spinner forever (the
// delete had already persisted server-side). `removeQueries`
// evicts the dead key so the prefix-invalidate can't refetch it.
queryClient.removeQueries({
queryKey: queryKeys.medicationDetail(medicationId),
});
// Navigate away first — we're leaving the detail page, so the
// dependent-bundle invalidation can settle in the background while
// the list page mounts and re-reads `["medications"]` fresh.
toast.success(t("medications.detail.zone.delete.toast"));
setDeleteDialogOpen(false);
router.push("/medications");
// Fire-and-forget the dependent-bundle invalidation (list /
// analytics / compliance / inline chart). Not awaited: navigation
// already happened and a transient refetch must not block it.
void invalidateKeys(queryClient, medicationDependentKeys);
} catch {
toast.error(t("medications.detail.zone.delete.failed"));
setTier3bBusy(false);
@@ -13,8 +13,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import {
INJECTION_SITE_KEYS,
describeInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
import type { MedicationDeliveryForm } from "@/lib/validations/medication";
import { useTranslations } from "@/lib/i18n/context";
@@ -48,14 +53,24 @@ const DELIVERY_FORM_KEYS: readonly MedicationDeliveryForm[] = [
export function Step3Dose({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
// The injection-site rotation map is a visual aide on the editor: the
// chosen site is persisted per intake-event (no medication-level
// column), so the picker selection here is a local preview the user
// collapses when not needed.
const [siteOpen, setSiteOpen] = useState(false);
const [previewSite, setPreviewSite] = useState<InjectionSiteKey | null>(null);
// v1.8.5 — injection-site tracking is a per-medication opt-in surfaced
// only for an INJECTION delivery form. When enabled, the user can
// restrict the allowed sites; an empty selection means "no
// restriction" (every site offered, minus the global exclusion).
const [allowedOpen, setAllowedOpen] = useState(false);
const isInjection = payload.deliveryForm === "INJECTION";
function toggleAllowedSite(site: InjectionSiteKey, checked: boolean) {
const current = new Set(payload.allowedInjectionSites);
if (checked) current.add(site);
else current.delete(site);
applyPartial({
allowedInjectionSites: INJECTION_SITE_KEYS.filter((s) =>
current.has(s),
),
});
}
return (
<div className="space-y-6" data-slot="wizard-step3">
<div className="grid grid-cols-2 gap-3">
@@ -151,40 +166,83 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
</div>
</div>
{/* v1.6.0 — collapsible injection-site rotation map. Shown only for
INJECTION delivery; the per-dose site is logged at intake time
so this surface is an optional rotation aide, not a persisted
medication field. */}
{/* v1.8.5 — injection-site tracking opt-in + allowed-sites editor.
Shown only for an INJECTION delivery form. Default off; enabling
it surfaces the post-dose site prompt and the rotation
suggestion. The allowed-sites checklist restricts the picker
(empty = no restriction). */}
{isInjection && (
<div
className="border-border/60 rounded-lg border"
className="border-border/60 space-y-3 rounded-lg border p-3"
data-slot="wizard-injection-site"
>
<Button
type="button"
variant="ghost"
className="h-11 w-full justify-between px-3"
aria-expanded={siteOpen}
onClick={() => setSiteOpen((prev) => !prev)}
>
<span className="text-sm font-medium">
{t("medications.wizard.steps.step3.injectionSiteToggle")}
<label className="flex items-start justify-between gap-3">
<span className="space-y-0.5">
<span className="block text-sm font-medium">
{t("medications.trackInjectionSitesToggle")}
</span>
<span className="text-muted-foreground block text-xs">
{t("medications.trackInjectionSitesHint")}
</span>
</span>
{siteOpen ? (
<ChevronUp aria-hidden="true" className="h-4 w-4" />
) : (
<ChevronDown aria-hidden="true" className="h-4 w-4" />
)}
</Button>
{siteOpen && (
<div className="space-y-2 px-3 pb-4">
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step3.injectionSiteHint")}
</p>
<InjectionSitePicker
value={previewSite}
onChange={setPreviewSite}
/>
<Switch
checked={payload.trackInjectionSites}
onCheckedChange={(checked) =>
applyPartial({
trackInjectionSites: checked,
// Clear the per-med restriction when tracking is turned off.
...(checked ? {} : { allowedInjectionSites: [] }),
})
}
aria-label={t("medications.trackInjectionSitesToggle")}
/>
</label>
{payload.trackInjectionSites && (
<div className="space-y-2">
<Button
type="button"
variant="ghost"
className="h-9 w-full justify-between px-2"
aria-expanded={allowedOpen}
onClick={() => setAllowedOpen((prev) => !prev)}
>
<span className="text-sm font-medium">
{t("medications.allowedSitesLabel")}
</span>
{allowedOpen ? (
<ChevronUp aria-hidden="true" className="h-4 w-4" />
) : (
<ChevronDown aria-hidden="true" className="h-4 w-4" />
)}
</Button>
{allowedOpen && (
<div className="space-y-2 px-1">
<p className="text-muted-foreground text-xs">
{t("medications.allowedSitesHint")}
</p>
<div className="grid grid-cols-2 gap-2">
{INJECTION_SITE_KEYS.map((site) => {
const checked =
payload.allowedInjectionSites.includes(site);
return (
<label
key={site}
className="flex items-center gap-2 text-sm"
>
<Checkbox
checked={checked}
onCheckedChange={(c) =>
toggleAllowedSite(site, c === true)
}
/>
{t(describeInjectionSite(site))}
</label>
);
})}
</div>
</div>
)}
</div>
)}
</div>
@@ -77,6 +77,10 @@ export function Step7Times({ payload, applyPartial }: StepProps) {
value={payload.timesOfDay}
onChange={(timesOfDay) => applyPartial({ timesOfDay })}
maxChips={8}
// The icon-based preset row above already offers the four
// suggested times; suppress the chips' own labelled preset row so
// each suggestion isn't shown twice.
showPresets={false}
/>
</div>
);
@@ -63,6 +63,7 @@ import {
type MedicationCategoryValue,
type MedicationDeliveryForm,
} from "@/lib/validations/medication";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
// ────────────────────────────────────────────────────────────────────
// Step 2 — treatment-class taxonomy
@@ -207,6 +208,18 @@ export interface WizardPayload {
* inventory tracking off (maps to `undefined` / NULL on the wire).
*/
dosesPerUnit: string;
/**
* v1.8.5 — per-medication injection-site tracking opt-in. Only
* surfaced in Step 3 when `deliveryForm === "INJECTION"`. Default
* false (opt-in). Toggling it off clears `allowedInjectionSites`.
*/
trackInjectionSites: boolean;
/**
* v1.8.5 — per-medication allowed / preferred injection sites. Empty =
* no restriction (every site offered). Only meaningful when
* `trackInjectionSites` is true.
*/
allowedInjectionSites: InjectionSiteKey[];
/** Set in Step 5 — mirrors `schedules[activeScheduleIndex].mode`. */
mode: "oneShot" | "recurring" | null;
/** Mirrors `schedules[activeScheduleIndex].cadence`. */
@@ -253,6 +266,8 @@ export function emptyWizardPayload(): WizardPayload {
treatmentRow: null,
deliveryForm: "ORAL",
dosesPerUnit: "",
trackInjectionSites: false,
allowedInjectionSites: [],
mode: draft.mode,
cadence: draft.cadence,
subControls: draft.subControls,
@@ -463,6 +478,10 @@ export interface CreateMedicationBody {
deliveryForm: MedicationDeliveryForm;
/** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */
dosesPerUnit?: number;
/** v1.8.5 — injection-site tracking opt-in. Omitted for non-injections. */
trackInjectionSites?: boolean;
/** v1.8.5 — per-medication allowed sites. Omitted when tracking is off. */
allowedInjectionSites?: InjectionSiteKey[];
/**
* v1.5.5 D-3 §10 invariant 16 — the wizard owns the create-time
* default for `notificationsEnabled` but never overwrites it on
@@ -602,6 +621,16 @@ export function buildCreateBody(
parsedDosesPerUnit >= 1 && {
dosesPerUnit: parsedDosesPerUnit,
}),
// v1.8.5 — injection-site tracking is only meaningful for an
// INJECTION delivery form. Always send the boolean for an injection
// (so edits can deactivate it); send the allowed list only when
// tracking is on. Non-injections omit both (server keeps defaults).
...(committed.deliveryForm === "INJECTION" && {
trackInjectionSites: committed.trackInjectionSites,
...(committed.trackInjectionSites && {
allowedInjectionSites: committed.allowedInjectionSites,
}),
}),
// v1.5.5 D-3 §10 invariant 16 — `notificationsEnabled` only on
// create. Edits never round-trip the wizard's default because the
// detail page's notifications switch is the single source of
@@ -807,6 +836,10 @@ export interface MedicationPayload {
deliveryForm?: string;
/** v1.6.0 — doses per pen / vial. NULL = inventory tracking off. */
dosesPerUnit?: number | null;
/** v1.8.5 — injection-site tracking opt-in. */
trackInjectionSites?: boolean;
/** v1.8.5 — per-medication allowed / preferred injection sites. */
allowedInjectionSites?: string[];
notificationsEnabled: boolean;
startsOn: Date | null;
endsOn: Date | null;
@@ -906,6 +939,9 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
? String(initial.dosesPerUnit)
: "",
trackInjectionSites: initial.trackInjectionSites ?? false,
allowedInjectionSites: (initial.allowedInjectionSites ??
[]) as InjectionSiteKey[],
mode: first.mode,
cadence: first.cadence,
subControls: first.subControls,
+51 -1
View File
@@ -11,6 +11,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { DateTimeInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
@@ -18,6 +19,7 @@ import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
import { MoodTagPicker } from "./mood-tag-picker";
const MOOD_LEVELS = [
{ value: "SUPER_GUT", score: 5, labelKey: "mood.levelSuperGut" },
@@ -77,10 +79,22 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
onSelect: (index) => setMood(MOOD_LEVELS[index]!.value),
});
const [tagsInput, setTagsInput] = useState("");
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
const [tagKeys, setTagKeys] = useState<string[]>([]);
// v1.8.5 (C1) — first-class free-text note. The model + API already
// accepted `note`; the web form was the only surface that couldn't
// write it.
const [note, setNote] = useState("");
const [moodLoggedAt, setMoodLoggedAt] = useState(getDefaultMoodLoggedAtValue);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function toggleTagKey(key: string) {
setTagKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
}
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
@@ -93,6 +107,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
function resetForm() {
setMood("");
setTagsInput("");
setTagKeys([]);
setNote("");
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
setError(null);
}
@@ -110,12 +126,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
.map((t) => t.trim())
.filter(Boolean);
const trimmedNote = note.trim();
const res = await fetch("/api/mood-entries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mood,
tags: tags.length > 0 ? tags : undefined,
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
note: trimmedNote.length > 0 ? trimmedNote : undefined,
moodLoggedAt: timestamp,
}),
});
@@ -293,7 +313,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
setTagsInput(next);
}
}}
className={`min-h-8 rounded-full border px-2.5 py-1 text-xs transition-colors ${
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
@@ -308,6 +328,36 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</div>
</div>
{/* v1.8.5 — structured-tag taxonomy picker. Additive next to the
free-text input above; an entry can carry both axes. */}
<div className="space-y-2">
<Label>
{t("mood.tagPicker")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
<MoodTagPicker selected={tagKeys} onToggle={toggleTagKey} />
</div>
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<Label htmlFor="mood-note">
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
<Textarea
id="mood-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={500}
rows={3}
/>
</div>
{error && (
<div
id={errorId}
+60 -1
View File
@@ -31,6 +31,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { DateTimeInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import {
@@ -64,6 +65,7 @@ import {
} from "@/lib/mood/labels";
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { MoodTagPicker } from "./mood-tag-picker";
// Re-export the score map under the legacy local name to keep the
// rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single
@@ -76,6 +78,9 @@ interface MoodEntry {
mood: string;
score: number;
tags: string[];
// v1.8.5 — structured-tag keys + free-text note.
tagKeys: string[];
note: string | null;
source: string;
moodLoggedAt: string;
}
@@ -127,8 +132,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
onSelect: (index) => setEditMood(MOOD_LEVELS_LIST[index]!),
});
const [editTagsInput, setEditTagsInput] = useState("");
const [editTagKeys, setEditTagKeys] = useState<string[]>([]);
const [editNote, setEditNote] = useState("");
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
const [editError, setEditError] = useState<string | null>(null);
function toggleEditTagKey(key: string) {
setEditTagKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
}
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
// v1.4.27 R4 RC2 — Sheet-branch sticky-pinned footer slot.
const editFormId = useId();
@@ -194,17 +207,21 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
id,
mood,
tags,
tagKeys,
note,
moodLoggedAt,
}: {
id: string;
mood: string;
tags: string[] | null;
tagKeys: string[];
note: string | null;
moodLoggedAt: string;
}) => {
const res = await fetch(`/api/mood-entries/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mood, tags, moodLoggedAt }),
body: JSON.stringify({ mood, tags, tagKeys, note, moodLoggedAt }),
});
const json = await res.json();
@@ -228,6 +245,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
setEditing(entry);
setEditMood(entry.mood);
setEditTagsInput(entry.tags.join(", "));
setEditTagKeys(entry.tagKeys ?? []);
setEditNote(entry.note ?? "");
setEditMoodLoggedAt(toDateTimeLocalValue(entry.moodLoggedAt));
setEditError(null);
}
@@ -266,11 +285,15 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
.map((t) => t.trim())
.filter(Boolean);
const trimmedNote = editNote.trim();
setEditError(null);
updateMutation.mutate({
id: editing.id,
mood: editMood,
tags: tags.length > 0 ? tags : null,
tagKeys: editTagKeys,
note: trimmedNote.length > 0 ? trimmedNote : null,
moodLoggedAt: measuredDate.toISOString(),
});
}
@@ -395,6 +418,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{entry.tags.length > 0 ? entry.tags.join(", ") : "-"}
{entry.note && (
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 text-xs italic">
{entry.note}
</p>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDateTime(entry.moodLoggedAt)}
@@ -467,6 +495,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
{entry.tags.join(", ")}
</p>
)}
{entry.note && (
<p className="text-muted-foreground/80 truncate text-xs italic">
{entry.note}
</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -603,6 +636,32 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
/>
</div>
{/* v1.8.5 — structured-tag taxonomy picker. */}
<div className="space-y-2">
<Label>
{t("mood.tagPicker")} ({t("common.optional")})
</Label>
<MoodTagPicker
selected={editTagKeys}
onToggle={toggleEditTagKey}
/>
</div>
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<Label htmlFor="edit-mood-note">
{t("mood.note")} ({t("common.optional")})
</Label>
<Textarea
id="edit-mood-note"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={500}
rows={3}
/>
</div>
{editError && (
<div
role="alert"
+83
View File
@@ -0,0 +1,83 @@
import {
AlertTriangle,
Apple,
BedDouble,
Brain,
Briefcase,
CheckCircle,
Clock,
CloudMoon,
CloudSun,
Dumbbell,
Flame,
Footprints,
Frown,
GlassWater,
HandHeart,
Heart,
HeartPulse,
HelpCircle,
Home,
LogOut,
Meh,
Moon,
MoonStar,
PartyPopper,
Plane,
Smile,
Tag,
Thermometer,
ThumbsUp,
User,
Users,
Wine,
Zap,
type LucideIcon,
} from "lucide-react";
/**
* v1.8.5 — icon-name → Lucide component map for the structured mood-tag
* taxonomy. The catalog stores Lucide icon names as strings (so the
* server stays icon-library-agnostic); this map resolves them on the
* client. Mirrors the static-map convention used by the workout / sport
* icon lookups. `Tag` is the fallback for an unmapped name.
*/
const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
AlertTriangle,
Apple,
BedDouble,
Brain,
Briefcase,
CheckCircle,
Clock,
CloudMoon,
CloudSun,
Dumbbell,
Flame,
Footprints,
Frown,
GlassWater,
HandHeart,
Heart,
HeartPulse,
HelpCircle,
Home,
LogOut,
Meh,
Moon,
MoonStar,
PartyPopper,
Plane,
Smile,
Thermometer,
ThumbsUp,
User,
Users,
Wine,
Zap,
};
export function moodTagIcon(name: string | null | undefined): LucideIcon {
if (!name) return Tag;
return MOOD_TAG_ICONS[name] ?? Tag;
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "./mood-tag-icons";
/**
* v1.8.5 — structured mood-tag capture surface.
*
* Loads the global Category -> Tag catalog from `/api/mood/tags` and
* renders one labelled group per category with icon chips the user can
* toggle. Selection is a controlled set of tag keys lifted to the
* parent form, which sends them as `tagKeys` on create / update.
*
* Additive next to the legacy free-text tag input: a user can use
* either or both. Renders nothing if the catalog is empty so a
* deployment that cleared the taxonomy degrades to the free-text field.
*/
interface CatalogTag {
key: string;
labelKey: string;
icon: string | null;
}
interface CatalogCategory {
key: string;
labelKey: string;
icon: string | null;
tags: CatalogTag[];
}
interface CatalogResponse {
categories: CatalogCategory[];
}
export function MoodTagPicker({
selected,
onToggle,
}: {
selected: string[];
onToggle: (tagKey: string) => void;
}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: queryKeys.moodTagCatalog(),
queryFn: async () => {
const res = await fetch("/api/mood/tags");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as CatalogResponse;
},
enabled: isAuthenticated,
// The catalog only changes on a migration / admin edit.
staleTime: 60 * 60 * 1000,
});
if (isLoading) {
return <Skeleton className="h-24 w-full rounded-lg" />;
}
if (!data || data.categories.length === 0) {
return null;
}
const selectedSet = new Set(selected);
return (
<div className="space-y-3" data-slot="mood-tag-picker">
{data.categories.map((category) => {
const CategoryIcon = moodTagIcon(category.icon);
return (
<div key={category.key} className="space-y-1.5">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<CategoryIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t(category.labelKey)}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{category.tags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
const isActive = selectedSet.has(tag.key);
const label = t(tag.labelKey);
return (
<button
key={tag.key}
type="button"
onClick={() => onToggle(tag.key)}
aria-pressed={isActive}
className={`inline-flex min-h-11 items-center gap-1.5 rounded-full border px-3 py-2 text-xs transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
<TagIcon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}
@@ -2,6 +2,7 @@
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
import { UnitPreferenceCard } from "@/components/settings/unit-preference-card";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -28,6 +29,8 @@ export function DashboardSection() {
<UnitPreferenceCard isAuthenticated={isAuthenticated} />
<InjectionSitesCard isAuthenticated={isAuthenticated} />
<DashboardLayoutSection id="dashboard-layout" />
</section>
);
@@ -0,0 +1,91 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Syringe } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Checkbox } from "@/components/ui/checkbox";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
INJECTION_SITE_KEYS,
describeInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
/**
* v1.8.5 — Settings → global injection-site exclusion.
*
* A per-user deny-list. Sites checked here are never offered for any
* medication's injection-site picker and are rejected (422) at intake —
* even when a medication lists them as preferred (deny always wins).
* Reads + PATCHes `GET/PATCH /api/auth/me/injection-site-prefs`; the
* mutation invalidates `injectionSitePrefs()` so every picker re-reads.
*/
export function InjectionSitesCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: queryKeys.injectionSitePrefs(),
queryFn: async () => {
const res = await fetch("/api/auth/me/injection-site-prefs");
if (!res.ok) return [] as InjectionSiteKey[];
const json = await res.json();
return (json.data?.globalExcludedInjectionSites ??
[]) as InjectionSiteKey[];
},
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});
const excluded: InjectionSiteKey[] = data ?? [];
const mutation = useMutation({
mutationFn: async (next: InjectionSiteKey[]) => {
const res = await fetch("/api/auth/me/injection-site-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ globalExcludedInjectionSites: next }),
});
if (!res.ok) throw new Error("patch_failed");
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: queryKeys.injectionSitePrefs(),
});
},
});
function toggle(site: InjectionSiteKey, checked: boolean) {
const set = new Set(excluded);
if (checked) set.add(site);
else set.delete(site);
mutation.mutate(INJECTION_SITE_KEYS.filter((s) => set.has(s)));
}
return (
<div className="rounded-lg border p-4">
<SettingsCardHeader
icon={Syringe}
title={t("settings.globalExcludedInjectionSitesLabel")}
description={t("settings.globalExcludedInjectionSitesHint")}
/>
<div className="mt-3 grid grid-cols-2 gap-2">
{INJECTION_SITE_KEYS.map((site) => (
<label key={site} className="flex items-center gap-2 text-sm">
<Checkbox
checked={excluded.includes(site)}
disabled={mutation.isPending}
onCheckedChange={(c) => toggle(site, c === true)}
/>
{t(describeInjectionSite(site))}
</label>
))}
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
TargetStatusPill,
statusGroupForCategory,
} from "../target-status-pill";
/**
* v1.8.5 W5 — `<TargetStatusPill>` was extracted from `target-card.tsx`
* so the Targets card and the Insights reference panel render the
* identical pill from the same server-emitted classification category.
* These tests pin the category → semantic-group mapping (which drives
* the pill colour) and the rendered label + range/source tooltip.
*/
function render(props: Parameters<typeof TargetStatusPill>[0]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TargetStatusPill {...props} />
</I18nProvider>,
);
}
describe("statusGroupForCategory", () => {
it("paints in-band categories green", () => {
expect(statusGroupForCategory("Normal")).toBe("in");
expect(statusGroupForCategory("Optimal")).toBe("in");
expect(statusGroupForCategory("On target")).toBe("in");
});
it("paints far-out categories red", () => {
expect(statusGroupForCategory("Hypertension Grade 3")).toBe("out");
expect(statusGroupForCategory("Significantly elevated")).toBe("out");
expect(statusGroupForCategory("Tachycardia")).toBe("out");
});
it("falls back to amber for caution + unmapped categories", () => {
expect(statusGroupForCategory("Slightly elevated")).toBe("near");
expect(statusGroupForCategory("Overweight")).toBe("near");
expect(statusGroupForCategory("Totally Unknown Category")).toBe("near");
});
});
describe("<TargetStatusPill>", () => {
it("renders the translated label + the in/out status attribute", () => {
const html = render({
classification: { category: "Optimal", color: "#50fa7b" },
range: { min: 120, max: 129 },
unit: "mmHg",
source: "ESH 2023",
});
expect(html).toContain('data-slot="target-status-pill"');
expect(html).toContain('data-status="in"');
expect(html).toContain("Optimal");
// The tooltip trigger is the pill itself; the range/source content
// lives behind a closed Radix tooltip and is not in static markup.
expect(html).toContain('data-state="closed"');
});
it("renders the raw category when no translation key matches", () => {
const html = render({
classification: { category: "Totally Unknown Category", color: "#fff" },
range: null,
unit: "kg",
source: "Custom",
});
expect(html).toContain("Totally Unknown Category");
expect(html).toContain('data-status="near"');
});
});
+12 -154
View File
@@ -32,6 +32,11 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { moodStabilityLabel } from "@/lib/targets/mood-stability-label";
import { ConsistencyStrip } from "./consistency-strip";
import { RangeBar } from "./range-bar";
import {
TargetStatusPill,
statusGroupForCategory,
translateStatus,
} from "./target-status-pill";
import { TargetCoachButton } from "./target-coach-button";
import { TargetEditSheet } from "./target-edit-sheet";
import { buildTargetPrompt } from "@/lib/ai/coach/target-prompts";
@@ -96,128 +101,6 @@ const TYPE_COLORS: Record<string, string> = {
BLOOD_GLUCOSE_BEDTIME: "text-dracula-red",
};
const STATUS_CATEGORY_KEY: Record<string, string> = {
Underweight: "underweight",
Normal: "normal",
Overweight: "overweight",
"Obesity Grade I": "obesityGrade1",
"Obesity Grade II": "obesityGrade2",
"Obesity Grade III": "obesityGrade3",
Optimal: "optimal",
"High-normal": "highNormal",
"Hypertension Grade 1": "hypertensionGrade1",
"Hypertension Grade 2": "hypertensionGrade2",
"Hypertension Grade 3": "hypertensionGrade3",
Bradycardia: "bradycardia",
Elevated: "elevated",
Tachycardia: "tachycardia",
"Significantly low": "significantlyLow",
"Slightly low": "slightlyLow",
"On target": "onTarget",
"Slightly elevated": "slightlyElevated",
"Significantly elevated": "significantlyElevated",
"Far too short": "farTooShort",
"Too short": "tooShort",
"Slightly long": "slightlyLong",
"Far too long": "farTooLong",
"Below essential": "belowEssential",
Essential: "essential",
Athletic: "athletic",
Fitness: "fitness",
Acceptable: "acceptable",
Obese: "obese",
"Very low": "veryLow",
"Low active": "lowActive",
"Moderately active": "moderatelyActive",
Active: "active",
"Very active": "veryActive",
Good: "good",
Moderate: "moderate",
Low: "low",
High: "high",
"Very good": "veryGood",
"Very stable": "veryStable",
Stable: "stable",
Fluctuating: "fluctuating",
};
/** Resolve a server-emitted classification.category to its translated label. */
function translateStatus(
category: string,
t: (key: string, vars?: Record<string, string>) => string,
): string {
const key = STATUS_CATEGORY_KEY[category];
if (!key) return category;
return t(`targets.status.${key}`);
}
/**
* Map an in-band classification category to one of three semantic
* groups. Used to drive the status-pill style without re-classifying
* the value from scratch; the server's category is canonical.
*
* "in" = inside the green band ("On target", "Normal", "Optimal", …).
* "near" = inside the orange band ("Slightly elevated", "Overweight",
* …) or a step inside an obesity / hypertension grade-1 band.
* "out" = outside all bands ("Hypertension Grade 3", "Obesity Grade
* III", "Tachycardia" away from the target band, …).
*
* When the category is unmapped we fall back to "near" — the safe
* middle, which paints the pill in the neutral amber tone rather than
* lighting up a false-positive green/red.
*/
function statusGroupForCategory(category: string): "in" | "near" | "out" {
const greenCategories = new Set([
"Normal",
"Optimal",
"On target",
"Good",
"Very good",
"Very stable",
"Stable",
"Athletic",
"Fitness",
"Acceptable",
"Active",
"Very active",
"Moderately active",
"Essential",
]);
const redCategories = new Set([
"Obesity Grade III",
"Hypertension Grade 3",
"Significantly elevated",
"Significantly low",
"Far too short",
"Far too long",
"Tachycardia",
"Bradycardia",
"Low",
"High",
"Fluctuating",
"Below essential",
"Very low",
]);
if (greenCategories.has(category)) return "in";
if (redCategories.has(category)) return "out";
return "near";
}
// Use the Tailwind v4 parenthesised CSS-variable shorthand
// (bg-VAR-PAREN-FORM-/N) instead of the legacy bracketed VAR-form. The
// bracketed form combined with an opacity modifier emits an escaped
// class selector that Turbopack's CSS parser rejects with
// "Unexpected token Delim('.')" because the scanner picks the literal
// string out of source files (including this comment) and emits a CSS
// rule for it. The parenthesised shorthand produces a clean selector
// that parses cleanly. See the Tailwind v4 upgrade guide for the
// migration from bracket-form to paren-form for CSS-variable values.
const STATUS_PILL_STYLES: Record<"in" | "near" | "out", string> = {
in: "bg-(--dracula-green)/12 text-(--dracula-green) ring-(--dracula-green)/30",
near: "bg-(--dracula-orange)/14 text-(--dracula-orange) ring-(--dracula-orange)/30",
out: "bg-(--dracula-red)/12 text-(--dracula-red) ring-(--dracula-red)/30",
};
interface TargetClassification {
category: string;
color: string;
@@ -373,7 +256,6 @@ export function TargetCard({
const statusGroup = target.classification
? statusGroupForCategory(target.classification.category)
: "near";
const statusPillStyle = STATUS_PILL_STYLES[statusGroup];
const statusLabel = target.classification
? translateStatus(target.classification.category, t)
: null;
@@ -452,37 +334,13 @@ export function TargetCard({
</h3>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
{statusLabel ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
data-slot="target-status-pill"
data-status={statusGroup}
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1",
statusPillStyle,
)}
>
{statusLabel}
</span>
</TooltipTrigger>
<TooltipContent>
{target.range && (
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(target.range.min),
max: String(target.range.max),
unit: target.unit,
})}
</p>
)}
<p className="text-xs">
{t("targets.sourceLabel", { source: target.source })}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{target.classification ? (
<TargetStatusPill
classification={target.classification}
range={target.range}
unit={target.unit}
source={target.source}
/>
) : null}
<Button
type="button"
@@ -0,0 +1,228 @@
"use client";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.8.5 W5 — `<TargetStatusPill>` extracted from `target-card.tsx`.
*
* The verbal status pill ("Optimal", "Slightly elevated", localized)
* is now a shared primitive so both the Targets card and the Insights
* per-category reference panel render the identical pill from the same
* server-emitted `classification.category`. Single-sourcing the
* category → label → colour mapping here means the two surfaces can
* never disagree on what a band reads or which tone it paints.
*
* Behaviour is byte-identical to the previous inline version in
* `target-card.tsx`: a coloured pill (green in-band / amber caution /
* red out) with a tooltip carrying the target range + guideline source.
*/
interface TargetClassification {
category: string;
color: string;
}
interface TargetRange {
min: number;
max: number;
}
/**
* Server classification category → i18n key under `targets.status.*`.
* The category is canonical (emitted by `/api/insights/targets`); this
* map only resolves it to the localized label.
*/
const STATUS_CATEGORY_KEY: Record<string, string> = {
Underweight: "underweight",
Normal: "normal",
Overweight: "overweight",
"Obesity Grade I": "obesityGrade1",
"Obesity Grade II": "obesityGrade2",
"Obesity Grade III": "obesityGrade3",
Optimal: "optimal",
"High-normal": "highNormal",
"Hypertension Grade 1": "hypertensionGrade1",
"Hypertension Grade 2": "hypertensionGrade2",
"Hypertension Grade 3": "hypertensionGrade3",
Bradycardia: "bradycardia",
Elevated: "elevated",
Tachycardia: "tachycardia",
"Significantly low": "significantlyLow",
"Slightly low": "slightlyLow",
"On target": "onTarget",
"Slightly elevated": "slightlyElevated",
"Significantly elevated": "significantlyElevated",
"Far too short": "farTooShort",
"Too short": "tooShort",
"Slightly long": "slightlyLong",
"Far too long": "farTooLong",
"Below essential": "belowEssential",
Essential: "essential",
Athletic: "athletic",
Fitness: "fitness",
Acceptable: "acceptable",
Obese: "obese",
"Very low": "veryLow",
"Low active": "lowActive",
"Moderately active": "moderatelyActive",
Active: "active",
"Very active": "veryActive",
Good: "good",
Moderate: "moderate",
Low: "low",
High: "high",
"Very good": "veryGood",
"Very stable": "veryStable",
Stable: "stable",
Fluctuating: "fluctuating",
};
/** Resolve a server-emitted classification.category to its translated label. */
export function translateStatus(
category: string,
t: (key: string, vars?: Record<string, string>) => string,
): string {
const key = STATUS_CATEGORY_KEY[category];
if (!key) return category;
return t(`targets.status.${key}`);
}
/**
* Map an in-band classification category to one of three semantic
* groups. Used to drive the status-pill style without re-classifying
* the value from scratch; the server's category is canonical.
*
* "in" = inside the green band ("On target", "Normal", "Optimal", …).
* "near" = inside the orange band ("Slightly elevated", "Overweight",
* …) or a step inside an obesity / hypertension grade-1 band.
* "out" = outside all bands ("Hypertension Grade 3", "Obesity Grade
* III", "Tachycardia" away from the target band, …).
*
* When the category is unmapped we fall back to "near" — the safe
* middle, which paints the pill in the neutral amber tone rather than
* lighting up a false-positive green/red.
*/
export function statusGroupForCategory(
category: string,
): "in" | "near" | "out" {
const greenCategories = new Set([
"Normal",
"Optimal",
"On target",
"Good",
"Very good",
"Very stable",
"Stable",
"Athletic",
"Fitness",
"Acceptable",
"Active",
"Very active",
"Moderately active",
"Essential",
]);
const redCategories = new Set([
"Obesity Grade III",
"Hypertension Grade 3",
"Significantly elevated",
"Significantly low",
"Far too short",
"Far too long",
"Tachycardia",
"Bradycardia",
"Low",
"High",
"Fluctuating",
"Below essential",
"Very low",
]);
if (greenCategories.has(category)) return "in";
if (redCategories.has(category)) return "out";
return "near";
}
// Coloured text on a 10% same-hue tint with a 30% ring. The tint and
// text share one theme-aware token per tone (`--status-pill-{in,near,
// out}` in globals.css) so the pill clears WCAG AA (4.5:1) on both the
// Targets card (`bg-card`) and the W5 insights reference panel
// (`bg-muted/30`) in both dark and light themes — the raw Dracula
// green/orange/red failed AA for the "out" tone on the muted panel in
// dark mode and for every tone on a white card in light mode.
//
// Use the Tailwind v4 parenthesised CSS-variable shorthand
// (bg-VAR-PAREN-FORM-/N) instead of the legacy bracketed VAR-form. The
// bracketed form combined with an opacity modifier emits an escaped
// class selector that Turbopack's CSS parser rejects with
// "Unexpected token Delim('.')" because the scanner picks the literal
// string out of source files (including this comment) and emits a CSS
// rule for it. The parenthesised shorthand produces a clean selector
// that parses cleanly. See the Tailwind v4 upgrade guide for the
// migration from bracket-form to paren-form for CSS-variable values.
export const STATUS_PILL_STYLES: Record<"in" | "near" | "out", string> = {
in: "bg-(--status-pill-in)/10 text-(--status-pill-in) ring-(--status-pill-in)/30",
near: "bg-(--status-pill-near)/10 text-(--status-pill-near) ring-(--status-pill-near)/30",
out: "bg-(--status-pill-out)/10 text-(--status-pill-out) ring-(--status-pill-out)/30",
};
export interface TargetStatusPillProps {
classification: TargetClassification;
range: TargetRange | null;
unit: string;
source: string;
}
/**
* Verbal status pill with a tooltip carrying the target range + the
* guideline source. The pill colour is derived from the classification
* category's semantic group; unmapped categories paint amber.
*/
export function TargetStatusPill({
classification,
range,
unit,
source,
}: TargetStatusPillProps) {
const { t } = useTranslations();
const statusGroup = statusGroupForCategory(classification.category);
const statusPillStyle = STATUS_PILL_STYLES[statusGroup];
const statusLabel = translateStatus(classification.category, t);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
data-slot="target-status-pill"
data-status={statusGroup}
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1",
statusPillStyle,
)}
>
{statusLabel}
</span>
</TooltipTrigger>
<TooltipContent>
{range && (
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(range.min),
max: String(range.max),
unit,
})}
</p>
)}
<p className="text-xs">{t("targets.sourceLabel", { source })}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -14,10 +14,13 @@
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
MIN_STABLE_DOSES,
buildComplianceDisplay,
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
expectedSlotCountForDay,
expectedSlotsBetween,
lastNonSkippedTakenAt,
type ComplianceMedicationContext,
type ComplianceSchedule,
@@ -1019,3 +1022,275 @@ describe("classifyIntakeTiming", () => {
);
});
});
// v1.8.5 — the cadence-density rule + the per-dose uptime strip.
// `buildComplianceDisplay` decides whether the card keeps the 7-/30-day
// percentage bars (dense cadences) or swaps to a per-dose timeline
// (sparse cadences). The 30-day window is the density yardstick.
describe("buildComplianceDisplay — density rule + dose timeline", () => {
const NOW = new Date("2025-06-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
function ctx(
overrides: Partial<ComplianceMedicationContext> = {},
): ComplianceMedicationContext {
return buildComplianceMedicationContext(
{
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
oneShot: overrides.oneShot ?? false,
// Created well before the windows so no day is excluded.
createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"),
},
overrides.lastIntakeAt ?? null,
overrides.timeZone ?? "UTC",
);
}
it("echoes the threshold so a client can re-derive the mode", () => {
const display = buildComplianceDisplay(
[],
[{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, rrule: "FREQ=DAILY", timesOfDay: ["08:00"] }],
ctx(),
{ now: NOW },
);
expect(display.minStableDoses).toBe(MIN_STABLE_DOSES);
});
it("daily med → percent mode (30-day window clears the 4-dose floor)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
expect(display.mode).toBe("percent");
expect(display.expected30).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
});
it("35-day-interval med → timeline mode (sparse: <4 doses in 30 days)", () => {
// A monthly injection: one dose every 5 weeks. Anchored on a recent
// start so the engine emits at most one slot inside the 30-day window.
const schedules: ComplianceSchedule[] = [
{
windowStart: "10:00",
windowEnd: "11:00",
daysOfWeek: null,
rollingIntervalDays: 35,
timesOfDay: ["10:00"],
},
];
const display = buildComplianceDisplay(
[],
schedules,
ctx({
startsOn: new Date("2025-05-01T00:00:00Z"),
// Rolling cadences re-anchor on the last non-skipped intake.
lastIntakeAt: new Date("2025-05-20T10:00:00Z"),
}),
{ now: NOW },
);
expect(display.mode).toBe("timeline");
expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES);
});
it("weekly med → timeline mode (≥4 needs ~28 days; 30d holds ~4 but <4 when phased)", () => {
// Weekly Mondays-only: across a 30-day trailing window this yields ~4
// Mondays. With a start mid-window the realised count drops below 4 →
// timeline. Pin the realised count rather than asserting a fixed mode
// independent of phase.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay(
[],
schedules,
ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
{ now: NOW },
);
// 2025-05-26 .. 2025-06-15 → Mondays 5/26, 6/2, 6/9 → 3 expected < 4.
expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES);
expect(display.mode).toBe("timeline");
});
it("builds taken / missed / skipped cells from the paired engine timeline", () => {
// Weekly Mondays, three past Mondays inside the window: one taken on
// time, one skipped, one missed (no intake). The strip mirrors that.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
timesOfDay: ["08:00"],
},
];
const events = [
// 2025-06-09 Monday — taken on time.
{
scheduledFor: new Date("2025-06-09T08:00:00Z"),
takenAt: new Date("2025-06-09T08:10:00Z"),
skipped: false,
},
// 2025-06-02 Monday — skipped.
{
scheduledFor: new Date("2025-06-02T08:00:00Z"),
takenAt: null,
skipped: true,
},
// 2025-05-26 Monday — no event → missed.
];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
{ now: NOW },
);
const past = display.doseTimeline.filter((c) => c.status !== "upcoming");
const statuses = past.map((c) => c.status);
expect(statuses).toContain("taken");
expect(statuses).toContain("skipped");
expect(statuses).toContain("missed");
const takenCell = past.find((c) => c.status === "taken")!;
expect(takenCell.takenAt).not.toBeNull();
expect(takenCell.timing).toBe("on_time");
const skippedCell = past.find((c) => c.status === "skipped")!;
expect(skippedCell.takenAt).toBeNull();
expect(skippedCell.timing).toBeNull();
// Summary: taken counts in numerator + denominator; skipped excluded
// from both; missed in denominator only.
expect(display.recentDoseSummary.taken).toBe(1);
expect(display.recentDoseSummary.total).toBe(2); // taken + missed
});
it("dose-streak: counts consecutive taken-or-skipped back from the newest, breaks on a miss", () => {
// Weekly Mondays: 5/26 missed, 6/2 taken, 6/9 taken → newest-back
// streak = 2 (stops at the 5/26 miss).
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
timesOfDay: ["08:00"],
},
];
const events = [
{
scheduledFor: new Date("2025-06-09T08:00:00Z"),
takenAt: new Date("2025-06-09T08:05:00Z"),
skipped: false,
},
{
scheduledFor: new Date("2025-06-02T08:00:00Z"),
takenAt: new Date("2025-06-02T08:05:00Z"),
skipped: false,
},
// 5/26 missed.
];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
{ now: NOW },
);
expect(display.recentDoseSummary.doseStreak).toBe(2);
});
it("caps the past strip at 12 cells for a dense daily med", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
const past = display.doseTimeline.filter((c) => c.status !== "upcoming");
expect(past.length).toBeLessThanOrEqual(12);
});
});
describe("expectedSlotsBetween", () => {
function ctx(
overrides: Partial<ComplianceMedicationContext> = {},
): ComplianceMedicationContext {
return buildComplianceMedicationContext(
{
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
oneShot: overrides.oneShot ?? false,
createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"),
},
overrides.lastIntakeAt ?? null,
overrides.timeZone ?? "UTC",
);
}
it("returns the occurrences themselves, ascending by instant", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00", "20:00"],
},
];
const slots = expectedSlotsBetween(
schedules,
new Date("2025-06-09T00:00:00Z"),
new Date("2025-06-10T23:59:59Z"),
ctx(),
);
// Two days × two times = four slots, ascending.
expect(slots.length).toBe(4);
for (let i = 1; i < slots.length; i++) {
expect(slots[i].at.getTime()).toBeGreaterThanOrEqual(
slots[i - 1].at.getTime(),
);
}
});
it("excludes PRN schedules from the expected slots", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
scheduleType: "PRN",
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const slots = expectedSlotsBetween(
schedules,
new Date("2025-06-09T00:00:00Z"),
new Date("2025-06-12T00:00:00Z"),
ctx(),
);
expect(slots.length).toBe(0);
});
});
@@ -24,6 +24,7 @@ const stubSummary = (): DataSummary => ({
min: 76,
max: 80,
mean: 78,
median: 78,
avg7: 78.2,
avg30: 77.9,
slope7: null,
@@ -74,6 +74,7 @@ describe("computeSummariesSlice", () => {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -273,6 +274,7 @@ describe("computeSummariesSlice", () => {
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
median: 82.1,
slope7: 0.02,
r2_7: 0.5,
slope30: 0.01,
@@ -307,6 +309,9 @@ describe("computeSummariesSlice", () => {
expect(weight.min).toBe(79.5);
expect(weight.max).toBe(84.0);
expect(weight.mean).toBe(82);
// v1.8.5 — the windowed median flows through from the narrow
// `PERCENTILE_CONT` column onto the slim summary.
expect(weight.median).toBe(82.1);
expect(weight.latest).toBe(82.7);
expect(weight.avg7).toBe(82);
expect(weight.slope7).toEqual({
@@ -76,6 +76,7 @@ describe("summarize", () => {
expect(summary.min).toBeNull();
expect(summary.max).toBeNull();
expect(summary.mean).toBeNull();
expect(summary.median).toBeNull();
expect(summary.avg7).toBeNull();
expect(summary.avg30).toBeNull();
});
@@ -88,10 +89,33 @@ describe("summarize", () => {
expect(summary.min).toBe(70);
expect(summary.max).toBe(80);
expect(summary.mean).toBe(75);
// 11 values — odd length, median is the 6th value (75).
expect(summary.median).toBe(75);
expect(summary.avg7).not.toBeNull();
expect(summary.slope7).not.toBeNull();
});
// v1.8.5 — median powers the category-page stat strip. It must be the
// 50th percentile over VALUES (not timestamps) and must resist a skew
// the mean would otherwise hide.
it("computes the median over an even-length series as the midpoint", () => {
// Four values; median = (72 + 74) / 2 = 73. Insertion order is
// intentionally not sorted to prove the helper sorts by value.
const data = makePoints([74, 70, 72, 76], 4);
const summary = summarize(data);
expect(summary.median).toBe(73);
});
it("median diverges from the mean on a right-skewed series", () => {
// One large outlier drags the mean up; the median stays at the
// central reading — exactly the honesty the stat strip surfaces.
const data = makePoints([10, 11, 12, 13, 200], 5);
const summary = summarize(data);
expect(summary.median).toBe(12);
expect(summary.mean).not.toBe(summary.median);
expect(summary.mean as number).toBeGreaterThan(summary.median as number);
});
// v3 audit caught a divergence: summarize().avg7 used `now` as the
// window anchor, while trendSlope() used the last point. A stale
// series (no readings for weeks) reported a slope but no average.
+318 -26
View File
@@ -23,12 +23,17 @@ import {
buildCadenceTimeline,
type CadenceEngineContext,
type IntakeEventLike,
type PairedDose,
type ScheduleLike,
} from "@/lib/medications/scheduling/cadence";
import {
occurrencesBetween,
type CanonicalSchedule,
type Occurrence,
type RecurrenceContext,
type ScheduleType,
} from "@/lib/medications/scheduling/recurrence";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
interface IntakeEvent {
takenAt: Date | null;
@@ -249,6 +254,53 @@ export function lastNonSkippedTakenAt(
}, null);
}
/**
* v1.8.5 — adapt a compliance medication context to the canonical engine's
* {@link RecurrenceContext}. The synthetic medication `id` only labels the
* row for the engine's internal logging; the `idTag` keeps the two callers'
* historical prefixes (`compliance-daily` vs `compliance-slots`) distinct.
*/
function toRecurrenceCtx(
ctx: ComplianceMedicationContext,
idTag: string,
): RecurrenceContext {
return {
medication: {
id: idTag,
startsOn: ctx.startsOn,
endsOn: ctx.endsOn,
oneShot: ctx.oneShot,
createdAt: ctx.createdAt,
},
timeZone: ctx.timeZone,
lastIntakeAt: ctx.lastIntakeAt,
};
}
/**
* v1.8.5 — adapt a {@link ComplianceSchedule} to the canonical engine's
* {@link CanonicalSchedule}, defaulting the optional fields the compliance
* payload may omit. `id` is engine-internal labelling only.
*/
function toCanonicalSchedule(
s: ComplianceSchedule,
id: string,
): CanonicalSchedule {
return {
id,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
timesOfDay: s.timesOfDay ?? [],
daysOfWeek: s.daysOfWeek ?? null,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
};
}
/**
* v1.7.0 item 5 — count the expected dose slots a medication's schedules
* emit inside `[dayStart, dayEnd)`, routed through the canonical engine.
@@ -264,34 +316,10 @@ export function expectedSlotCountForDay(
ctx: ComplianceMedicationContext,
): number {
let count = 0;
const recurrenceCtx = {
medication: {
id: "compliance-daily",
startsOn: ctx.startsOn,
endsOn: ctx.endsOn,
oneShot: ctx.oneShot,
createdAt: ctx.createdAt,
},
timeZone: ctx.timeZone,
lastIntakeAt: ctx.lastIntakeAt,
};
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily");
for (let i = 0; i < schedules.length; i++) {
const s = schedules[i];
const canonical = {
id: `compliance-daily-${i}`,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
timesOfDay: s.timesOfDay ?? [],
daysOfWeek: s.daysOfWeek ?? null,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
scheduleType: s.scheduleType ?? ("SCHEDULED" as const),
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
};
count += occurrencesBetween(
canonical,
toCanonicalSchedule(schedules[i], `compliance-daily-${i}`),
dayStart,
// occurrencesBetween is inclusive of both ends; subtract 1 ms so a
// slot exactly at the next day's midnight doesn't double-count.
@@ -302,6 +330,270 @@ export function expectedSlotCountForDay(
return count;
}
/**
* v1.8.5 — the sibling of {@link expectedSlotCountForDay} that returns the
* expected-dose occurrences *themselves* (ascending by instant) over an
* arbitrary window, rather than just the per-day count. Same loop, same
* canonical-engine delegation; we keep the slots so the dose-adherence
* timeline can pair each expected slot to its intake.
*
* Used by {@link buildComplianceDisplay} to decide the card's render mode
* (percent bars vs an uptime-style per-dose strip) and to build the strip.
* `occurrencesBetween` is inclusive of both ends; the caller passes a
* `[from, to]` window and we sort the union of every schedule's slots so
* a multi-schedule medication interleaves its slots in time order.
*/
export function expectedSlotsBetween(
schedules: ComplianceSchedule[],
from: Date,
to: Date,
ctx: ComplianceMedicationContext,
): Occurrence[] {
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots");
const all: Occurrence[] = [];
for (let i = 0; i < schedules.length; i++) {
all.push(
...occurrencesBetween(
toCanonicalSchedule(schedules[i], `compliance-slots-${i}`),
from,
to,
recurrenceCtx,
),
);
}
return all.sort((a, b) => a.at.getTime() - b.at.getTime());
}
/**
* v1.8.5 — render mode for the per-medication compliance display. A
* window's percentage is only meaningful once the denominator is large
* enough that a single miss moves the rate by a small amount; below that
* the bar swings violently and tells the user nothing. So:
*
* - `"percent"`: keep the 7-/30-day percentage bars (dense cadences:
* daily, multi-daily, weekday meds). The window holds ≥
* `MIN_STABLE_DOSES` expected doses.
* - `"timeline"`: swap to an uptime-style per-dose strip (sparse cadences:
* weekly, bi-/tri-weekly, rolling 35-day injections) where each cell is
* one expected dose, taken=green / missed=red / skipped=grey.
*/
export type ComplianceDisplayMode = "percent" | "timeline";
/**
* v1.8.5 — one cell of the dose-adherence timeline. A projection of the
* canonical engine's expected-dose slots paired with the recorded intake,
* oldest → newest. `site` is reserved for injection meds so the strip can
* dual-encode rotation; it is `null` until the per-dose injection-site
* write path lands.
*/
export interface DoseTimelineCell {
/** ISO instant of the expected dose slot. */
scheduledFor: string;
status: "taken" | "missed" | "skipped" | "upcoming";
/** ISO instant the dose was logged, or null when not taken. */
takenAt: string | null;
/** Punctuality bucket for a taken dose; null when not taken. */
timing: IntakeTimingClass | null;
/** Injection zone for injection meds; null otherwise / when unrecorded. */
site: InjectionSiteKey | null;
}
/**
* v1.8.5 — the additive compliance-display block returned alongside the
* existing `compliance7` / `compliance30` fields (which iOS + the Health
* Score still read verbatim). The server decides `mode` from the density
* rule so the client stays dumb.
*/
export interface ComplianceDisplay {
mode: ComplianceDisplayMode;
/** Realised expected dose count over the 7-day window. */
expected7: number;
/** Realised expected dose count over the 30-day window. */
expected30: number;
/** Echo of the density threshold so a client can re-derive the mode. */
minStableDoses: number;
/** Uptime strip — last N expected dose slots, oldest → newest. */
doseTimeline: DoseTimelineCell[];
/** Compact summary for the "last N doses" line. */
recentDoseSummary: { taken: number; total: number; doseStreak: number };
}
/**
* v1.8.5 — the floor at which a window's percentage is stable. Four
* expected doses is the point where a single miss moves the rate by ≤25%
* rather than ±50–100%. A daily med clears it in 4 days; a weekly med
* needs ~28 days; a tri-weekly / 35-day-rolling med never clears it in a
* 30-day window — which is exactly the signal to show the timeline.
*/
export const MIN_STABLE_DOSES = 4;
/** Number of past dose cells the card strip carries. */
const TIMELINE_PAST_DOSES = 12;
/** Number of upcoming dose cells appended so the strip shows what's next. */
const TIMELINE_UPCOMING_DOSES = 2;
/** A single paired dose, distilled into a timeline cell. */
function toTimelineCell(dose: PairedDose): DoseTimelineCell {
const takenAt = dose.match?.takenAt ?? null;
return {
scheduledFor: dose.windowStart.toISOString(),
status: dose.status,
takenAt: takenAt ? takenAt.toISOString() : null,
// Punctuality is computed against the slot window the engine already
// emitted. `windowStart` / `windowEnd` are full instants here; reuse
// the HH:mm classifier by formatting them back to wall-clock on the
// slot's own day so an early / late dose reads correctly.
timing:
dose.status === "taken" && takenAt
? classifyIntakeTiming(
takenAt,
hhmmOf(dose.windowStart),
hhmmOf(dose.windowEnd),
dose.windowStart,
)
: null,
// Injection-site dual-encoding is reserved for the per-dose write
// path; the compliance read does not yet carry it.
site: null,
};
}
/** Format a Date's UTC wall-clock as "HH:mm" for the punctuality classifier. */
function hhmmOf(d: Date): string {
const h = String(d.getUTCHours()).padStart(2, "0");
const m = String(d.getUTCMinutes()).padStart(2, "0");
return `${h}:${m}`;
}
/**
* v1.8.5 — compute the additive {@link ComplianceDisplay} block.
*
* The mode is decided purely by density: when the 30-day window holds at
* least {@link MIN_STABLE_DOSES} expected doses the percentage bars stay
* (`"percent"`); otherwise the per-dose timeline takes over (`"timeline"`).
* The 30-day window is the honest denominator — a weekly med clears the
* floor there, a sparse injection never does.
*
* The timeline itself is a projection of {@link buildCadenceTimeline}: the
* engine already pairs each expected slot to its closest intake, so we
* take the last {@link TIMELINE_PAST_DOSES} past slots plus a couple of
* upcoming ones and distil each into a cell. `doseStreak` counts
* consecutive taken-or-skipped doses back from the newest non-upcoming
* dose — dose-indexed, unlike the day-indexed `streak` on
* {@link ComplianceResult} which inflates on off-cadence days.
*/
export function buildComplianceDisplay(
events: IntakeEvent[],
schedules: ComplianceSchedule[],
ctx: ComplianceMedicationContext,
options?: { now?: Date },
): ComplianceDisplay {
const now = options?.now ?? new Date();
const DAY_MS = 24 * 60 * 60 * 1000;
// Realised expected counts over each window. `expectedSlotsBetween`
// already excludes PRN / off-cadence days through the canonical engine.
const from7 = new Date(now.getTime() - 7 * DAY_MS);
const from30 = new Date(now.getTime() - 30 * DAY_MS);
const expected7 = expectedSlotsBetween(schedules, from7, now, ctx).length;
const expected30 = expectedSlotsBetween(schedules, from30, now, ctx).length;
const mode: ComplianceDisplayMode =
expected30 >= MIN_STABLE_DOSES ? "percent" : "timeline";
// Build the per-dose strip from the same engine the cadence chart uses.
// Pull a generous past window (90 days) so even a 35-day-rolling med
// surfaces a few cells, then keep the last N plus a short look-ahead.
const engineCtx: CadenceEngineContext = {
startsOn: ctx.startsOn,
endsOn: ctx.endsOn,
oneShot: ctx.oneShot,
createdAt: ctx.createdAt,
lastIntakeAt: ctx.lastIntakeAt,
timeZone: ctx.timeZone,
};
const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({
id: `display-${i}`,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
daysOfWeek: s.daysOfWeek ?? null,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
timesOfDay: s.timesOfDay,
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
scheduleType: s.scheduleType ?? null,
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
}));
const normalisedEvents: IntakeEventLike[] = events.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
}));
// Past window for the strip — bounded so a daily med doesn't return
// hundreds of cells. `buildCadenceTimeline` anchors on `now - windowDays`
// and projects forward; a small look-ahead is added below.
const STRIP_PAST_DAYS = 90;
const past = buildCadenceTimeline(
normalisedSchedules,
normalisedEvents,
now,
STRIP_PAST_DAYS,
ctx.createdAt,
engineCtx.timeZone,
engineCtx,
)
.filter((d) => d.status !== "upcoming")
.sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime());
// Upcoming slots — expand a short forward window and keep the soonest.
const lookahead = new Date(now.getTime() + 60 * DAY_MS);
const upcoming = buildCadenceTimeline(
normalisedSchedules,
normalisedEvents,
lookahead,
60,
ctx.createdAt,
engineCtx.timeZone,
engineCtx,
)
.filter((d) => d.status === "upcoming" && d.windowStart.getTime() > now.getTime())
.sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime())
.slice(0, TIMELINE_UPCOMING_DOSES);
const recentPast = past.slice(-TIMELINE_PAST_DOSES);
const doseTimeline = [...recentPast, ...upcoming].map(toTimelineCell);
// Summary over the past cells the strip shows.
const summaryTaken = recentPast.filter((d) => d.status === "taken").length;
const summaryTotal = recentPast.filter(
(d) => d.status === "taken" || d.status === "missed",
).length;
// Dose-streak: consecutive taken-or-skipped doses, newest → oldest, from
// the newest non-upcoming dose. A miss breaks it; a skip does not.
let doseStreak = 0;
for (let i = recentPast.length - 1; i >= 0; i--) {
const s = recentPast[i].status;
if (s === "missed") break;
if (s === "taken" || s === "skipped") doseStreak++;
}
return {
mode,
expected7,
expected30,
minStableDoses: MIN_STABLE_DOSES,
doseTimeline,
recentDoseSummary: {
taken: summaryTaken,
total: summaryTotal,
doseStreak,
},
};
}
/**
* Calculate compliance for a medication over a given period.
*
+29
View File
@@ -109,6 +109,22 @@ interface NarrowAggregateRow {
type: string;
avg7: number | null;
avg30: number | null;
/**
* v1.8.5 — 50th percentile over the trailing 90-day window. The
* windowed median is cheap (the narrow query already index-scans the
* 90-day partition) and represents the central reading the stat strip
* surfaces next to min / max / mean. An all-time median would require
* sorting every raw row per type, which the rollup tier cannot
* compose and which the perf posture (v1.8.3 anti-freeze) forbids on
* the read path. Null when the window holds no rows.
*
* NOTE: this window (trailing 90 days) is the contract for the stat
* strip's `DataSummary.median`. The JS `summarize()` in `trends.ts`
* computes its median over the *full* series it is handed (a
* caller-defined window), so the two share a field name but not a
* window — see the `median` doc on `DataSummary`.
*/
median: number | null;
avg30_last_month: number | null;
slope7: number | null;
r2_7: number | null;
@@ -159,6 +175,7 @@ function emptySummary(): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -271,6 +288,11 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
AND m."measured_at" < NOW() - INTERVAL '30 days'
)::double precision AS avg30_last_month,
PERCENTILE_CONT(0.5) WITHIN GROUP (
ORDER BY m."value"
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
)::double precision AS median,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
@@ -414,6 +436,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
min: round2(row.min),
max: round2(row.max),
mean: round2(row.mean),
median: round2(narrow?.median ?? null),
avg7: round2(narrow?.avg7 ?? null),
avg30: round2(narrow?.avg30 ?? null),
slope7: buildSlope(narrow?.slope7 ?? null, narrow?.r2_7 ?? null),
@@ -509,6 +532,11 @@ async function computeFromLiveAggregate(
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
AND m."measured_at" < NOW() - INTERVAL '30 days'
)::double precision AS avg30_last_month,
PERCENTILE_CONT(0.5) WITHIN GROUP (
ORDER BY m."value"
) FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
)::double precision AS median,
REGR_SLOPE(
m."value",
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
@@ -617,6 +645,7 @@ async function computeFromLiveAggregate(
min: round2(row.min_value),
max: round2(row.max_value),
mean: round2(row.mean_value),
median: round2(win?.median ?? null),
avg7: round2(win?.avg7 ?? null),
avg30: round2(win?.avg30 ?? null),
slope7: buildSlope(win?.slope7 ?? null, win?.r2_7 ?? null),
+37
View File
@@ -120,6 +120,24 @@ export interface DataSummary {
max: number | null;
/** Null when the series is empty (vs the previous 0 sentinel). */
mean: number | null;
/**
* v1.8.5 — 50th percentile (median) of the value series. Surfaced on
* the insights category-page stat strip alongside min / max / mean so
* a skewed series (a few very high or very low readings dragging the
* mean) reads honestly. Null when the series is empty.
*
* Window: caller-defined. `summarize()` computes the median over the
* full `DataPoint[]` it is handed, so the window is whatever the caller
* passed (full history for `/api/measurements/series`, per-day means
* for the snapshot builder, …). It is NOT guaranteed to be the trailing
* 90-day window. The insights stat strip does not read this value — its
* median comes from the SQL `summaries-slice` path, which fixes the
* window to the trailing 90 days (`PERCENTILE_CONT(0.5) … FILTER (WHERE
* measured_at >= NOW() - INTERVAL '90 days')`). A consumer comparing the
* two must account for the differing windows. The percentile *algorithm*
* matches (linear-interpolated midpoint), only the window differs.
*/
median: number | null;
avg7: number | null;
avg30: number | null;
slope7: TrendSlope | null;
@@ -152,6 +170,7 @@ export function summarize(data: DataPoint[]): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
@@ -184,6 +203,23 @@ export function summarize(data: DataPoint[]): DataSummary {
}
const mean = sum / values.length;
// v1.8.5 — median (50th percentile) over the FULL series handed to
// this function (the window is caller-defined; see the `median` doc on
// DataSummary). A separate numeric sort from the date-ordered `sorted`
// array above so the percentile is taken over values, not timestamps.
// Linear-interpolated midpoint on an even-length series (the standard
// PERCENTILE_CONT definition) so the *algorithm* matches the SQL
// `PERCENTILE_CONT(0.5)` the rollup-tier slim slice emits — but note
// the SQL path windows to the trailing 90 days while this path does
// not, so the two are byte-comparable only when the caller already
// passed a 90-day series.
const valueSorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(valueSorted.length / 2);
const median =
valueSorted.length % 2 === 0
? (valueSorted[mid - 1] + valueSorted[mid]) / 2
: valueSorted[mid];
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
const last7 = sorted.filter((p) => now - p.date.getTime() < 7 * DAY);
@@ -215,6 +251,7 @@ export function summarize(data: DataPoint[]): DataSummary {
min: minVal,
max: maxVal,
mean: Math.round(mean * 100) / 100,
median: Math.round(median * 100) / 100,
avg7:
last7.length > 0
? Math.round(
+3
View File
@@ -76,6 +76,9 @@ export function invalidateUserMood(userId: string): void {
// v1.4.36 W1 — mood writes change the mood target rows on the
// insights/targets response (MOOD_SCORE, MOOD_STABILITY).
caches.insightsTargets.deleteByPrefix(userId);
// v1.8.5 — mood writes dirty every dimension of the mood-insights
// aggregate (heatmap cell, distribution, weekday, tag breakdown).
caches.moodInsights.deleteByPrefix(userId);
}
/**
+12
View File
@@ -260,6 +260,18 @@ export const caches = {
maxEntries: 1000,
ttlMs: 60_000,
}),
/**
* v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,
* weekday, tags, cross-metric correlations) for the Mood Insights page.
* Read on every `/insights/mood` mount; bounded 365-day live read behind
* the cache. 60 s TTL matches `moodAnalytics`; invalidated alongside it
* on a mood write via `invalidateUserMood`.
*/
moodInsights: new ServerCache<unknown>({
name: "moodInsights",
maxEntries: 1000,
ttlMs: 60_000,
}),
/**
* v1.4.36 W1 — `/api/insights/targets` is read on every Insights
* mount and runs >1 s cold (it pulls 30-day measurements for every
@@ -398,6 +398,44 @@ describe("buildFhirDocumentBundle", () => {
expect(flights?.valueQuantity?.code).toBe("{flights}");
});
it("routes HealthKit placeholder codes onto the custom CodeSystem, never LOINC", () => {
const bundle = buildFhirDocumentBundle(
makeData({
measurements: {
WEIGHT: [{ value: 80, measuredAt: "2026-04-30T08:00:00.000Z" }],
FLIGHTS_CLIMBED: [
{ value: 12, measuredAt: "2026-04-30T08:00:00.000Z" },
],
},
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const codings = observationsOf(bundle).flatMap((o) => o.code.coding ?? []);
// A real LOINC metric keeps the LOINC namespace.
const weight = codings.find((c) => c.code === "29463-7");
expect(weight?.system).toBe("http://loinc.org");
// The HealthKit placeholder moves to the shared custom CodeSystem with the
// raw HK identifier as the code (byte-aligned with the iOS exporter).
const flightsCoding = codings.find(
(c) => c.code === "HKQuantityTypeIdentifierFlightsClimbed",
);
expect(flightsCoding?.system).toBe(
"https://healthlog.dev/fhir/CodeSystem/healthkit",
);
// Conformance invariant: no HealthKit identifier is ever emitted under the
// LOINC namespace anywhere in the bundle.
const hkUnderLoinc = codings.filter(
(c) =>
c.system === "http://loinc.org" &&
c.code?.startsWith("HKQuantityTypeIdentifier"),
);
expect(hkUnderLoinc).toHaveLength(0);
});
it("discriminates glucose LOINC by context (random/fasting/afterMeal)", () => {
const bundle = buildFhirDocumentBundle(
makeData({
+9 -1
View File
@@ -21,6 +21,7 @@ import type { DoctorReportData } from "@/lib/doctor-report-data";
import { resolveGlucoseUnit, convertGlucose } from "@/lib/glucose";
import {
LOINC_SYSTEM,
HEALTHKIT_CODESYSTEM,
UCUM_SYSTEM,
MEASUREMENT_LOINC,
BP_PANEL_LOINC,
@@ -66,8 +67,15 @@ function escapeXml(value: string): string {
function codeableFromMapping(m: LoincMapping): FhirCodeableConcept {
if (m.loinc) {
// HealthKit placeholder codes have no published LOINC term; they must not
// sit under the LOINC namespace (a non-LOINC code there is a conformance
// violation). Route them onto the shared custom CodeSystem instead —
// byte-aligned with the iOS exporter.
const system = m.loinc.startsWith("HKQuantityTypeIdentifier")
? HEALTHKIT_CODESYSTEM
: LOINC_SYSTEM;
return {
coding: [{ system: LOINC_SYSTEM, code: m.loinc, display: m.display }],
coding: [{ system, code: m.loinc, display: m.display }],
text: m.display,
};
}
+15 -4
View File
@@ -12,6 +12,15 @@
export const LOINC_SYSTEM = "http://loinc.org";
export const UCUM_SYSTEM = "http://unitsofmeasure.org";
/**
* Shared custom CodeSystem for HealthKit placeholder metrics that have no
* published LOINC term. A non-LOINC code under `http://loinc.org` is a FHIR
* conformance violation, so these route here instead. Byte-aligned with the
* iOS exporter (confirmed 2026-06-01) — both clients emit the identical
* `system` + raw `HKQuantityTypeIdentifier…` `code`.
*/
export const HEALTHKIT_CODESYSTEM =
"https://healthlog.dev/fhir/CodeSystem/healthkit";
export type FhirObservationCategory =
| "vital-signs"
@@ -145,10 +154,12 @@ export const MEASUREMENT_LOINC: Record<string, LoincMapping> = {
unit: "kg",
category: "vital-signs",
},
// HK-placeholder codes — no published LOINC term. iOS emits the HealthKit
// identifier STRING as the `code` (in the LOINC system slot); the server
// must emit the identical placeholder, not invent a LOINC. The `loinc`
// field below carries that placeholder string verbatim.
// HK-placeholder codes — no published LOINC term. Both iOS and the server
// emit the HealthKit identifier STRING as the `code` under the shared
// `HEALTHKIT_CODESYSTEM` (NOT the LOINC namespace — a non-LOINC code under
// http://loinc.org fails FHIR conformance). The `loinc` field below carries
// the placeholder string verbatim; the builder routes any
// `HKQuantityTypeIdentifier…` code onto the custom system.
WALKING_DOUBLE_SUPPORT: {
loinc: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
display: "Walking double support percentage",
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more