mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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.
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -225,6 +225,7 @@ export default function InsightsPage() {
|
||||
)}
|
||||
|
||||
<TrendsRow
|
||||
briefing={briefingPayload}
|
||||
annotations={advisor.payload?.trendAnnotations ?? null}
|
||||
loading={advisor.isLoading || advisor.isRegenerating}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Vendored
+3
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+12
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user