diff --git a/.planning/ios-coord/v1.8.5-server-to-ios-injection-site.md b/.planning/ios-coord/v1.8.5-server-to-ios-injection-site.md new file mode 100644 index 000000000..71a999f18 --- /dev/null +++ b/.planning/ios-coord/v1.8.5-server-to-ios-injection-site.md @@ -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. diff --git a/.planning/ios-coord/v1.8.5-server-to-ios-mean-metric-consolidation.md b/.planning/ios-coord/v1.8.5-server-to-ios-mean-metric-consolidation.md new file mode 100644 index 000000000..a694a1892 --- /dev/null +++ b/.planning/ios-coord/v1.8.5-server-to-ios-mean-metric-consolidation.md @@ -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::` 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 92abd02d2..af8ac9363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5e72f31ea..2788fb057 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 5bc9aa094..29043ee61 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -37,6 +37,7 @@ function summary(partial: Partial): DataSummary { min: null, max: null, mean: null, + median: null, avg7: null, avg30: null, slope7: null, diff --git a/messages/de.json b/messages/de.json index c55754cd6..e880d7d36 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 2a3a1109a..b72e5de89 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 646426611..4a09a62ae 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 27f3d555d..b0536ba98 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 4b4417156..73de3baa4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 87e160988..d781ee6f1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index ed9603af0..f6ff459ec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/0099_v185_medication_injection_tracking/migration.sql b/prisma/migrations/0099_v185_medication_injection_tracking/migration.sql new file mode 100644 index 000000000..4d46e7985 --- /dev/null +++ b/prisma/migrations/0099_v185_medication_injection_tracking/migration.sql @@ -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"[]; diff --git a/prisma/migrations/0100_v185_user_global_excluded_injection_sites/migration.sql b/prisma/migrations/0100_v185_user_global_excluded_injection_sites/migration.sql new file mode 100644 index 000000000..c6912a979 --- /dev/null +++ b/prisma/migrations/0100_v185_user_global_excluded_injection_sites/migration.sql @@ -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"[]; diff --git a/prisma/migrations/0101_v185_mood_tag_taxonomy/migration.sql b/prisma/migrations/0101_v185_mood_tag_taxonomy/migration.sql new file mode 100644 index 000000000..3afb5449f --- /dev/null +++ b/prisma/migrations/0101_v185_mood_tag_taxonomy/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af4015027..e03a08b5f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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.`). + 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.`). + 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 diff --git a/src/app/api/auth/me/injection-site-prefs/route.ts b/src/app/api/auth/me/injection-site-prefs/route.ts new file mode 100644 index 000000000..b265988f0 --- /dev/null +++ b/src/app/api/auth/me/injection-site-prefs/route.ts @@ -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); +}); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 7331f81c8..ce3a032fb 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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" }, diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index 935f1b1f7..a281f05e5 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -115,6 +115,13 @@ async function callRoute(): Promise> { return body.data.dailyCompliance as Record; } +async function callRouteData(): Promise> { + 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; +} + 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); + }); +}); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index b46d41a35..7dce9c68c 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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 = {}; @@ -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, + }); }, ); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 5984eda74..993dc1ac2 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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 diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index a899b8c5b..c1abfc1bf 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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. diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 660ddc731..6cfb38f89 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { 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 { 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 { 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 { 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; diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 77b31b80e..7e0792d2e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -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({ diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index f04616a1b..8ab1cf5e6 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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 }), diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index c68f8d21c..f0e940154 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -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, + }); }, ); diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 34cf8ee17..d15b8852c 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 5afdfba91..40a95fe20 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -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 && diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts new file mode 100644 index 000000000..a21037371 --- /dev/null +++ b/src/app/api/mood/insights/route.ts @@ -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, + 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); +}); diff --git a/src/app/api/mood/tags/route.ts b/src/app/api/mood/tags/route.ts new file mode 100644 index 000000000..6cf10e667 --- /dev/null +++ b/src/app/api/mood/tags/route.ts @@ -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 }); +}); diff --git a/src/app/globals.css b/src/app/globals.css index ea3744a81..a3e673edc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index c71a5e9fa..d87b52c80 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -25,6 +25,7 @@ export default function InsightsBlutzuckerPage() { yAxisUnit="mg/dL" emptyStateIcon={} 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?" /> ); diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index cdb451715..ce89442ef 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -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() { + + } diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index a7df175c1..0153fc9d9 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -225,6 +225,7 @@ export default function InsightsPage() { )} diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 073bf3dca..a36cb2a64 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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={ + + } + diversityNudge={ + + } + showAllValuesType="PULSE" > ` 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 ( + + + + + + ); +} diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index c150d178f..209622e39 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -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={} + diversityNudge={ + + } + showAllValuesType="WEIGHT" > ; 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; + 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 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) => ( + + ))} {types.map((type, i) => ( ; + 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(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 ( +
+
+ setTooltip((prev) => (prev?.pinned ? prev : null))} + > + {monthMarkers.map((m, i) => ( + + {m.label} + + ))} + + {!stretch && + WEEKDAY_LABELS.map( + (label, i) => + label && ( + + {label} + + ), + )} + + {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 ( + { + 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, + }); + }} + /> + ); + })} + +
+ + {tooltip && ( +
+ {tooltip.text} +
+ )} + +
+ {[ + { 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 }) => ( + +
+ {label} + + ))} +
+
+ ); +} diff --git a/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx b/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx index 4e142bf4a..3987da8a5 100644 --- a/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx @@ -20,6 +20,7 @@ function fakeSummary(count: number): DataSummary { min: null, max: null, mean: null, + median: null, avg7: null, avg30: null, slope7: null, diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index c69bfaffb..c13f477f1 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -30,6 +30,7 @@ function fakeSummary(count: number): DataSummary { min: null, max: null, mean: null, + median: null, avg7: null, avg30: null, slope7: null, diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx new file mode 100644 index 000000000..24ce1db02 --- /dev/null +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -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 — `` 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( + {node}, + ); +} + +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("", () => { + it("renders the four stat cells with min / max / median / mean", () => { + const html = render(); + 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(); + 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(); + expect(html).toBe(""); + }); + + it("renders nothing for a zero-count metric", () => { + const empty: DataSummary = { ...populated, count: 0 }; + const html = render(); + expect(html).toBe(""); + }); + + it("falls back to mean when median is absent (older payload)", () => { + const noMedian: DataSummary = { ...populated, median: null }; + const html = render(); + // 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"'); + }); +}); diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 0d3ed5aa4..0ff5bbaf9 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -4,15 +4,18 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; /** - * v1.8.0 — `` unit tests. + * v1.8.0 → v1.8.5 — `` 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("", () => { @@ -87,10 +157,35 @@ describe("", () => { 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("", () => { 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("", () => { 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(""); + }); + }); }); diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx index 7c518b0a2..490189708 100644 --- a/src/components/insights/__tests__/trends-row.test.tsx +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -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("", () => { 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( + , + ); + 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( + , + ); + 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(); + 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(); + 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( +

{componentEntries.map(({ key, label, value }) => (

  • {/* 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}