diff --git a/CHANGELOG.md b/CHANGELOG.md index f71a8c7c5..0c8dfb73e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ ## [Unreleased] +## [1.16.11] — 2026-06-12 — the supply speaks up, tags find their groups, and nothing asks twice + +### Added + +- **The app says so before the pack runs out.** A daily pass projects every tracked supply against its schedule and notifies on the configured channels when the runway falls below a per-account threshold — once per crossing, re-armed by a refill, re-announced when the threshold changes. The threshold lives beside the reminder settings (1–60 days, default 7, or off), the push deep-links straight into the supply tab, the table's stock column carries the projection as "≈ X Tage", and the card states the remaining runway once it crosses the threshold — only then. +- **Mood tags get groups, icons and a home of their own.** Custom groups with encrypted labels, a searchable 75-icon picker, and a manage page under settings; tags archive without touching the entries that carry them — editing an old entry can no longer drop its archived tags either — the picker offers inline creation right where a tag is missing, custom labels finally render in the picker AND across insights, and the arrangement persists per account. The whole surface ships in the public API contract. +- **Every due dose in one confirmed action.** When two or more doses are due — including a dose escalated past its catch-up window — a button on the medications list collects them into one confirm dialog and records each through the same path as the card buttons: slot attribution, stock consumption and every dependent surface included; failures are counted, not hidden. +- **A medication can declare itself as-needed.** No schedules, never due, never reminded, excluded from every compliance rate — but intakes log normally, consume inventory exactly like scheduled doses, and the history stays. The wizard offers it as a cadence choice; switching it onto a fixed schedule later never repaints the schedule-less stretch as missed doses, and the doctor report lists it without inventing a rate. (#316) +- **Units-per-dose and the default pack size are editable from the supply tab** — a manufacturer switch with a different blister size no longer requires walking back through the wizard. + +### Changed + +- **One wrench marks every customize entry point.** Dashboard, insights and medications share the glyph, the table corners clip to their container again, and the card/table view choice moves into its own settings section. +- **The coach opens in place.** The button brings the side drawer over the page you were reading instead of navigating away, and grew to a comfortable tap size. +- **The card's dose state lives on the next-intake row.** Take-now, late and the overdue escalation speak from the line that names the dose — with their time beside them, on mobile too — instead of a separately reserved status line that left a hole on every settled card. +- A source ladder saved before a newer integration existed now lists and ranks the later-added sources everywhere — settings, charts, rollups and the sleep picker — instead of silently pinning them below every ranked one. +- WHOOP's self-reported profile weight is no longer ingested as a measurement; the stale entry stops resurfacing as the newest weight, and existing accounts heal on the next sync. + +### Fixed + +- **The hypnogram keeps its timeline when a summary writer joins the night.** A stage summary stamping all its rows on one instant no longer evicts a per-segment night; when only summaries exist, the card shows the stage breakdown instead of a fake skyline. +- **A dose taken on the dashboard updates the dashboard.** The snapshot band refetches with every intake path instead of waiting out its polling interval. +- The supply tab can delete a container, its controls carry the outline they always implied, each container states its condition inline next to its figures, and the table's stock warning follows the user's runway threshold instead of a leftover four-dose constant. +- Every wide event carries the recent event-loop lag, and a stalled loop emits its own warning event — the instance-level signal per-request durations cannot carry. + ## [1.16.10] — 2026-06-12 — the stock follows every dose, and the list takes your order ### Added diff --git a/README.md b/README.md index 4d8f03720..bd0c053a6 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,13 @@ iOS TestFlight

-HealthLog dashboard: health metric tiles with 7-day trends and a weight chart, dark theme +HealthLog dashboard: health metric tiles with 7-day trends, blood-pressure and weight charts, dark theme ## Why HealthLog HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry. -Try the [live demo](https://demo.healthlog.dev) to see a working install. +Try the [live demo](https://demo.healthlog.dev) to see a working install — sign in with `demo` / `demo123demo123`. ## Highlights diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 64d8de8a3..21b1ac6cc 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.16.10 + version: 1.16.11 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -3564,6 +3564,49 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me/notification-prefs: + get: + tags: + - Notifications + summary: Read per-user notification preferences + description: Returns the fully-resolved preferences with the documented defaults filled in. A null persisted row + resolves to the defaults (server-managed reminders, low-stock threshold 7 days, mood reminder 22:00, Coach + nudges on). + responses: + "200": + description: Resolved preferences. + content: + application/json: + schema: + $ref: "#/components/schemas/GetNotificationPrefsResponse" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Notifications + summary: Update per-user notification preferences + description: 'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves + the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic + update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side + MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert + — null switches it off. Rate-limit 60/min per user.' + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationPrefsPatchRequest" + responses: + "200": + description: Resolved next-state preferences echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchNotificationPrefsResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/insights/layout: get: tags: @@ -4453,6 +4496,376 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/mood/tags: + get: + tags: + - Mood + summary: Effective per-user mood-tag tree (v1.8.5 / v1.13.0 / v1.17.0) + description: "The fully-resolved, ordered Category → Tag tree: seeded catalogue + the caller's custom tags and groups, + per-user layout (group order + placements) applied server-side, hidden catalogue tags omitted. `include` is a + comma list: `hidden` (hidden catalogue tags, `hidden:true`), `archived` (own inactive custom tags, + `archived:true`), `usage` (per-tag `usageCount`). With any flag set, the caller's own empty groups are kept; the + plain read drops empty categories." + parameters: + - in: query + name: include + schema: + description: Comma list of `hidden`, `archived`, `usage`. + type: string + description: Comma list of `hidden`, `archived`, `usage`. + responses: + "200": + description: The effective tag tree. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagTreeEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/mood/tags/custom: + post: + tags: + - Mood + summary: Create a custom mood tag (v1.13.0 / v1.17.0) + description: "Mints a `custom:` key, encrypts the label at rest, stores a BINARY tag owned by the caller under the + chosen group (default: the seeded `custom` category). 422 over the 50-tag cap or on an unknown / foreign + `categoryKey`." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + categoryKey: + type: string + minLength: 1 + maxLength: 80 + required: + - label + responses: + "201": + description: Custom tag created. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodCustomTagCreatedEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/mood/tags/custom/{key}: + patch: + tags: + - Mood + summary: Update a custom mood tag (v1.13.0 / v1.17.0) + description: "Owner-scoped: another user's key or a catalogue key 404s. Rename (re-encrypts), re-icon, archive/restore + via `isActive`, or move groups via `categoryKey`." + parameters: + - in: path + name: key + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + isActive: + type: boolean + categoryKey: + type: string + minLength: 1 + maxLength: 80 + responses: + "200": + description: Custom tag updated. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" + "401": *a1 + "404": &a5 + description: Not found / not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Mood + summary: Archive or purge a custom mood tag (v1.13.0) + description: "Default: soft-deactivates (`isActive:false`) — history intact, restorable via PATCH. `?purge=true`: + hard-deletes the row; the FK cascade removes its entry links. Owner-scoped 404." + parameters: + - in: path + name: key + schema: + type: string + required: true + - in: query + name: purge + schema: + type: string + enum: + - "true" + - "false" + responses: + "200": + description: Archived (or purged). + content: + application/json: + schema: + $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" + "401": *a1 + "404": *a5 + "422": *a3 + "429": *a4 + /api/mood/tags/{key}/hidden: + put: + tags: + - Mood + summary: Hide / show a catalogue mood tag (v1.13.0) + description: Per-user hide of a CATALOGUE tag (upserts / removes a `mood_tag_hidden` row). 400 on a `custom:` key — + custom tags archive via their own `isActive`. + parameters: + - in: path + name: key + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + hidden: + type: boolean + required: + - hidden + responses: + "200": + description: Visibility updated. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagHiddenEnvelope" + "400": + description: Custom-tag key — use the custom PATCH instead. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "404": *a5 + "422": *a3 + "429": *a4 + /api/mood/tags/groups: + post: + tags: + - Mood + summary: Create a custom mood-tag group (v1.17.0) + description: Mints a `customcat:` key, encrypts the label at rest, stores a group owned by the caller. 422 over + the 12-group cap. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + required: + - label + responses: + "201": + description: Custom group created. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagGroupCreatedEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/mood/tags/groups/{key}: + patch: + tags: + - Mood + summary: Update a custom mood-tag group (v1.17.0) + description: "Owner-scoped: another user's key or a seeded category key 404s. Rename (re-encrypts), re-icon, + retire/restore via `isActive`." + parameters: + - in: path + name: key + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + isActive: + type: boolean + responses: + "200": + description: Custom group updated. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" + "401": *a1 + "404": *a5 + "422": *a3 + "429": *a4 + delete: + tags: + - Mood + summary: Delete a custom mood-tag group (v1.17.0) + description: "Non-destructive: the group's custom tags re-home to the seeded `custom` category, catalogue-tag placements + evaporate back to their seeded category, the layout drops the group, then the row soft-deactivates (default) or + hard-deletes (`?purge=true`). No tag and no entry link is deleted." + parameters: + - in: path + name: key + schema: + type: string + required: true + - in: query + name: purge + schema: + type: string + enum: + - "true" + - "false" + responses: + "200": + description: Group deleted; `rehomedCount` custom tags moved. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" + "401": *a1 + "404": *a5 + "422": *a3 + "429": *a4 + /api/mood/tags/layout: + get: + tags: + - Mood + summary: Read the per-user mood-tag layout (v1.17.0) + description: "Returns the stored blob merged over defaults: `groupOrder` resolved against the caller's effective + category set (seeded + own groups), `placements` as stored." + responses: + "200": + description: Resolved layout. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagLayoutGetEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + put: + tags: + - Mood + summary: Update the per-user mood-tag layout (v1.17.0) + description: "Preserve-when-absent merge: a `groupOrder`-only PUT keeps the stored `placements` and vice versa. Bounded: + ≤ 50 groups, ≤ 400 placement entries, keys ≤ 80 chars. Returns the resolved layout." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + groupOrder: + maxItems: 50 + type: array + items: + type: string + minLength: 1 + maxLength: 80 + placements: + type: object + propertyNames: + type: string + minLength: 1 + maxLength: 80 + additionalProperties: + type: array + items: + type: string + minLength: 1 + maxLength: 80 + responses: + "200": + description: Merged + resolved layout. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagLayoutPutEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/settings/reminder-thresholds: + get: + tags: + - Notifications + summary: Read the medication reminder thresholds + description: Returns the operator-level late/missed minute thresholds that tier an open dose's status, plus the calling + user's low-stock runway threshold (days; null = alert off). One endpoint so every threshold consumer reads one + shape. Auth via cookie or Bearer. + responses: + "200": + description: Resolved thresholds. + content: + application/json: + schema: + $ref: "#/components/schemas/GetReminderThresholdsResponse" + "401": *a1 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -4808,20 +5221,26 @@ components: one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is logged (non-skipped). type: boolean + asNeeded: + description: "As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules + (supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance + rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays + active indefinitely. Mutually exclusive with `oneShot`." + type: boolean schedules: - minItems: 1 type: array items: $ref: "#/components/schemas/MedicationScheduleInput" required: - name - dose - - schedules description: 'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and - `timesOfDay` is dual-written from `windowStart` when the caller omits it.' + `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a + PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication + still requires at least one schedule entry.' MedicationScheduleInput: type: object properties: @@ -5092,6 +5511,12 @@ components: one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is logged (non-skipped). type: boolean + asNeeded: + description: "As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules + (supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance + rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays + active indefinitely. Mutually exclusive with `oneShot`." + type: boolean schedules: type: array items: @@ -5104,10 +5529,12 @@ components: minimum: 0 maximum: 1440 - type: "null" - description: Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules` + description: "Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false records the current timestamp on `pausedAt`; flipping back to true clears it. v1.5 invariants - on the `schedules` array match the create path. + on the `schedules` array match the create path. v1.16.11 — `asNeeded: true` requires the medication to end + schedule-less: supply `schedules: []` to clear an existing plan (any schedule entry is a 422); an empty + `schedules` array without `asNeeded: true` is likewise a 422." MedicationIntakeRequest: type: object properties: @@ -5387,6 +5814,57 @@ components: - type: "null" description: Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or empty string) clears it. `userId` is never accepted — it is narrowed from the session/token. + NotificationPrefsPatchRequest: + type: object + properties: + medication: + type: object + properties: + clientManaged: + type: boolean + deliveryDefault: + type: string + enum: + - server + - client + lowStockRunwayDays: + anyOf: + - type: integer + minimum: 1 + maximum: 60 + - type: "null" + mood: + type: object + properties: + reminderHour: + type: integer + minimum: 0 + maximum: 23 + cycle: + type: object + properties: + clientManaged: + type: boolean + coach: + type: object + properties: + nudgesEnabled: + type: boolean + nudgeMedication: + type: boolean + nudgeVitals: + type: boolean + nudgeRoutine: + type: boolean + nudgeFrequency: + type: string + enum: + - weekly + - biweekly + description: Partial per-user notification preferences. Every category and field is optional; the server deep-merges the + supplied keys over the persisted row, so a PATCH touching only `medication` leaves the siblings intact. + `medication.lowStockRunwayDays` (1–60, nullable) is the low-stock alert threshold in remaining runway days — + `null` switches the alert off. InsightsLayoutBody: type: object properties: @@ -8650,6 +9128,12 @@ components: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged. + asNeeded: + type: boolean + description: "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on + any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and + excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes + per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`." createdAt: type: string format: date-time @@ -8716,6 +9200,7 @@ components: - startsOn - endsOn - oneShot + - asNeeded - createdAt - updatedAt - schedules @@ -8862,6 +9347,12 @@ components: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged. + asNeeded: + type: boolean + description: "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on + any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and + excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes + per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`." createdAt: type: string format: date-time @@ -8896,6 +9387,7 @@ components: - startsOn - endsOn - oneShot + - asNeeded - createdAt - updatedAt - schedules @@ -11424,6 +11916,124 @@ components: additionalProperties: false description: Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value. + GetNotificationPrefsResponse: + type: object + properties: + data: + $ref: "#/components/schemas/NotificationPrefs" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NotificationPrefs: + type: object + properties: + medication: + type: object + properties: + clientManaged: + type: boolean + description: True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is + suppressed. + deliveryDefault: + type: string + enum: + - server + - client + description: 'Roaming user-level delivery default. "client" implies clientManaged: true.' + lowStockRunwayDays: + anyOf: + - type: integer + minimum: 1 + maximum: 60 + - type: "null" + description: Low-stock alert threshold as remaining runway days (1–60). null = alert off. Default 7. + required: + - clientManaged + - deliveryDefault + - lowStockRunwayDays + additionalProperties: false + mood: + type: object + properties: + reminderHour: + type: integer + minimum: 0 + maximum: 23 + description: Local-time hour for the daily mood reminder. Default 22. + required: + - reminderHour + additionalProperties: false + cycle: + type: object + properties: + clientManaged: + type: boolean + description: True when the iOS app owns local cycle reminders; the server-side cycle APNs cron is suppressed. + required: + - clientManaged + additionalProperties: false + coach: + type: object + properties: + nudgesEnabled: + type: boolean + description: Master switch for the proactive Coach nudge cron. + nudgeMedication: + type: boolean + description: Medication-group nudge triggers (compliance). + nudgeVitals: + type: boolean + description: Vitals-group nudge triggers (bp / score / weight / sleep). + nudgeRoutine: + type: boolean + description: Routine-group nudge triggers (measurement gap / self-context). + nudgeFrequency: + type: string + enum: + - weekly + - biweekly + description: "Nudge frequency cap: one per 7 or per 14 days." + required: + - nudgesEnabled + - nudgeMedication + - nudgeVitals + - nudgeRoutine + - nudgeFrequency + additionalProperties: false + required: + - medication + - mood + - cycle + - coach + additionalProperties: false + description: Fully-resolved per-user notification preferences. Every key is present — a null / drifted persisted row + resolves to the documented defaults. + PatchNotificationPrefsResponse: + type: object + properties: + data: + $ref: "#/components/schemas/NotificationPrefs" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false InsightsLayout: type: object properties: @@ -13213,6 +13823,508 @@ components: - interpretation - lagDays additionalProperties: false + MoodTagTreeEnvelope: + type: object + properties: + data: + type: object + properties: + categories: + type: array + items: + $ref: "#/components/schemas/MoodTagCategoryDTO" + required: + - categories + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagCategoryDTO: + type: object + properties: + key: + type: string + labelKey: + anyOf: + - type: string + - type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + custom: + type: boolean + tags: + type: array + items: + $ref: "#/components/schemas/MoodTagDTO" + required: + - key + - labelKey + - label + - icon + - custom + - tags + additionalProperties: false + description: "One group of the effective tree, already per-user ordered (layout applied server-side). `custom: true` = + the caller's own group (render `label`); seeded groups resolve `labelKey`." + MoodTagDTO: + type: object + properties: + key: + type: string + labelKey: + anyOf: + - type: string + - type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + kind: + type: string + enum: + - BINARY + - RATED + scaleMin: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + scaleMax: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + inverse: + type: boolean + custom: + type: boolean + hidden: + description: Catalogue tags only, present when `include` contains `hidden`. + type: boolean + archived: + description: Custom tags only, present when `include` contains `archived`. + type: boolean + usageCount: + description: Live-entry link count for this user, present when `include` contains `usage`. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - key + - labelKey + - label + - icon + - kind + - scaleMin + - scaleMax + - inverse + - custom + additionalProperties: false + description: One effective mood tag. Render `label` when `custom`, else resolve `labelKey` against the locale. + MoodCustomTagCreatedEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + labelKey: + type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + kind: + type: string + enum: + - BINARY + - RATED + scaleMin: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + scaleMax: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + inverse: + type: boolean + custom: + type: boolean + const: true + required: + - key + - labelKey + - label + - icon + - kind + - scaleMin + - scaleMax + - inverse + - custom + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodCustomTagPatchEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + labelKey: + type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + kind: + type: string + enum: + - BINARY + - RATED + scaleMin: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + scaleMax: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + inverse: + type: boolean + custom: + type: boolean + const: true + isActive: + type: boolean + required: + - key + - labelKey + - label + - icon + - kind + - scaleMin + - scaleMax + - inverse + - custom + - isActive + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodCustomTagDeleteEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + purged: + type: boolean + required: + - key + - purged + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagHiddenEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + hidden: + type: boolean + required: + - key + - hidden + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagGroupCreatedEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + labelKey: + type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + custom: + type: boolean + const: true + required: + - key + - labelKey + - label + - icon + - custom + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagGroupPatchEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + labelKey: + type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + custom: + type: boolean + const: true + isActive: + type: boolean + required: + - key + - labelKey + - label + - icon + - custom + - isActive + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagGroupDeleteEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + purged: + type: boolean + rehomedCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - key + - purged + - rehomedCount + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagLayoutGetEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MoodTagLayoutResolved" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagLayoutResolved: + type: object + properties: + groupOrder: + type: array + items: + type: string + placements: + type: object + propertyNames: + type: string + additionalProperties: + type: array + items: + type: string + required: + - groupOrder + - placements + additionalProperties: false + description: "The stored layout merged over defaults: `groupOrder` fully resolved against the user's effective category + set, `placements` as stored." + MoodTagLayoutPutEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MoodTagLayoutResolved" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetReminderThresholdsResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ReminderThresholdsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ReminderThresholdsResponse: + type: object + properties: + lateMinutes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Minutes after the dose anchor at which an open dose tiers 'late' (operator-level singleton; default 120). + missedMinutes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Minutes after the dose anchor at which an open dose tiers 'missed' (operator-level singleton; default 240). + lowStockRunwayDays: + anyOf: + - type: integer + minimum: 1 + maximum: 60 + - type: "null" + description: Per-user low-stock alert threshold as remaining runway days (1–60). null = the alert is off. Default 7. + Written via PATCH /api/auth/me/notification-prefs. + required: + - lateMinutes + - missedMinutes + - lowStockRunwayDays + additionalProperties: false + description: "Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's + low-stock runway threshold (v1.16.11)." MedicationScheduleOutput: type: object properties: @@ -13582,6 +14694,12 @@ components: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged. + asNeeded: + type: boolean + description: "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on + any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and + excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes + per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`." createdAt: type: string format: date-time @@ -13614,6 +14732,7 @@ components: - startsOn - endsOn - oneShot + - asNeeded - createdAt - updatedAt - schedules diff --git a/docs/assets/dashboard-hero.jpeg b/docs/assets/dashboard-hero.jpeg new file mode 100755 index 000000000..26fd5be3e Binary files /dev/null and b/docs/assets/dashboard-hero.jpeg differ diff --git a/docs/assets/dashboard-hero.png b/docs/assets/dashboard-hero.png deleted file mode 100644 index f065f336c..000000000 Binary files a/docs/assets/dashboard-hero.png and /dev/null differ diff --git a/e2e/ai-provider-dropdown.spec.ts b/e2e/ai-provider-dropdown.spec.ts index ff19ab433..757ca1d55 100644 --- a/e2e/ai-provider-dropdown.spec.ts +++ b/e2e/ai-provider-dropdown.spec.ts @@ -80,7 +80,15 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => { }), ); + // Hydration gate — the provider query is fired by the same client + // boundary that renders the select, so its response proves React + // has hydrated the card and attached the select's change handler. + // `selectOption` on the SSR-painted element any earlier flips the + // native value without React noticing, and the config form below + // never switches (CI failure at slow hydration). + const cardHydrated = page.waitForResponse("**/api/user/ai-provider"); await page.goto("/settings/ai", { waitUntil: "domcontentloaded" }); + await cardHydrated; // 1. Dropdown surfaces on the page. const select = page.getByTestId("ai-active-provider-select"); diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index cabecdadb..62177edf9 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -19,8 +19,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * bottom-sheet (`data-variant="bottom-sheet"`) on `=sm`. * - * 3. With an unread nudge the FAB carries the dot and clicking it - * navigates to the full-page chat at `/insights/coach`. + * 3. With an unread nudge the FAB carries the dot; clicking the FAB + * opens the Coach drawer in place (v1.16.11 — no navigation, the + * page underneath stays) and the dot clears. * * 4. The drawer's "Conversations" button no longer opens an in-panel * tray; it hands off to the full-page route `/insights/coach`. @@ -177,7 +178,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { "drawer bottom-sheet branch is mobile-only", ); + // Hydration gate — the status query is fired by the sub-page + // boundary itself, so its response proves React has hydrated the + // page and attached the launch icon's click handler. Clicking the + // SSR-painted icon any earlier is silently lost (CI flake). + const pageHydrated = page.waitForResponse( + /\/api\/insights\/blood-pressure-status/, + ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); + await pageHydrated; const icon = page.locator('[data-slot="coach-launch-icon"]').first(); await expect(icon).toBeVisible({ timeout: 10_000 }); @@ -196,7 +205,12 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { "drawer side-sheet branch is desktop-only", ); + // Hydration gate — see the bottom-sheet branch above. + const pageHydrated = page.waitForResponse( + /\/api\/insights\/blood-pressure-status/, + ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); + await pageHydrated; const icon = page.locator('[data-slot="coach-launch-icon"]').first(); await expect(icon).toBeVisible({ timeout: 10_000 }); @@ -207,9 +221,9 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await expect(drawer).toHaveAttribute("data-variant", "side-sheet"); }); - test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the full-page chat", async ({ + test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the Coach drawer in place", async ({ page, - }) => { + }, testInfo) => { // The FAB contract is viewport-independent — the test runs on // both projects. Override the default nudge mock with an unread // one (later registration wins). @@ -224,7 +238,14 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { }), ); + // Hydration gate — the nudge-status query is fired by the FAB + // component itself, so its response proves the FAB's click handler + // is attached (the button paints from SSR HTML long before that). + const fabHydrated = page.waitForResponse( + /\/api\/insights\/coach\/nudge-status/, + ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); + await fabHydrated; const fab = page.locator('[data-slot="coach-fab"]'); await expect(fab).toBeVisible({ timeout: 10_000 }); @@ -236,10 +257,24 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { ).toBeVisible(); await fab.click(); - await page.waitForURL("**/insights/coach", { timeout: 10_000 }); - await expect( - page.locator('[data-slot="coach-page"]'), - ).toBeVisible({ timeout: 10_000 }); + // v1.16.11 — the FAB opens the side drawer in place instead of + // navigating to `/insights/coach`; the page underneath stays. + const drawer = page.locator('[data-slot="coach-drawer"]'); + await expect(drawer).toBeVisible({ timeout: 10_000 }); + await expect(drawer).toHaveAttribute( + "data-variant", + testInfo.project.name === "chromium-mobile" + ? "bottom-sheet" + : "side-sheet", + ); + expect(new URL(page.url()).pathname).not.toContain("/insights/coach"); + + // Opening the Coach counts as reading the nudge on this device — + // the dot clears and the unread flag drops off the FAB. + await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount( + 0, + ); + await expect(fab).not.toHaveAttribute("data-unread", "true"); }); test("the drawer's Conversations button hands off to the full-page chat on Desktop Chrome", async ({ @@ -253,7 +288,12 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { "exercises the desktop drawer layout", ); + // Hydration gate — see the bottom-sheet test above. + const pageHydrated = page.waitForResponse( + /\/api\/insights\/blood-pressure-status/, + ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); + await pageHydrated; const icon = page.locator('[data-slot="coach-launch-icon"]').first(); await expect(icon).toBeVisible({ timeout: 10_000 }); diff --git a/messages/de.json b/messages/de.json index 3dcafb78a..078258bfa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -664,7 +664,58 @@ "bulkDeleteSuccess": "{count} Einträge gelöscht", "bulkDeleteError": "Fehler beim Löschen der ausgewählten Einträge", "bulkDeleteConfirmTitle": "{count} Einträge löschen?", - "bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden endgültig gelöscht." + "bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden endgültig gelöscht.", + "customize": "Stimmungs-Tags anpassen", + "addTagInline": "Tag hinzufügen", + "manage": { + "groupsTitle": "Gruppen", + "groupsDescription": "Lege eigene Gruppen an und bestimme ihre Reihenfolge in der Auswahl.", + "newGroup": "Neue Gruppe", + "groupLabel": "Gruppenname", + "groupIcon": "Symbol", + "groupCreated": "Gruppe erstellt", + "groupRenamed": "Gruppe gespeichert", + "groupDeleteTitle": "Gruppe löschen?", + "groupDeleteBody": "Tags aus dieser Gruppe wandern zurück in ihre ursprüngliche Gruppe. Es werden keine Einträge gelöscht.", + "groupDeleted": "Gruppe gelöscht", + "groupEmpty": "Noch keine eigenen Gruppen.", + "groupLimitReached": "Gruppen-Limit erreicht", + "tagsTitle": "Tags", + "tagsDescription": "Blende Tags aus, sortiere und verschiebe sie oder bearbeite sie.", + "newTag": "Neuer Tag", + "tagLabel": "Tag-Name", + "tagIcon": "Symbol", + "tagGroup": "Gruppe", + "usageCount": "{count} Einträge", + "hideTag": "Tag ausblenden", + "showTag": "Tag einblenden", + "moveToGroup": "In Gruppe verschieben", + "archive": "Archivieren", + "restore": "Wiederherstellen", + "archivedTitle": "Archivierte Tags", + "archivedDescription": "Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten – sie verschwinden nur aus der Auswahl.", + "archivedEmpty": "Nichts archiviert.", + "purgeTitle": "Tag endgültig löschen?", + "purgeBody": "Der Tag wird aus {count} bisherigen Einträgen entfernt. Das lässt sich nicht rückgängig machen.", + "purgeAction": "Endgültig löschen", + "purged": "Tag gelöscht", + "tagLimitReached": "Tag-Limit erreicht", + "iconPickerTitle": "Symbol wählen", + "iconSearchPlaceholder": "Symbole durchsuchen…", + "iconSearchEmpty": "Kein passendes Symbol.", + "iconSearchClear": "Suche leeren", + "orderSave": "Reihenfolge speichern", + "orderSaved": "Reihenfolge gespeichert", + "moveUp": "Nach oben", + "moveDown": "Nach unten", + "iconGroupEmotions": "Gefühle", + "iconGroupActivities": "Aktivitäten", + "iconGroupHealth": "Gesundheit", + "iconGroupFood": "Essen & Trinken", + "iconGroupWeather": "Wetter", + "iconGroupPlaces": "Orte", + "iconGroupMisc": "Sonstiges" + } }, "medications": { "title": "Medikamente", @@ -678,6 +729,15 @@ "logIntake": "Einnahme erfassen", "newMedication": "Neues Medikament" }, + "takeAllDue": { + "button": "Alle fälligen einnehmen", + "dialogTitle": "Fällige Medikamente einnehmen", + "dialogDescription": "Diese Medikamente sind jetzt fällig. Bestätige, um sie alle als genommen zu erfassen.", + "confirm": "Alle einnehmen ({count})", + "successToast": "{count} Einnahmen erfasst", + "partialToast": "{taken} erfasst, {failed} fehlgeschlagen – bitte erneut versuchen", + "failedToast": "Keine Einnahme erfasst – bitte erneut versuchen" + }, "logIntake": { "dialogTitle": "Einnahme erfassen", "dialogDescription": "Erfasse eine Dosis für ein vorhandenes Medikament. Du kannst sie auf ein vergangenes Datum und eine vergangene Uhrzeit zurückdatieren.", @@ -760,6 +820,7 @@ "today": "Heute", "yesterday": "Gestern", "tomorrow": "Morgen", + "asNeededMarker": "Bei Bedarf", "withoutNotification": "Ohne Benachrichtigung", "pausedSince": "Pausiert seit", "takeNow": "Jetzt einnehmen", @@ -1147,6 +1208,10 @@ "oneShot": { "label": "Einmalig", "description": "Einzelgabe, zum Beispiel eine Impfung oder ein Antibiotikum." + }, + "asNeeded": { + "label": "Bei Bedarf", + "description": "Kein fester Plan — du erfasst eine Einnahme, wenn du sie brauchst (z. B. Schmerzmittel)." } }, "summary": { @@ -1160,6 +1225,7 @@ "yearly": "Einmal im Jahr", "rolling": "Alle {n} Tage seit der letzten Einnahme", "oneShot": "Eine Einmaldosis", + "asNeeded": "Bei Bedarf", "everyNWeeks": "Alle {n} Wochen", "everyNMonths": "Alle {n} Monate" }, @@ -1345,7 +1411,8 @@ }, "cadence": { "oneShotOn": "Einmalige Dosis am {date}.", - "oneShotPending": "Einmalige Dosis ausstehend." + "oneShotPending": "Einmalige Dosis ausstehend.", + "asNeeded": "Bei Bedarf — kein fester Plan." }, "intake": { "title": "Einnahmeverlauf", @@ -1574,6 +1641,7 @@ "statusTitle": "Status", "nextDoseLabel": "Nächste Dosis", "nextDoseNone": "Keine anstehende Dosis.", + "nextDoseAsNeeded": "Bei Bedarf — keine geplante Dosis.", "pausedHint": "Pausiert — keine Erinnerungen.", "reminderLabel": "Erinnerung", "reminderOn": "Aktiv", @@ -1581,6 +1649,7 @@ "supplyLabel": "Bestand", "supplyRunway": "Reicht noch ca. {days} Tage", "jumpToZeitplan": "Zum Zeitplan", + "jumpToVerlauf": "Zum Verlauf", "jumpToBestand": "Zum Bestand", "complianceTitle": "Therapietreue", "complianceEmpty": "Noch keine Daten zur Therapietreue." @@ -1600,6 +1669,8 @@ "emptyHelper": "Erfasse eine Packung oder einen Behälter, um den verbleibenden Vorrat und die Reichweite zu verfolgen.", "emptyCta": "Packung erfassen", "addButton": "Erfassen", + "lowStockLinkOn": "Benachrichtigung bei niedrigem Bestand: unter {days} Tagen", + "lowStockLinkOff": "Benachrichtigung bei niedrigem Bestand: aus", "addTitle": "Packung oder Behälter erfassen", "addDescription": "Lege einen neuen Vorrat mit seiner Einheitenanzahl an; das Ablaufdatum ist optional.", "addQuantityLabel": "Menge (Einheiten)", @@ -1616,6 +1687,10 @@ "adjustSubmit": "Speichern", "adjustSuccess": "Bestand aktualisiert.", "adjustFailed": "Bestand konnte nicht aktualisiert werden.", + "deleteTitle": "Packung löschen?", + "deleteDescription": "Entfernt diese Packung oder diesen Behälter dauerhaft aus dem Bestand. Protokollierte Einnahmen bleiben unverändert.", + "deleteSuccess": "Bestand gelöscht.", + "deleteFailed": "Bestand konnte nicht gelöscht werden.", "containerTypeLabel": "Behälterart", "containerType": { "PEN": "Pen", @@ -1631,7 +1706,19 @@ "quantityInUnits": "= {units} Einheiten", "quantityInDoses": "≈ {doses} Dosen", "quantityUnderOneDose": "< 1 Dosis", - "expiredSuffix": "+ {units} Einheiten abgelaufen" + "expiredSuffix": "+ {units} Einheiten abgelaufen", + "packagingTitle": "Packung & Dosis", + "packagingUnitsPerDose": "1 Dosis = {units} Einheiten", + "packagingDefaultPack": "Standard-Packungsgröße: {units} Einheiten", + "packagingNoDefaultPack": "Keine Standard-Packungsgröße hinterlegt", + "packagingEdit": "Bearbeiten", + "packagingDescription": "Lege fest, wie viele Einheiten eine Dosis verbraucht und wie groß eine neue Packung üblicherweise ist — z. B. nach einem Herstellerwechsel mit anderer Blistergröße.", + "packagingUnitsPerDoseLabel": "Einheiten pro Dosis", + "packagingUnitsPerDoseHelper": "Z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis (1–100). Bereits erfasste Einnahmen bleiben unverändert.", + "packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)", + "packagingDefaultPackHelper": "Vorbefüllung beim Erfassen neuer Packungen (1–1000). Leer lassen, wenn keine üblich ist.", + "packagingSuccess": "Packungsangaben gespeichert", + "packagingFailed": "Speichern fehlgeschlagen" }, "erweitert": { "group": { @@ -1733,6 +1820,8 @@ "tableComplianceDays": "{days} T", "tableStockDoseOne": "1 Dosis", "tableStockDoses": "{count} Dosen", + "tableStockRunway": "≈ {days} Tage übrig", + "cardLowStockRunway": "Vorrat: ≈ {days} Tage", "takeActionFor": "Einnehmen – {name}", "skipActionFor": "Überspringen – {name}", "reorderTitle": "Reihenfolge anpassen", @@ -3773,6 +3862,10 @@ "medications": { "title": "Medikamente", "description": "Listenansicht und Reihenfolge der Medikamente." + }, + "mood": { + "title": "Stimmungs-Tags", + "description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl." } }, "profile": "Profil", @@ -5297,6 +5390,20 @@ "disabledToast": "Proaktive Coach-Hinweise deaktiviert.", "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." }, + "lowStock": { + "title": "Vorratswarnung", + "description": "Prüft deine erfassten Medikamentenvorräte einmal täglich und benachrichtigt dich, wenn einer weniger Tage abdeckt als diese Schwelle. Eine Benachrichtigung pro Engpass; nach dem Auffüllen meldet sie sich wieder.", + "toggleAria": "Vorratswarnungen umschalten", + "statusOn": "Aktiv", + "statusOff": "Aus", + "daysLabel": "Warnen unter (Tage)", + "daysAria": "Vorratsschwelle in Tagen wählen", + "savedToast": "Vorratsschwelle gespeichert.", + "enabledToast": "Vorratswarnungen aktiviert.", + "disabledToast": "Vorratswarnungen deaktiviert.", + "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.", + "medicationsLink": "Zu deinen Medikamenten" + }, "eventMedicationReminder": "Medikamenten-Erinnerung", "eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.", "eventMeasurementAnomaly": "Auffälliger Messwert", @@ -5316,7 +5423,9 @@ "eventCyclePeriodConfirm": "Hat deine Periode begonnen?", "eventCyclePeriodConfirmDesc": "Eine sanfte Nachfrage am oder nach dem vorhergesagten Beginn, solange keine Periode erfasst ist.", "eventCycleFertileSoon": "Fruchtbares Fenster steht bevor", - "eventCycleFertileSoonDesc": "Wird einige Tage vor deinem geschätzten fruchtbaren Fenster gesendet, wenn dein Ziel die Empfängnis ist." + "eventCycleFertileSoonDesc": "Wird einige Tage vor deinem geschätzten fruchtbaren Fenster gesendet, wenn dein Ziel die Empfängnis ist.", + "eventMedicationLowStock": "Medikamentenvorrat niedrig", + "eventMedicationLowStockDesc": "Wenn ein erfasster Vorrat weniger Tage abdeckt als deine Vorratsschwelle" }, "i18n": { "maintainershipBanner": { @@ -5479,6 +5588,11 @@ "buttonSnoozeThreeHours": "🕐 3h", "buttonSkip": "⏭ Überspringen" }, + "lowStockReminders": { + "title": "Vorrat niedrig: {medName}", + "body": "Dein Vorrat an {medName} reicht noch etwa {days} Tage ({units} Einheiten übrig).", + "bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig)." + }, "moodReminders": { "dailyTitle": "Stimmung erfassen", "dailyBody": "Wie geht es dir heute?" diff --git a/messages/en.json b/messages/en.json index a9ea8f561..dff86db90 100644 --- a/messages/en.json +++ b/messages/en.json @@ -664,7 +664,58 @@ "bulkDeleteSuccess": "{count} entries deleted", "bulkDeleteError": "Error deleting the selected entries", "bulkDeleteConfirmTitle": "Delete {count} entries?", - "bulkDeleteConfirmBody": "The {count} selected mood entries will be permanently deleted." + "bulkDeleteConfirmBody": "The {count} selected mood entries will be permanently deleted.", + "customize": "Customize mood tags", + "addTagInline": "Add tag", + "manage": { + "groupsTitle": "Groups", + "groupsDescription": "Create your own groups and set the order the picker shows them in.", + "newGroup": "New group", + "groupLabel": "Group name", + "groupIcon": "Icon", + "groupCreated": "Group created", + "groupRenamed": "Group saved", + "groupDeleteTitle": "Delete group?", + "groupDeleteBody": "Tags in this group move back to their original group. No entries are deleted.", + "groupDeleted": "Group deleted", + "groupEmpty": "No own groups yet.", + "groupLimitReached": "Group limit reached", + "tagsTitle": "Tags", + "tagsDescription": "Hide, rearrange, move, or edit the tags the picker shows.", + "newTag": "New tag", + "tagLabel": "Tag name", + "tagIcon": "Icon", + "tagGroup": "Group", + "usageCount": "{count} entries", + "hideTag": "Hide tag", + "showTag": "Show tag", + "moveToGroup": "Move to group", + "archive": "Archive", + "restore": "Restore", + "archivedTitle": "Archived tags", + "archivedDescription": "Archived tags stay in your past entries — they only disappear from the picker.", + "archivedEmpty": "Nothing archived.", + "purgeTitle": "Delete tag permanently?", + "purgeBody": "This removes the tag from {count} past entries. This cannot be undone.", + "purgeAction": "Delete permanently", + "purged": "Tag deleted", + "tagLimitReached": "Tag limit reached", + "iconPickerTitle": "Choose an icon", + "iconSearchPlaceholder": "Search icons…", + "iconSearchEmpty": "No icon matches.", + "iconSearchClear": "Clear search", + "orderSave": "Save order", + "orderSaved": "Order saved", + "moveUp": "Move up", + "moveDown": "Move down", + "iconGroupEmotions": "Emotions", + "iconGroupActivities": "Activities", + "iconGroupHealth": "Health", + "iconGroupFood": "Food & drink", + "iconGroupWeather": "Weather", + "iconGroupPlaces": "Places", + "iconGroupMisc": "Other" + } }, "medications": { "title": "Medications", @@ -678,6 +729,15 @@ "logIntake": "Log an intake", "newMedication": "New medication" }, + "takeAllDue": { + "button": "Take all due", + "dialogTitle": "Take due medications", + "dialogDescription": "These medications are due now. Confirm to record all of them as taken.", + "confirm": "Take all ({count})", + "successToast": "{count} intakes recorded", + "partialToast": "{taken} recorded, {failed} failed – please try again", + "failedToast": "No intake recorded – please try again" + }, "logIntake": { "dialogTitle": "Log an intake", "dialogDescription": "Record a dose against an existing medication. You can backdate it to a past date and time.", @@ -760,6 +820,7 @@ "today": "Today", "yesterday": "Yesterday", "tomorrow": "Tomorrow", + "asNeededMarker": "As needed", "withoutNotification": "Without notification", "pausedSince": "Paused since", "takeNow": "Take now", @@ -1147,6 +1208,10 @@ "oneShot": { "label": "Single dose", "description": "One administration, e.g. a vaccine or an antibiotic course." + }, + "asNeeded": { + "label": "As needed", + "description": "No fixed plan — log an intake whenever you need one (e.g. pain relief)." } }, "summary": { @@ -1160,6 +1225,7 @@ "yearly": "Once a year", "rolling": "Every {n} days from your last intake", "oneShot": "A single dose", + "asNeeded": "As needed", "everyNWeeks": "Every {n} weeks", "everyNMonths": "Every {n} months" }, @@ -1345,7 +1411,8 @@ }, "cadence": { "oneShotOn": "One-time dose on {date}.", - "oneShotPending": "One-time dose pending." + "oneShotPending": "One-time dose pending.", + "asNeeded": "As needed — no fixed plan." }, "intake": { "title": "Intake history", @@ -1574,6 +1641,7 @@ "statusTitle": "Status", "nextDoseLabel": "Next dose", "nextDoseNone": "No upcoming dose.", + "nextDoseAsNeeded": "As needed — no planned dose.", "pausedHint": "Paused — no reminders.", "reminderLabel": "Reminder", "reminderOn": "On", @@ -1581,6 +1649,7 @@ "supplyLabel": "Supply", "supplyRunway": "Lasts about {days} more days", "jumpToZeitplan": "Open schedule", + "jumpToVerlauf": "Open history", "jumpToBestand": "Open supply", "complianceTitle": "Adherence", "complianceEmpty": "No adherence data yet." @@ -1600,6 +1669,8 @@ "emptyHelper": "Register a pack or container to track remaining supply and runway.", "emptyCta": "Register a pack", "addButton": "Register", + "lowStockLinkOn": "Low-stock notification: below {days} days", + "lowStockLinkOff": "Low-stock notification: off", "addTitle": "Register a pack or container", "addDescription": "Record a new supply with its unit count; the expiry date is optional.", "addQuantityLabel": "Quantity (units)", @@ -1616,6 +1687,10 @@ "adjustSubmit": "Save", "adjustSuccess": "Stock updated.", "adjustFailed": "The stock could not be updated.", + "deleteTitle": "Delete container?", + "deleteDescription": "Permanently removes this pack or container from the stock. Logged intakes stay unchanged.", + "deleteSuccess": "Stock entry deleted.", + "deleteFailed": "The stock entry could not be deleted.", "containerTypeLabel": "Container type", "containerType": { "PEN": "Pen", @@ -1631,7 +1706,19 @@ "quantityInUnits": "= {units} units", "quantityInDoses": "≈ {doses} doses", "quantityUnderOneDose": "< 1 dose", - "expiredSuffix": "+ {units} units expired" + "expiredSuffix": "+ {units} units expired", + "packagingTitle": "Packaging & dose", + "packagingUnitsPerDose": "1 dose = {units} units", + "packagingDefaultPack": "Default pack size: {units} units", + "packagingNoDefaultPack": "No default pack size set", + "packagingEdit": "Edit", + "packagingDescription": "Set how many units one dose consumes and how large a new pack usually is — e.g. after a manufacturer switch with a different blister size.", + "packagingUnitsPerDoseLabel": "Units per dose", + "packagingUnitsPerDoseHelper": "E.g. two 2 mg tablets for a 4 mg dose (1–100). Doses already recorded stay unchanged.", + "packagingDefaultPackLabel": "Default pack size (units)", + "packagingDefaultPackHelper": "Prefills the quantity when registering new packs (1–1000). Leave empty if none applies.", + "packagingSuccess": "Packaging details saved", + "packagingFailed": "Saving failed" }, "erweitert": { "group": { @@ -1733,6 +1820,8 @@ "tableComplianceDays": "{days} d", "tableStockDoseOne": "1 dose", "tableStockDoses": "{count} doses", + "tableStockRunway": "≈ {days} days left", + "cardLowStockRunway": "Supply: ≈ {days} days", "takeActionFor": "Take – {name}", "skipActionFor": "Skip – {name}", "reorderTitle": "Adjust order", @@ -3773,6 +3862,10 @@ "medications": { "title": "Medications", "description": "List view and medication order." + }, + "mood": { + "title": "Mood tags", + "description": "Groups, tags, and order of the mood picker." } }, "profile": "Profile", @@ -5297,6 +5390,20 @@ "disabledToast": "Proactive Coach nudges disabled.", "saveError": "Could not save. Please try again later." }, + "lowStock": { + "title": "Low-stock alert", + "description": "Checks your tracked medication supplies once a day and notifies you when one covers fewer days than this threshold. One notification per shortage; it re-arms after a refill.", + "toggleAria": "Toggle low-stock alerts", + "statusOn": "Active", + "statusOff": "Off", + "daysLabel": "Notify below (days)", + "daysAria": "Select the low-stock threshold in days", + "savedToast": "Low-stock threshold saved.", + "enabledToast": "Low-stock alerts enabled.", + "disabledToast": "Low-stock alerts disabled.", + "saveError": "Could not save. Please try again.", + "medicationsLink": "Go to your medications" + }, "eventMedicationReminder": "Medication reminder", "eventMedicationReminderDesc": "Sent at each scheduled dose time.", "eventMeasurementAnomaly": "Unusual measurement", @@ -5316,7 +5423,9 @@ "eventCyclePeriodConfirm": "Did your period start?", "eventCyclePeriodConfirmDesc": "A gentle check-in on or after the predicted start while no period is logged.", "eventCycleFertileSoon": "Fertile window coming up", - "eventCycleFertileSoonDesc": "Sent a few days before your estimated fertile window when your goal is trying to conceive." + "eventCycleFertileSoonDesc": "Sent a few days before your estimated fertile window when your goal is trying to conceive.", + "eventMedicationLowStock": "Medication stock low", + "eventMedicationLowStockDesc": "When a tracked medication supply covers fewer days than your low-stock threshold" }, "i18n": { "maintainershipBanner": { @@ -5479,6 +5588,11 @@ "buttonSnoozeThreeHours": "🕐 3h", "buttonSkip": "⏭ Skip" }, + "lowStockReminders": { + "title": "Low supply: {medName}", + "body": "{medName}: {units} units left — enough for about {days} more days.", + "bodyToday": "{medName}: {units} units left — less than one day of supply." + }, "moodReminders": { "dailyTitle": "Log your mood", "dailyBody": "How are you feeling today?" diff --git a/messages/es.json b/messages/es.json index 63018e39e..67341a39b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -664,7 +664,58 @@ "bulkDeleteSuccess": "{count} entradas eliminadas", "bulkDeleteError": "Error al eliminar las entradas seleccionadas", "bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?", - "bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán de forma permanente." + "bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán de forma permanente.", + "customize": "Personalizar etiquetas de ánimo", + "addTagInline": "Añadir etiqueta", + "manage": { + "groupsTitle": "Grupos", + "groupsDescription": "Crea tus propios grupos y define su orden en el selector.", + "newGroup": "Nuevo grupo", + "groupLabel": "Nombre del grupo", + "groupIcon": "Icono", + "groupCreated": "Grupo creado", + "groupRenamed": "Grupo guardado", + "groupDeleteTitle": "¿Eliminar grupo?", + "groupDeleteBody": "Las etiquetas de este grupo vuelven a su grupo original. No se elimina ninguna entrada.", + "groupDeleted": "Grupo eliminado", + "groupEmpty": "Aún no hay grupos propios.", + "groupLimitReached": "Límite de grupos alcanzado", + "tagsTitle": "Etiquetas", + "tagsDescription": "Oculta, reordena, mueve o edita las etiquetas del selector.", + "newTag": "Nueva etiqueta", + "tagLabel": "Nombre de la etiqueta", + "tagIcon": "Icono", + "tagGroup": "Grupo", + "usageCount": "{count} entradas", + "hideTag": "Ocultar etiqueta", + "showTag": "Mostrar etiqueta", + "moveToGroup": "Mover a grupo", + "archive": "Archivar", + "restore": "Restaurar", + "archivedTitle": "Etiquetas archivadas", + "archivedDescription": "Las etiquetas archivadas permanecen en tus entradas anteriores; solo desaparecen del selector.", + "archivedEmpty": "Nada archivado.", + "purgeTitle": "¿Eliminar etiqueta definitivamente?", + "purgeBody": "Esto elimina la etiqueta de {count} entradas anteriores. No se puede deshacer.", + "purgeAction": "Eliminar definitivamente", + "purged": "Etiqueta eliminada", + "tagLimitReached": "Límite de etiquetas alcanzado", + "iconPickerTitle": "Elegir icono", + "iconSearchPlaceholder": "Buscar iconos…", + "iconSearchEmpty": "Ningún icono coincide.", + "iconSearchClear": "Borrar búsqueda", + "orderSave": "Guardar orden", + "orderSaved": "Orden guardado", + "moveUp": "Subir", + "moveDown": "Bajar", + "iconGroupEmotions": "Emociones", + "iconGroupActivities": "Actividades", + "iconGroupHealth": "Salud", + "iconGroupFood": "Comida y bebida", + "iconGroupWeather": "Tiempo", + "iconGroupPlaces": "Lugares", + "iconGroupMisc": "Otros" + } }, "medications": { "title": "Medicamentos", @@ -678,6 +729,15 @@ "logIntake": "Registrar una toma", "newMedication": "Nuevo medicamento" }, + "takeAllDue": { + "button": "Tomar todas las pendientes", + "dialogTitle": "Tomar medicamentos pendientes", + "dialogDescription": "Estos medicamentos están pendientes ahora. Confirma para registrarlos todos como tomados.", + "confirm": "Tomar todos ({count})", + "successToast": "{count} tomas registradas", + "partialToast": "{taken} registradas, {failed} fallidas – inténtalo de nuevo", + "failedToast": "No se registró ninguna toma – inténtalo de nuevo" + }, "logIntake": { "dialogTitle": "Registrar una toma", "dialogDescription": "Registra una dosis de un medicamento existente. Puedes posfecharla a una fecha y hora pasadas.", @@ -760,6 +820,7 @@ "today": "Hoy", "yesterday": "Ayer", "tomorrow": "Mañana", + "asNeededMarker": "Según necesidad", "withoutNotification": "Sin notificación", "pausedSince": "En pausa desde", "takeNow": "Tomar ahora", @@ -1147,6 +1208,10 @@ "oneShot": { "label": "Dosis única", "description": "Una sola administración, p. ej. una vacuna o un ciclo de antibiótico." + }, + "asNeeded": { + "label": "Según necesidad", + "description": "Sin plan fijo: registra una toma cuando la necesites (p. ej. analgésicos)." } }, "summary": { @@ -1160,6 +1225,7 @@ "yearly": "Una vez al año", "rolling": "Cada {n} días desde tu última toma", "oneShot": "Una dosis única", + "asNeeded": "Según necesidad", "everyNWeeks": "Cada {n} semanas", "everyNMonths": "Cada {n} meses" }, @@ -1345,7 +1411,8 @@ }, "cadence": { "oneShotOn": "Dosis única el {date}.", - "oneShotPending": "Dosis única pendiente." + "oneShotPending": "Dosis única pendiente.", + "asNeeded": "Según necesidad — sin plan fijo." }, "intake": { "title": "Historial de tomas", @@ -1574,6 +1641,7 @@ "statusTitle": "Estado", "nextDoseLabel": "Próxima dosis", "nextDoseNone": "No hay dosis pendiente.", + "nextDoseAsNeeded": "Según necesidad — sin dosis programada.", "pausedHint": "En pausa — sin recordatorios.", "reminderLabel": "Recordatorio", "reminderOn": "Activado", @@ -1581,6 +1649,7 @@ "supplyLabel": "Existencias", "supplyRunway": "Alcanza para unos {days} días más", "jumpToZeitplan": "Ver horario", + "jumpToVerlauf": "Ver historial", "jumpToBestand": "Ver existencias", "complianceTitle": "Adherencia", "complianceEmpty": "Aún no hay datos de adherencia." @@ -1600,6 +1669,8 @@ "emptyHelper": "Registra un envase o recipiente para hacer seguimiento de las existencias restantes y la autonomía.", "emptyCta": "Registrar un envase", "addButton": "Registrar", + "lowStockLinkOn": "Aviso de reserva baja: por debajo de {days} días", + "lowStockLinkOff": "Aviso de reserva baja: desactivado", "addTitle": "Registrar un envase o recipiente", "addDescription": "Registra una nueva existencia con su número de unidades; la fecha de caducidad es opcional.", "addQuantityLabel": "Cantidad (unidades)", @@ -1616,6 +1687,10 @@ "adjustSubmit": "Guardar", "adjustSuccess": "Existencias actualizadas.", "adjustFailed": "No se pudieron actualizar las existencias.", + "deleteTitle": "¿Eliminar envase?", + "deleteDescription": "Elimina permanentemente este envase o recipiente de las existencias. Las tomas registradas no cambian.", + "deleteSuccess": "Entrada de existencias eliminada.", + "deleteFailed": "No se pudo eliminar la entrada de existencias.", "containerTypeLabel": "Tipo de envase", "containerType": { "PEN": "Pluma", @@ -1631,7 +1706,19 @@ "quantityInUnits": "= {units} unidades", "quantityInDoses": "≈ {doses} dosis", "quantityUnderOneDose": "< 1 dosis", - "expiredSuffix": "+ {units} unidades caducadas" + "expiredSuffix": "+ {units} unidades caducadas", + "packagingTitle": "Envase y dosis", + "packagingUnitsPerDose": "1 dosis = {units} unidades", + "packagingDefaultPack": "Tamaño de envase predeterminado: {units} unidades", + "packagingNoDefaultPack": "Sin tamaño de envase predeterminado", + "packagingEdit": "Editar", + "packagingDescription": "Define cuántas unidades consume una dosis y el tamaño habitual de un envase nuevo, p. ej. tras un cambio de fabricante con otro tamaño de blíster.", + "packagingUnitsPerDoseLabel": "Unidades por dosis", + "packagingUnitsPerDoseHelper": "P. ej. dos comprimidos de 2 mg para una dosis de 4 mg (1–100). Las tomas ya registradas no cambian.", + "packagingDefaultPackLabel": "Tamaño de envase predeterminado (unidades)", + "packagingDefaultPackHelper": "Rellena la cantidad al registrar envases nuevos (1–1000). Déjalo vacío si no aplica.", + "packagingSuccess": "Datos del envase guardados", + "packagingFailed": "Error al guardar" }, "erweitert": { "group": { @@ -1733,6 +1820,8 @@ "tableComplianceDays": "{days} d", "tableStockDoseOne": "1 dosis", "tableStockDoses": "{count} dosis", + "tableStockRunway": "≈ {days} días restantes", + "cardLowStockRunway": "Reserva: ≈ {days} días", "takeActionFor": "Tomar – {name}", "skipActionFor": "Omitir – {name}", "reorderTitle": "Ajustar orden", @@ -3773,6 +3862,10 @@ "medications": { "title": "Medicamentos", "description": "Vista de lista y orden de los medicamentos." + }, + "mood": { + "title": "Etiquetas de ánimo", + "description": "Grupos, etiquetas y orden del selector de ánimo." } }, "profile": "Perfil", @@ -5297,6 +5390,20 @@ "disabledToast": "Avisos proactivos del Coach desactivados.", "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde." }, + "lowStock": { + "title": "Aviso de existencias bajas", + "description": "Comprueba una vez al día las existencias registradas de tus medicamentos y te avisa cuando alguna cubre menos días que este umbral. Un aviso por escasez; se reactiva tras reponer.", + "toggleAria": "Activar o desactivar los avisos de existencias bajas", + "statusOn": "Activo", + "statusOff": "Desactivado", + "daysLabel": "Avisar por debajo de (días)", + "daysAria": "Seleccionar el umbral de existencias en días", + "savedToast": "Umbral de existencias guardado.", + "enabledToast": "Avisos de existencias bajas activados.", + "disabledToast": "Avisos de existencias bajas desactivados.", + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "medicationsLink": "Ir a tus medicamentos" + }, "eventMedicationReminder": "Recordatorio de medicación", "eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.", "eventMeasurementAnomaly": "Medición inusual", @@ -5316,7 +5423,9 @@ "eventCyclePeriodConfirm": "¿Empezó tu período?", "eventCyclePeriodConfirmDesc": "Un recordatorio suave en o después del inicio previsto mientras no se registre ningún período.", "eventCycleFertileSoon": "Ventana fértil próxima", - "eventCycleFertileSoonDesc": "Se envía unos días antes de tu ventana fértil estimada cuando tu objetivo es concebir." + "eventCycleFertileSoonDesc": "Se envía unos días antes de tu ventana fértil estimada cuando tu objetivo es concebir.", + "eventMedicationLowStock": "Existencias de medicación bajas", + "eventMedicationLowStockDesc": "Cuando unas existencias registradas cubren menos días que tu umbral de existencias" }, "i18n": { "maintainershipBanner": { @@ -5479,6 +5588,11 @@ "buttonSnoozeThreeHours": "🕐 3h", "buttonSkip": "⏭ Omitir" }, + "lowStockReminders": { + "title": "Quedan pocas existencias: {medName}", + "body": "{medName}: quedan {units} unidades — para unos {days} días más.", + "bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro." + }, "moodReminders": { "dailyTitle": "Registrar el ánimo", "dailyBody": "¿Cómo te sientes hoy?" diff --git a/messages/fr.json b/messages/fr.json index 9e24dfa09..f92ef14a5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -664,7 +664,58 @@ "bulkDeleteSuccess": "{count} entrées supprimées", "bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées", "bulkDeleteConfirmTitle": "Supprimer {count} entrées ?", - "bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront définitivement supprimées." + "bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront définitivement supprimées.", + "customize": "Personnaliser les étiquettes d’humeur", + "addTagInline": "Ajouter une étiquette", + "manage": { + "groupsTitle": "Groupes", + "groupsDescription": "Crée tes propres groupes et définis leur ordre dans le sélecteur.", + "newGroup": "Nouveau groupe", + "groupLabel": "Nom du groupe", + "groupIcon": "Icône", + "groupCreated": "Groupe créé", + "groupRenamed": "Groupe enregistré", + "groupDeleteTitle": "Supprimer le groupe ?", + "groupDeleteBody": "Les étiquettes de ce groupe reviennent dans leur groupe d’origine. Aucune saisie n’est supprimée.", + "groupDeleted": "Groupe supprimé", + "groupEmpty": "Pas encore de groupe personnel.", + "groupLimitReached": "Limite de groupes atteinte", + "tagsTitle": "Étiquettes", + "tagsDescription": "Masque, réordonne, déplace ou modifie les étiquettes du sélecteur.", + "newTag": "Nouvelle étiquette", + "tagLabel": "Nom de l’étiquette", + "tagIcon": "Icône", + "tagGroup": "Groupe", + "usageCount": "{count} saisies", + "hideTag": "Masquer l’étiquette", + "showTag": "Afficher l’étiquette", + "moveToGroup": "Déplacer vers un groupe", + "archive": "Archiver", + "restore": "Restaurer", + "archivedTitle": "Étiquettes archivées", + "archivedDescription": "Les étiquettes archivées restent dans tes saisies passées — elles disparaissent seulement du sélecteur.", + "archivedEmpty": "Rien d’archivé.", + "purgeTitle": "Supprimer l’étiquette définitivement ?", + "purgeBody": "L’étiquette sera retirée de {count} saisies passées. Cette action est irréversible.", + "purgeAction": "Supprimer définitivement", + "purged": "Étiquette supprimée", + "tagLimitReached": "Limite d’étiquettes atteinte", + "iconPickerTitle": "Choisir une icône", + "iconSearchPlaceholder": "Rechercher des icônes…", + "iconSearchEmpty": "Aucune icône ne correspond.", + "iconSearchClear": "Effacer la recherche", + "orderSave": "Enregistrer l’ordre", + "orderSaved": "Ordre enregistré", + "moveUp": "Monter", + "moveDown": "Descendre", + "iconGroupEmotions": "Émotions", + "iconGroupActivities": "Activités", + "iconGroupHealth": "Santé", + "iconGroupFood": "Nourriture et boissons", + "iconGroupWeather": "Météo", + "iconGroupPlaces": "Lieux", + "iconGroupMisc": "Autres" + } }, "medications": { "title": "Médicaments", @@ -678,6 +729,15 @@ "logIntake": "Enregistrer une prise", "newMedication": "Nouveau médicament" }, + "takeAllDue": { + "button": "Prendre toutes les doses dues", + "dialogTitle": "Prendre les médicaments dus", + "dialogDescription": "Ces médicaments sont à prendre maintenant. Confirmez pour les enregistrer tous comme pris.", + "confirm": "Tout prendre ({count})", + "successToast": "{count} prises enregistrées", + "partialToast": "{taken} enregistrées, {failed} échouées – veuillez réessayer", + "failedToast": "Aucune prise enregistrée – veuillez réessayer" + }, "logIntake": { "dialogTitle": "Enregistrer une prise", "dialogDescription": "Enregistrez une dose pour un médicament existant. Vous pouvez l’antidater à une date et une heure passées.", @@ -760,6 +820,7 @@ "today": "Aujourd'hui", "yesterday": "Hier", "tomorrow": "Demain", + "asNeededMarker": "Au besoin", "withoutNotification": "Sans notification", "pausedSince": "En pause depuis", "takeNow": "Prendre maintenant", @@ -1147,6 +1208,10 @@ "oneShot": { "label": "Dose unique", "description": "Une seule administration, par exemple un vaccin ou une cure d’antibiotique." + }, + "asNeeded": { + "label": "Au besoin", + "description": "Pas de plan fixe — enregistrez une prise quand vous en avez besoin (p. ex. antidouleur)." } }, "summary": { @@ -1160,6 +1225,7 @@ "yearly": "Une fois par an", "rolling": "Tous les {n} jours depuis votre dernière prise", "oneShot": "Une dose unique", + "asNeeded": "Au besoin", "everyNWeeks": "Toutes les {n} semaines", "everyNMonths": "Tous les {n} mois" }, @@ -1345,7 +1411,8 @@ }, "cadence": { "oneShotOn": "Dose unique le {date}.", - "oneShotPending": "Dose unique en attente." + "oneShotPending": "Dose unique en attente.", + "asNeeded": "Au besoin — pas de plan fixe." }, "intake": { "title": "Historique des prises", @@ -1574,6 +1641,7 @@ "statusTitle": "Statut", "nextDoseLabel": "Prochaine dose", "nextDoseNone": "Aucune dose à venir.", + "nextDoseAsNeeded": "Au besoin — aucune dose planifiée.", "pausedHint": "En pause — aucun rappel.", "reminderLabel": "Rappel", "reminderOn": "Activé", @@ -1581,6 +1649,7 @@ "supplyLabel": "Stock", "supplyRunway": "Couvre encore environ {days} jours", "jumpToZeitplan": "Voir l'horaire", + "jumpToVerlauf": "Voir l'historique", "jumpToBestand": "Voir le stock", "complianceTitle": "Observance", "complianceEmpty": "Aucune donnée d'observance pour l'instant." @@ -1600,6 +1669,8 @@ "emptyHelper": "Enregistrez une boîte ou un contenant pour suivre le stock restant et l'autonomie.", "emptyCta": "Enregistrer une boîte", "addButton": "Enregistrer", + "lowStockLinkOn": "Alerte de stock bas : en dessous de {days} jours", + "lowStockLinkOff": "Alerte de stock bas : désactivée", "addTitle": "Enregistrer une boîte ou un contenant", "addDescription": "Enregistrez un nouveau stock avec son nombre d'unités ; la date de péremption est facultative.", "addQuantityLabel": "Quantité (unités)", @@ -1616,6 +1687,10 @@ "adjustSubmit": "Enregistrer", "adjustSuccess": "Stock mis à jour.", "adjustFailed": "Le stock n'a pas pu être mis à jour.", + "deleteTitle": "Supprimer le contenant ?", + "deleteDescription": "Supprime définitivement cette boîte ou ce contenant du stock. Les prises enregistrées restent inchangées.", + "deleteSuccess": "Entrée de stock supprimée.", + "deleteFailed": "L'entrée de stock n'a pas pu être supprimée.", "containerTypeLabel": "Type de contenant", "containerType": { "PEN": "Stylo", @@ -1631,7 +1706,19 @@ "quantityInUnits": "= {units} unités", "quantityInDoses": "≈ {doses} doses", "quantityUnderOneDose": "< 1 dose", - "expiredSuffix": "+ {units} unités périmées" + "expiredSuffix": "+ {units} unités périmées", + "packagingTitle": "Conditionnement et dose", + "packagingUnitsPerDose": "1 dose = {units} unités", + "packagingDefaultPack": "Taille de boîte par défaut : {units} unités", + "packagingNoDefaultPack": "Aucune taille de boîte par défaut", + "packagingEdit": "Modifier", + "packagingDescription": "Définis combien d’unités consomme une dose et la taille habituelle d’une nouvelle boîte — p. ex. après un changement de fabricant avec une autre taille de plaquette.", + "packagingUnitsPerDoseLabel": "Unités par dose", + "packagingUnitsPerDoseHelper": "P. ex. deux comprimés de 2 mg pour une dose de 4 mg (1–100). Les prises déjà enregistrées restent inchangées.", + "packagingDefaultPackLabel": "Taille de boîte par défaut (unités)", + "packagingDefaultPackHelper": "Préremplit la quantité lors de l’enregistrement de nouvelles boîtes (1–1000). Laisse vide si aucune ne s’applique.", + "packagingSuccess": "Conditionnement enregistré", + "packagingFailed": "Échec de l’enregistrement" }, "erweitert": { "group": { @@ -1733,6 +1820,8 @@ "tableComplianceDays": "{days} j", "tableStockDoseOne": "1 dose", "tableStockDoses": "{count} doses", + "tableStockRunway": "≈ {days} jours restants", + "cardLowStockRunway": "Réserve : ≈ {days} jours", "takeActionFor": "Prendre – {name}", "skipActionFor": "Passer – {name}", "reorderTitle": "Modifier l'ordre", @@ -3773,6 +3862,10 @@ "medications": { "title": "Médicaments", "description": "Vue de la liste et ordre des médicaments." + }, + "mood": { + "title": "Étiquettes d’humeur", + "description": "Groupes, étiquettes et ordre du sélecteur d’humeur." } }, "profile": "Profil", @@ -5297,6 +5390,20 @@ "disabledToast": "Rappels proactifs du Coach désactivés.", "saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard." }, + "lowStock": { + "title": "Alerte de stock faible", + "description": "Vérifie une fois par jour les stocks suivis de vos médicaments et vous prévient quand l’un d’eux couvre moins de jours que ce seuil. Une notification par pénurie ; elle se réarme après un réapprovisionnement.", + "toggleAria": "Activer ou désactiver les alertes de stock faible", + "statusOn": "Actif", + "statusOff": "Désactivé", + "daysLabel": "Prévenir en dessous de (jours)", + "daysAria": "Choisir le seuil de stock en jours", + "savedToast": "Seuil de stock enregistré.", + "enabledToast": "Alertes de stock faible activées.", + "disabledToast": "Alertes de stock faible désactivées.", + "saveError": "Échec de l’enregistrement. Réessayez.", + "medicationsLink": "Voir tes médicaments" + }, "eventMedicationReminder": "Rappel de médicament", "eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.", "eventMeasurementAnomaly": "Mesure inhabituelle", @@ -5316,7 +5423,9 @@ "eventCyclePeriodConfirm": "Vos règles ont-elles commencé ?", "eventCyclePeriodConfirmDesc": "Un rappel discret le jour prévu ou après, tant qu'aucune règle n'est enregistrée.", "eventCycleFertileSoon": "Fenêtre fertile à venir", - "eventCycleFertileSoonDesc": "Envoyé quelques jours avant votre fenêtre fertile estimée lorsque votre objectif est de concevoir." + "eventCycleFertileSoonDesc": "Envoyé quelques jours avant votre fenêtre fertile estimée lorsque votre objectif est de concevoir.", + "eventMedicationLowStock": "Stock de médicament faible", + "eventMedicationLowStockDesc": "Quand un stock suivi couvre moins de jours que votre seuil de stock" }, "i18n": { "maintainershipBanner": { @@ -5479,6 +5588,11 @@ "buttonSnoozeThreeHours": "🕐 3h", "buttonSkip": "⏭ Ignorer" }, + "lowStockReminders": { + "title": "Stock faible : {medName}", + "body": "{medName} : {units} unités restantes — environ {days} jours de réserve.", + "bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve." + }, "moodReminders": { "dailyTitle": "Enregistrer votre humeur", "dailyBody": "Comment vous sentez-vous aujourd’hui ?" diff --git a/messages/it.json b/messages/it.json index 692bf94fe..edd4bbb1f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -664,7 +664,58 @@ "bulkDeleteSuccess": "{count} voci eliminate", "bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate", "bulkDeleteConfirmTitle": "Eliminare {count} voci?", - "bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate definitivamente." + "bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate definitivamente.", + "customize": "Personalizza i tag dell’umore", + "addTagInline": "Aggiungi tag", + "manage": { + "groupsTitle": "Gruppi", + "groupsDescription": "Crea i tuoi gruppi e definisci il loro ordine nel selettore.", + "newGroup": "Nuovo gruppo", + "groupLabel": "Nome del gruppo", + "groupIcon": "Icona", + "groupCreated": "Gruppo creato", + "groupRenamed": "Gruppo salvato", + "groupDeleteTitle": "Eliminare il gruppo?", + "groupDeleteBody": "I tag di questo gruppo tornano nel loro gruppo originale. Nessuna voce viene eliminata.", + "groupDeleted": "Gruppo eliminato", + "groupEmpty": "Ancora nessun gruppo personale.", + "groupLimitReached": "Limite di gruppi raggiunto", + "tagsTitle": "Tag", + "tagsDescription": "Nascondi, riordina, sposta o modifica i tag del selettore.", + "newTag": "Nuovo tag", + "tagLabel": "Nome del tag", + "tagIcon": "Icona", + "tagGroup": "Gruppo", + "usageCount": "{count} voci", + "hideTag": "Nascondi tag", + "showTag": "Mostra tag", + "moveToGroup": "Sposta nel gruppo", + "archive": "Archivia", + "restore": "Ripristina", + "archivedTitle": "Tag archiviati", + "archivedDescription": "I tag archiviati restano nelle tue voci passate — scompaiono solo dal selettore.", + "archivedEmpty": "Niente in archivio.", + "purgeTitle": "Eliminare il tag definitivamente?", + "purgeBody": "Il tag verrà rimosso da {count} voci passate. L’operazione non può essere annullata.", + "purgeAction": "Elimina definitivamente", + "purged": "Tag eliminato", + "tagLimitReached": "Limite di tag raggiunto", + "iconPickerTitle": "Scegli un’icona", + "iconSearchPlaceholder": "Cerca icone…", + "iconSearchEmpty": "Nessuna icona corrisponde.", + "iconSearchClear": "Cancella ricerca", + "orderSave": "Salva ordine", + "orderSaved": "Ordine salvato", + "moveUp": "Sposta su", + "moveDown": "Sposta giù", + "iconGroupEmotions": "Emozioni", + "iconGroupActivities": "Attività", + "iconGroupHealth": "Salute", + "iconGroupFood": "Cibo e bevande", + "iconGroupWeather": "Meteo", + "iconGroupPlaces": "Luoghi", + "iconGroupMisc": "Altro" + } }, "medications": { "title": "Farmaci", @@ -678,6 +729,15 @@ "logIntake": "Registra un'assunzione", "newMedication": "Nuovo farmaco" }, + "takeAllDue": { + "button": "Assumi tutte le dosi in scadenza", + "dialogTitle": "Assumi i farmaci in scadenza", + "dialogDescription": "Questi farmaci sono da assumere adesso. Conferma per registrarli tutti come assunti.", + "confirm": "Assumi tutti ({count})", + "successToast": "{count} assunzioni registrate", + "partialToast": "{taken} registrate, {failed} non riuscite – riprova", + "failedToast": "Nessuna assunzione registrata – riprova" + }, "logIntake": { "dialogTitle": "Registra un'assunzione", "dialogDescription": "Registra una dose per un farmaco esistente. Puoi retrodatarla a una data e un'ora passate.", @@ -760,6 +820,7 @@ "today": "Oggi", "yesterday": "Ieri", "tomorrow": "Domani", + "asNeededMarker": "Al bisogno", "withoutNotification": "Senza notifica", "pausedSince": "In pausa dal", "takeNow": "Assumi ora", @@ -1147,6 +1208,10 @@ "oneShot": { "label": "Dose singola", "description": "Una singola somministrazione, ad es. un vaccino o un ciclo di antibiotico." + }, + "asNeeded": { + "label": "Al bisogno", + "description": "Nessun piano fisso: registra un'assunzione quando ti serve (ad es. antidolorifici)." } }, "summary": { @@ -1160,6 +1225,7 @@ "yearly": "Una volta all’anno", "rolling": "Ogni {n} giorni dall’ultima assunzione", "oneShot": "Una dose singola", + "asNeeded": "Al bisogno", "everyNWeeks": "Ogni {n} settimane", "everyNMonths": "Ogni {n} mesi" }, @@ -1345,7 +1411,8 @@ }, "cadence": { "oneShotOn": "Dose singola il {date}.", - "oneShotPending": "Dose singola in sospeso." + "oneShotPending": "Dose singola in sospeso.", + "asNeeded": "Al bisogno — nessun piano fisso." }, "intake": { "title": "Storico delle assunzioni", @@ -1574,6 +1641,7 @@ "statusTitle": "Stato", "nextDoseLabel": "Prossima dose", "nextDoseNone": "Nessuna dose in programma.", + "nextDoseAsNeeded": "Al bisogno — nessuna dose programmata.", "pausedHint": "In pausa — nessun promemoria.", "reminderLabel": "Promemoria", "reminderOn": "Attivo", @@ -1581,6 +1649,7 @@ "supplyLabel": "Scorta", "supplyRunway": "Copre ancora circa {days} giorni", "jumpToZeitplan": "Apri programma", + "jumpToVerlauf": "Apri cronologia", "jumpToBestand": "Apri scorta", "complianceTitle": "Aderenza", "complianceEmpty": "Ancora nessun dato sull'aderenza." @@ -1600,6 +1669,8 @@ "emptyHelper": "Registra una confezione o un contenitore per monitorare la scorta rimanente e l'autonomia.", "emptyCta": "Registra una confezione", "addButton": "Registra", + "lowStockLinkOn": "Avviso scorta bassa: sotto i {days} giorni", + "lowStockLinkOff": "Avviso scorta bassa: disattivato", "addTitle": "Registra una confezione o un contenitore", "addDescription": "Registra una nuova scorta con il numero di unità; la data di scadenza è facoltativa.", "addQuantityLabel": "Quantità (unità)", @@ -1616,6 +1687,10 @@ "adjustSubmit": "Salva", "adjustSuccess": "Scorta aggiornata.", "adjustFailed": "Impossibile aggiornare la scorta.", + "deleteTitle": "Eliminare la confezione?", + "deleteDescription": "Rimuove definitivamente questa confezione o contenitore dalla scorta. Le assunzioni registrate restano invariate.", + "deleteSuccess": "Scorta eliminata.", + "deleteFailed": "Impossibile eliminare la scorta.", "containerTypeLabel": "Tipo di contenitore", "containerType": { "PEN": "Penna", @@ -1631,7 +1706,19 @@ "quantityInUnits": "= {units} unità", "quantityInDoses": "≈ {doses} dosi", "quantityUnderOneDose": "< 1 dose", - "expiredSuffix": "+ {units} unità scadute" + "expiredSuffix": "+ {units} unità scadute", + "packagingTitle": "Confezione e dose", + "packagingUnitsPerDose": "1 dose = {units} unità", + "packagingDefaultPack": "Dimensione confezione predefinita: {units} unità", + "packagingNoDefaultPack": "Nessuna dimensione confezione predefinita", + "packagingEdit": "Modifica", + "packagingDescription": "Imposta quante unità consuma una dose e la dimensione abituale di una nuova confezione, ad es. dopo un cambio di produttore con blister diverso.", + "packagingUnitsPerDoseLabel": "Unità per dose", + "packagingUnitsPerDoseHelper": "Ad es. due compresse da 2 mg per una dose da 4 mg (1–100). Le assunzioni già registrate restano invariate.", + "packagingDefaultPackLabel": "Dimensione confezione predefinita (unità)", + "packagingDefaultPackHelper": "Precompila la quantità quando registri nuove confezioni (1–1000). Lascia vuoto se non applicabile.", + "packagingSuccess": "Dati confezione salvati", + "packagingFailed": "Salvataggio non riuscito" }, "erweitert": { "group": { @@ -1733,6 +1820,8 @@ "tableComplianceDays": "{days} g", "tableStockDoseOne": "1 dose", "tableStockDoses": "{count} dosi", + "tableStockRunway": "≈ {days} giorni rimanenti", + "cardLowStockRunway": "Scorta: ≈ {days} giorni", "takeActionFor": "Assumi – {name}", "skipActionFor": "Salta – {name}", "reorderTitle": "Modifica ordine", @@ -3773,6 +3862,10 @@ "medications": { "title": "Farmaci", "description": "Vista elenco e ordine dei farmaci." + }, + "mood": { + "title": "Tag dell’umore", + "description": "Gruppi, tag e ordine del selettore dell’umore." } }, "profile": "Profilo", @@ -5297,6 +5390,20 @@ "disabledToast": "Avvisi proattivi del Coach disattivati.", "saveError": "Impossibile salvare. Riprova più tardi." }, + "lowStock": { + "title": "Avviso scorte basse", + "description": "Controlla una volta al giorno le scorte tracciate dei tuoi farmaci e ti avvisa quando una copre meno giorni di questa soglia. Un avviso per carenza; si riattiva dopo il rifornimento.", + "toggleAria": "Attiva o disattiva gli avvisi di scorte basse", + "statusOn": "Attivo", + "statusOff": "Disattivato", + "daysLabel": "Avvisa sotto (giorni)", + "daysAria": "Seleziona la soglia delle scorte in giorni", + "savedToast": "Soglia delle scorte salvata.", + "enabledToast": "Avvisi di scorte basse attivati.", + "disabledToast": "Avvisi di scorte basse disattivati.", + "saveError": "Salvataggio non riuscito. Riprova.", + "medicationsLink": "Vai ai tuoi farmaci" + }, "eventMedicationReminder": "Promemoria farmaco", "eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.", "eventMeasurementAnomaly": "Misurazione insolita", @@ -5316,7 +5423,9 @@ "eventCyclePeriodConfirm": "Il ciclo è iniziato?", "eventCyclePeriodConfirmDesc": "Un promemoria discreto dal giorno previsto in poi finché non registri il ciclo.", "eventCycleFertileSoon": "Finestra fertile in arrivo", - "eventCycleFertileSoonDesc": "Inviato alcuni giorni prima della finestra fertile stimata quando il tuo obiettivo è il concepimento." + "eventCycleFertileSoonDesc": "Inviato alcuni giorni prima della finestra fertile stimata quando il tuo obiettivo è il concepimento.", + "eventMedicationLowStock": "Scorte di farmaco in esaurimento", + "eventMedicationLowStockDesc": "Quando una scorta tracciata copre meno giorni della tua soglia di scorte" }, "i18n": { "maintainershipBanner": { @@ -5479,6 +5588,11 @@ "buttonSnoozeThreeHours": "🕐 3h", "buttonSkip": "⏭ Salta" }, + "lowStockReminders": { + "title": "Scorte in esaurimento: {medName}", + "body": "{medName}: {units} unità rimaste — bastano per circa {days} giorni.", + "bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta." + }, "moodReminders": { "dailyTitle": "Registra il tuo umore", "dailyBody": "Come ti senti oggi?" diff --git a/messages/pl.json b/messages/pl.json index 03223cb51..7511fb7ce 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -664,7 +664,58 @@ "bulkDeleteSuccess": "Usunięto wpisy: {count}", "bulkDeleteError": "Błąd podczas usuwania wybranych wpisów", "bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?", - "bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną trwale usunięte." + "bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną trwale usunięte.", + "customize": "Dostosuj tagi nastroju", + "addTagInline": "Dodaj tag", + "manage": { + "groupsTitle": "Grupy", + "groupsDescription": "Twórz własne grupy i ustal ich kolejność w wyborze.", + "newGroup": "Nowa grupa", + "groupLabel": "Nazwa grupy", + "groupIcon": "Ikona", + "groupCreated": "Grupa utworzona", + "groupRenamed": "Grupa zapisana", + "groupDeleteTitle": "Usunąć grupę?", + "groupDeleteBody": "Tagi z tej grupy wracają do swojej pierwotnej grupy. Żadne wpisy nie zostaną usunięte.", + "groupDeleted": "Grupa usunięta", + "groupEmpty": "Brak własnych grup.", + "groupLimitReached": "Osiągnięto limit grup", + "tagsTitle": "Tagi", + "tagsDescription": "Ukrywaj, porządkuj, przenoś lub edytuj tagi widoczne w wyborze.", + "newTag": "Nowy tag", + "tagLabel": "Nazwa tagu", + "tagIcon": "Ikona", + "tagGroup": "Grupa", + "usageCount": "{count} wpisów", + "hideTag": "Ukryj tag", + "showTag": "Pokaż tag", + "moveToGroup": "Przenieś do grupy", + "archive": "Archiwizuj", + "restore": "Przywróć", + "archivedTitle": "Zarchiwizowane tagi", + "archivedDescription": "Zarchiwizowane tagi pozostają w Twoich wcześniejszych wpisach — znikają tylko z wyboru.", + "archivedEmpty": "Nic nie zarchiwizowano.", + "purgeTitle": "Usunąć tag na zawsze?", + "purgeBody": "Tag zostanie usunięty z {count} wcześniejszych wpisów. Tej operacji nie można cofnąć.", + "purgeAction": "Usuń na zawsze", + "purged": "Tag usunięty", + "tagLimitReached": "Osiągnięto limit tagów", + "iconPickerTitle": "Wybierz ikonę", + "iconSearchPlaceholder": "Szukaj ikon…", + "iconSearchEmpty": "Brak pasującej ikony.", + "iconSearchClear": "Wyczyść wyszukiwanie", + "orderSave": "Zapisz kolejność", + "orderSaved": "Kolejność zapisana", + "moveUp": "W górę", + "moveDown": "W dół", + "iconGroupEmotions": "Emocje", + "iconGroupActivities": "Aktywności", + "iconGroupHealth": "Zdrowie", + "iconGroupFood": "Jedzenie i napoje", + "iconGroupWeather": "Pogoda", + "iconGroupPlaces": "Miejsca", + "iconGroupMisc": "Inne" + } }, "medications": { "title": "Leki", @@ -678,6 +729,15 @@ "logIntake": "Zarejestruj przyjęcie", "newMedication": "Nowy lek" }, + "takeAllDue": { + "button": "Przyjmij wszystkie zaległe", + "dialogTitle": "Przyjmij zaległe leki", + "dialogDescription": "Te leki są teraz do przyjęcia. Potwierdź, aby zarejestrować je wszystkie jako przyjęte.", + "confirm": "Przyjmij wszystkie ({count})", + "successToast": "Zarejestrowano przyjęcia: {count}", + "partialToast": "Zarejestrowano: {taken}, nieudane: {failed} – spróbuj ponownie", + "failedToast": "Nie zarejestrowano żadnego przyjęcia – spróbuj ponownie" + }, "logIntake": { "dialogTitle": "Zarejestruj przyjęcie", "dialogDescription": "Zarejestruj dawkę istniejącego leku. Możesz wstawić ją wstecznie na przeszłą datę i godzinę.", @@ -760,6 +820,7 @@ "today": "Dzisiaj", "yesterday": "Wczoraj", "tomorrow": "Jutro", + "asNeededMarker": "W razie potrzeby", "withoutNotification": "Bez powiadomienia", "pausedSince": "Wstrzymane od", "takeNow": "Przyjmij teraz", @@ -1147,6 +1208,10 @@ "oneShot": { "label": "Jednorazowo", "description": "Jednorazowe podanie, np. szczepionka lub kuracja antybiotykowa." + }, + "asNeeded": { + "label": "W razie potrzeby", + "description": "Bez stałego planu — zapisz przyjęcie, gdy go potrzebujesz (np. lek przeciwbólowy)." } }, "summary": { @@ -1160,6 +1225,7 @@ "yearly": "Raz w roku", "rolling": "Co {n} dni od ostatniego przyjęcia", "oneShot": "Pojedyncza dawka", + "asNeeded": "W razie potrzeby", "everyNWeeks": "Co {n} tygodni", "everyNMonths": "Co {n} miesięcy" }, @@ -1345,7 +1411,8 @@ }, "cadence": { "oneShotOn": "Dawka jednorazowa w dniu {date}.", - "oneShotPending": "Dawka jednorazowa oczekuje." + "oneShotPending": "Dawka jednorazowa oczekuje.", + "asNeeded": "W razie potrzeby — bez stałego planu." }, "intake": { "title": "Historia przyjęć", @@ -1574,6 +1641,7 @@ "statusTitle": "Status", "nextDoseLabel": "Następna dawka", "nextDoseNone": "Brak nadchodzącej dawki.", + "nextDoseAsNeeded": "W razie potrzeby — brak zaplanowanej dawki.", "pausedHint": "Wstrzymano — brak przypomnień.", "reminderLabel": "Przypomnienie", "reminderOn": "Włączone", @@ -1581,6 +1649,7 @@ "supplyLabel": "Zapas", "supplyRunway": "Wystarczy na około {days} dni", "jumpToZeitplan": "Otwórz harmonogram", + "jumpToVerlauf": "Otwórz historię", "jumpToBestand": "Otwórz zapas", "complianceTitle": "Przestrzeganie", "complianceEmpty": "Brak danych o przestrzeganiu." @@ -1600,6 +1669,8 @@ "emptyHelper": "Zarejestruj opakowanie lub pojemnik, aby śledzić pozostały zapas i jego wystarczalność.", "emptyCta": "Zarejestruj opakowanie", "addButton": "Zarejestruj", + "lowStockLinkOn": "Powiadomienie o niskim zapasie: poniżej {days} dni", + "lowStockLinkOff": "Powiadomienie o niskim zapasie: wyłączone", "addTitle": "Zarejestruj opakowanie lub pojemnik", "addDescription": "Zapisz nowy zapas z liczbą jednostek; data ważności jest opcjonalna.", "addQuantityLabel": "Ilość (jednostki)", @@ -1616,6 +1687,10 @@ "adjustSubmit": "Zapisz", "adjustSuccess": "Zapas zaktualizowany.", "adjustFailed": "Nie udało się zaktualizować zapasu.", + "deleteTitle": "Usunąć opakowanie?", + "deleteDescription": "Trwale usuwa to opakowanie lub pojemnik z zapasu. Zarejestrowane dawki pozostają bez zmian.", + "deleteSuccess": "Zapas usunięty.", + "deleteFailed": "Nie udało się usunąć zapasu.", "containerTypeLabel": "Rodzaj pojemnika", "containerType": { "PEN": "Wstrzykiwacz", @@ -1631,7 +1706,19 @@ "quantityInUnits": "= {units} jednostek", "quantityInDoses": "Dawki: ≈ {doses}", "quantityUnderOneDose": "< 1 dawka", - "expiredSuffix": "+ przeterminowane jednostki: {units}" + "expiredSuffix": "+ przeterminowane jednostki: {units}", + "packagingTitle": "Opakowanie i dawka", + "packagingUnitsPerDose": "1 dawka = {units} jedn.", + "packagingDefaultPack": "Domyślny rozmiar opakowania: {units} jedn.", + "packagingNoDefaultPack": "Brak domyślnego rozmiaru opakowania", + "packagingEdit": "Edytuj", + "packagingDescription": "Określ, ile jednostek zużywa jedna dawka i jaki jest typowy rozmiar nowego opakowania — np. po zmianie producenta z innym blistrem.", + "packagingUnitsPerDoseLabel": "Jednostki na dawkę", + "packagingUnitsPerDoseHelper": "Np. dwie tabletki 2 mg na dawkę 4 mg (1–100). Zarejestrowane dawki pozostają bez zmian.", + "packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)", + "packagingDefaultPackHelper": "Wstępnie wypełnia ilość przy dodawaniu nowych opakowań (1–1000). Zostaw puste, jeśli nie dotyczy.", + "packagingSuccess": "Zapisano dane opakowania", + "packagingFailed": "Zapis nie powiódł się" }, "erweitert": { "group": { @@ -1733,6 +1820,8 @@ "tableComplianceDays": "{days} dni", "tableStockDoseOne": "1 dawka", "tableStockDoses": "Dawki: {count}", + "tableStockRunway": "≈ {days} dni zapasu", + "cardLowStockRunway": "Zapas: ≈ {days} dni", "takeActionFor": "Przyjmij – {name}", "skipActionFor": "Pomiń – {name}", "reorderTitle": "Zmień kolejność", @@ -3773,6 +3862,10 @@ "medications": { "title": "Leki", "description": "Widok listy i kolejność leków." + }, + "mood": { + "title": "Tagi nastroju", + "description": "Grupy, tagi i kolejność w wyborze nastroju." } }, "profile": "Profil", @@ -5297,6 +5390,20 @@ "disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.", "saveError": "Nie udało się zapisać. Spróbuj ponownie później." }, + "lowStock": { + "title": "Alert niskiego zapasu", + "description": "Raz dziennie sprawdza śledzone zapasy leków i powiadamia, gdy któryś wystarcza na mniej dni niż ten próg. Jedno powiadomienie na niedobór; po uzupełnieniu uzbraja się ponownie.", + "toggleAria": "Przełącz alerty niskiego zapasu", + "statusOn": "Aktywne", + "statusOff": "Wyłączone", + "daysLabel": "Powiadom poniżej (dni)", + "daysAria": "Wybierz próg zapasu w dniach", + "savedToast": "Próg zapasu zapisany.", + "enabledToast": "Alerty niskiego zapasu włączone.", + "disabledToast": "Alerty niskiego zapasu wyłączone.", + "saveError": "Zapis nieudany. Spróbuj ponownie.", + "medicationsLink": "Przejdź do swoich leków" + }, "eventMedicationReminder": "Przypomnienie o leku", "eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.", "eventMeasurementAnomaly": "Nietypowy pomiar", @@ -5316,7 +5423,9 @@ "eventCyclePeriodConfirm": "Czy okres się zaczął?", "eventCyclePeriodConfirmDesc": "Delikatne przypomnienie w dniu przewidywanego początku lub później, gdy nie zarejestrowano okresu.", "eventCycleFertileSoon": "Zbliża się okno płodności", - "eventCycleFertileSoonDesc": "Wysyłane kilka dni przed szacowanym oknem płodności, gdy Twoim celem jest poczęcie." + "eventCycleFertileSoonDesc": "Wysyłane kilka dni przed szacowanym oknem płodności, gdy Twoim celem jest poczęcie.", + "eventMedicationLowStock": "Niski zapas leku", + "eventMedicationLowStockDesc": "Gdy śledzony zapas leku wystarcza na mniej dni niż twój próg zapasu" }, "i18n": { "maintainershipBanner": { @@ -5479,6 +5588,11 @@ "buttonSnoozeThreeHours": "🕐 3h", "buttonSkip": "⏭ Pomiń" }, + "lowStockReminders": { + "title": "Niski zapas: {medName}", + "body": "{medName}: pozostało {units} jednostek — wystarczy na około {days} dni.", + "bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu." + }, "moodReminders": { "dailyTitle": "Zapisz nastrój", "dailyBody": "Jak się dzisiaj czujesz?" diff --git a/package.json b/package.json index 4361dcb1c..c5b3e8ff8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.16.10", + "version": "1.16.11", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", @@ -80,7 +80,7 @@ "radix-ui": "^1.4.3", "react": "19.2.5", "react-dom": "19.2.5", - "react-hook-form": "^7.77.0", + "react-hook-form": "^7.78.0", "recharts": "^3.8.1", "rrule": "^2.8.1", "sax": "^1.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a26df611a..00cf5666d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 3.2.2(react@19.2.5) '@hookform/resolvers': specifier: ^5.4.0 - version: 5.4.0(react-hook-form@7.77.0(react@19.2.5)) + version: 5.4.0(react-hook-form@7.78.0(react@19.2.5)) '@node-rs/argon2': specifier: ^2.0.2 version: 2.0.2 @@ -99,8 +99,8 @@ importers: specifier: 19.2.5 version: 19.2.5(react@19.2.5) react-hook-form: - specifier: ^7.77.0 - version: 7.77.0(react@19.2.5) + specifier: ^7.78.0 + version: 7.78.0(react@19.2.5) recharts: specifier: ^3.8.1 version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1) @@ -2479,141 +2479,141 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rollup/rollup-android-arm-eabi@4.60.4': - resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + '@rollup/rollup-android-arm-eabi@4.61.1': + resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.4': - resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + '@rollup/rollup-android-arm64@4.61.1': + resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.4': - resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + '@rollup/rollup-darwin-arm64@4.61.1': + resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.4': - resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + '@rollup/rollup-darwin-x64@4.61.1': + resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.4': - resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + '@rollup/rollup-freebsd-arm64@4.61.1': + resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.4': - resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + '@rollup/rollup-freebsd-x64@4.61.1': + resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': - resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.4': - resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.4': - resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + '@rollup/rollup-linux-arm64-gnu@4.61.1': + resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.4': - resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + '@rollup/rollup-linux-arm64-musl@4.61.1': + resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.4': - resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + '@rollup/rollup-linux-loong64-gnu@4.61.1': + resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.4': - resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + '@rollup/rollup-linux-loong64-musl@4.61.1': + resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.4': - resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.4': - resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + '@rollup/rollup-linux-ppc64-musl@4.61.1': + resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.4': - resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.4': - resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + '@rollup/rollup-linux-riscv64-musl@4.61.1': + resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.4': - resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + '@rollup/rollup-linux-s390x-gnu@4.61.1': + resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.4': - resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + '@rollup/rollup-linux-x64-gnu@4.61.1': + resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.4': - resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + '@rollup/rollup-linux-x64-musl@4.61.1': + resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.4': - resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + '@rollup/rollup-openbsd-x64@4.61.1': + resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.4': - resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + '@rollup/rollup-openharmony-arm64@4.61.1': + resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.4': - resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + '@rollup/rollup-win32-arm64-msvc@4.61.1': + resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.4': - resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + '@rollup/rollup-win32-ia32-msvc@4.61.1': + resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.4': - resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + '@rollup/rollup-win32-x64-gnu@4.61.1': + resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.4': - resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + '@rollup/rollup-win32-x64-msvc@4.61.1': + resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} cpu: [x64] os: [win32] @@ -2842,6 +2842,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -5676,8 +5679,8 @@ packages: peerDependencies: react: ^19.2.5 - react-hook-form@7.77.0: - resolution: {integrity: sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==} + react-hook-form@7.78.0: + resolution: {integrity: sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 @@ -5835,8 +5838,8 @@ packages: resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} engines: {node: '>= 0.8.15'} - rollup@4.60.4: - resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + rollup@4.61.1: + resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -7505,10 +7508,10 @@ snapshots: dependencies: hono: 4.12.23 - '@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@19.2.5))': + '@hookform/resolvers@5.4.0(react-hook-form@7.78.0(react@19.2.5))': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.77.0(react@19.2.5) + react-hook-form: 7.78.0(react@19.2.5) '@humanfs/core@0.19.1': {} @@ -9029,79 +9032,79 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.60.4': + '@rollup/rollup-android-arm-eabi@4.61.1': optional: true - '@rollup/rollup-android-arm64@4.60.4': + '@rollup/rollup-android-arm64@4.61.1': optional: true - '@rollup/rollup-darwin-arm64@4.60.4': + '@rollup/rollup-darwin-arm64@4.61.1': optional: true - '@rollup/rollup-darwin-x64@4.60.4': + '@rollup/rollup-darwin-x64@4.61.1': optional: true - '@rollup/rollup-freebsd-arm64@4.60.4': + '@rollup/rollup-freebsd-arm64@4.61.1': optional: true - '@rollup/rollup-freebsd-x64@4.60.4': + '@rollup/rollup-freebsd-x64@4.61.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.4': + '@rollup/rollup-linux-arm-musleabihf@4.61.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.4': + '@rollup/rollup-linux-arm64-gnu@4.61.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.4': + '@rollup/rollup-linux-arm64-musl@4.61.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.4': + '@rollup/rollup-linux-loong64-gnu@4.61.1': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.4': + '@rollup/rollup-linux-loong64-musl@4.61.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.4': + '@rollup/rollup-linux-ppc64-gnu@4.61.1': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.4': + '@rollup/rollup-linux-ppc64-musl@4.61.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.4': + '@rollup/rollup-linux-riscv64-gnu@4.61.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.4': + '@rollup/rollup-linux-riscv64-musl@4.61.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.4': + '@rollup/rollup-linux-s390x-gnu@4.61.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.4': + '@rollup/rollup-linux-x64-gnu@4.61.1': optional: true - '@rollup/rollup-linux-x64-musl@4.60.4': + '@rollup/rollup-linux-x64-musl@4.61.1': optional: true - '@rollup/rollup-openbsd-x64@4.60.4': + '@rollup/rollup-openbsd-x64@4.61.1': optional: true - '@rollup/rollup-openharmony-arm64@4.60.4': + '@rollup/rollup-openharmony-arm64@4.61.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.4': + '@rollup/rollup-win32-arm64-msvc@4.61.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.4': + '@rollup/rollup-win32-ia32-msvc@4.61.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.4': + '@rollup/rollup-win32-x64-gnu@4.61.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.4': + '@rollup/rollup-win32-x64-msvc@4.61.1': optional: true '@rtsao/scc@1.1.0': {} @@ -9340,6 +9343,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -12358,7 +12363,7 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-hook-form@7.77.0(react@19.2.5): + react-hook-form@7.78.0(react@19.2.5): dependencies: react: 19.2.5 @@ -12530,35 +12535,35 @@ snapshots: rgbcolor@1.0.1: optional: true - rollup@4.60.4: + rollup@4.61.1: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.4 - '@rollup/rollup-android-arm64': 4.60.4 - '@rollup/rollup-darwin-arm64': 4.60.4 - '@rollup/rollup-darwin-x64': 4.60.4 - '@rollup/rollup-freebsd-arm64': 4.60.4 - '@rollup/rollup-freebsd-x64': 4.60.4 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 - '@rollup/rollup-linux-arm-musleabihf': 4.60.4 - '@rollup/rollup-linux-arm64-gnu': 4.60.4 - '@rollup/rollup-linux-arm64-musl': 4.60.4 - '@rollup/rollup-linux-loong64-gnu': 4.60.4 - '@rollup/rollup-linux-loong64-musl': 4.60.4 - '@rollup/rollup-linux-ppc64-gnu': 4.60.4 - '@rollup/rollup-linux-ppc64-musl': 4.60.4 - '@rollup/rollup-linux-riscv64-gnu': 4.60.4 - '@rollup/rollup-linux-riscv64-musl': 4.60.4 - '@rollup/rollup-linux-s390x-gnu': 4.60.4 - '@rollup/rollup-linux-x64-gnu': 4.60.4 - '@rollup/rollup-linux-x64-musl': 4.60.4 - '@rollup/rollup-openbsd-x64': 4.60.4 - '@rollup/rollup-openharmony-arm64': 4.60.4 - '@rollup/rollup-win32-arm64-msvc': 4.60.4 - '@rollup/rollup-win32-ia32-msvc': 4.60.4 - '@rollup/rollup-win32-x64-gnu': 4.60.4 - '@rollup/rollup-win32-x64-msvc': 4.60.4 + '@rollup/rollup-android-arm-eabi': 4.61.1 + '@rollup/rollup-android-arm64': 4.61.1 + '@rollup/rollup-darwin-arm64': 4.61.1 + '@rollup/rollup-darwin-x64': 4.61.1 + '@rollup/rollup-freebsd-arm64': 4.61.1 + '@rollup/rollup-freebsd-x64': 4.61.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 + '@rollup/rollup-linux-arm-musleabihf': 4.61.1 + '@rollup/rollup-linux-arm64-gnu': 4.61.1 + '@rollup/rollup-linux-arm64-musl': 4.61.1 + '@rollup/rollup-linux-loong64-gnu': 4.61.1 + '@rollup/rollup-linux-loong64-musl': 4.61.1 + '@rollup/rollup-linux-ppc64-gnu': 4.61.1 + '@rollup/rollup-linux-ppc64-musl': 4.61.1 + '@rollup/rollup-linux-riscv64-gnu': 4.61.1 + '@rollup/rollup-linux-riscv64-musl': 4.61.1 + '@rollup/rollup-linux-s390x-gnu': 4.61.1 + '@rollup/rollup-linux-x64-gnu': 4.61.1 + '@rollup/rollup-linux-x64-musl': 4.61.1 + '@rollup/rollup-openbsd-x64': 4.61.1 + '@rollup/rollup-openharmony-arm64': 4.61.1 + '@rollup/rollup-win32-arm64-msvc': 4.61.1 + '@rollup/rollup-win32-ia32-msvc': 4.61.1 + '@rollup/rollup-win32-x64-gnu': 4.61.1 + '@rollup/rollup-win32-x64-msvc': 4.61.1 fsevents: 2.3.3 router@2.2.0: @@ -13319,7 +13324,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 - rollup: 4.60.4 + rollup: 4.61.1 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.2 diff --git a/prisma/migrations/0151_v11611_medication_low_stock_stamp/migration.sql b/prisma/migrations/0151_v11611_medication_low_stock_stamp/migration.sql new file mode 100644 index 000000000..f193ce9ed --- /dev/null +++ b/prisma/migrations/0151_v11611_medication_low_stock_stamp/migration.sql @@ -0,0 +1,11 @@ +-- v1.16.11 — low-stock notification stamp (medications). +-- +-- The daily low-stock pass notifies once per threshold crossing: +-- `low_stock_notified_at` records the dispatch instant, +-- `low_stock_notified_threshold_days` the user threshold (days of +-- remaining runway) it was written against. The pass clears both when +-- the projected runway rises back to / above the threshold (refill), +-- and treats a differing stamped threshold as re-armed. NULL = armed. + +ALTER TABLE "medications" ADD COLUMN "low_stock_notified_at" TIMESTAMP(3); +ALTER TABLE "medications" ADD COLUMN "low_stock_notified_threshold_days" INTEGER; diff --git a/prisma/migrations/0152_v1170_mood_tag_groups/migration.sql b/prisma/migrations/0152_v1170_mood_tag_groups/migration.sql new file mode 100644 index 000000000..9b6b53c7d --- /dev/null +++ b/prisma/migrations/0152_v1170_mood_tag_groups/migration.sql @@ -0,0 +1,29 @@ +-- v1.17.0 — per-user custom mood-tag groups + per-user tag layout. +-- +-- Additive, non-destructive (mirrors 0127_v1130_custom_mood_tags): +-- 1. `mood_tag_categories` gains `user_id` (NULL = global seeded category, +-- set = a user's custom group carrying a `customcat:` key) and +-- `label_encrypted` (AES-256-GCM ciphertext of a custom group's +-- free-text label; NULL for seeded rows, which resolve `label_key` +-- against the locale). +-- 2. `users` gains `mood_tag_layout_json` — the per-user presentation blob +-- (group order + per-group tag placement), display-only, following the +-- `medication_list_layout_json` per-surface-column convention. +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS, constraint adds guarded by +-- duplicate_object, CREATE INDEX IF NOT EXISTS. + +-- ─── 1. mood_tag_categories: owner + encrypted custom label ────────── +ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "user_id" TEXT; +ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "label_encrypted" TEXT; + +DO $$ BEGIN + ALTER TABLE "mood_tag_categories" + ADD CONSTRAINT "mood_tag_categories_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE INDEX IF NOT EXISTS "mood_tag_categories_user_id_idx" ON "mood_tag_categories"("user_id"); + +-- ─── 2. users: per-user mood-tag presentation blob ─────────────────── +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "mood_tag_layout_json" JSONB; diff --git a/prisma/migrations/0153_v11611_medication_as_needed/migration.sql b/prisma/migrations/0153_v11611_medication_as_needed/migration.sql new file mode 100644 index 000000000..ea3d38ffe --- /dev/null +++ b/prisma/migrations/0153_v11611_medication_as_needed/migration.sql @@ -0,0 +1,9 @@ +-- v1.16.11 (#316) — as-needed (PRN) medications. +-- +-- `as_needed = true` marks a medication taken only when the user needs +-- it: no fixed schedule (zero medication_schedules rows — enforced at +-- the route layer), never due, never reminded, excluded from +-- compliance, active indefinitely. Intakes still log, inventory still +-- consumes. Additive: every existing row backfills `false`. + +ALTER TABLE "medications" ADD COLUMN "as_needed" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 79c5acbfd..80ae7c70c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -145,6 +145,13 @@ model User { // once the user has explicitly toggled the view or saved an order. // Shape: { "version": 1, "view": "table", "order": ["", ...] } medicationListLayoutJson Json? @map("medication_list_layout_json") + // v1.17.0 — per-user mood-tag presentation: group order + per-group tag + // placement (catalogue tags "moved" into a group live here only — their + // `categoryId` never changes). Display-only, preserve-when-absent PUT, + // unknown keys dropped at read. Null = seeded defaults. + // Shape: { "groupOrder": ["", ...], + // "placements": { "": ["", ...] } } + moodTagLayoutJson Json? @map("mood_tag_layout_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.7.0 unit pref — global metric/imperial display preference. @@ -402,6 +409,7 @@ model User { fitbitOAuthStates FitbitOAuthState[] moodEntries MoodEntry[] moodTagsCustom MoodTag[] @relation("UserMoodTags") + moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") moodTagsHidden MoodTagHidden[] notificationChannels NotificationChannel[] pushSubscriptions PushSubscription[] @@ -1297,6 +1305,16 @@ model Medication { /// the migration backfills `oneShot = false` for every existing /// row. oneShot Boolean @default(false) @map("one_shot") + /// v1.16.11 (#316) — as-needed (PRN) medication. Taken only when the + /// user needs it (pain relief, rescue inhaler): no fixed schedule, + /// never due, never reminded, never counted in compliance, stays + /// active indefinitely — but intakes log, inventory consumes, and the + /// history renders. An as-needed medication carries ZERO + /// MedicationSchedule rows (the create/update routes 422 on a + /// schedules array alongside `asNeeded: true`); every "never due / + /// never reminded" surface follows structurally from the empty + /// schedule list. Mutually exclusive with `oneShot`. + asNeeded Boolean @default(false) @map("as_needed") /// v1.6.0 — route of administration. Decoupled from `treatmentClass` /// so the injection-site picker can surface for any INJECTION dose. /// A one-time injection is `oneShot = true` + `deliveryForm = @@ -1337,6 +1355,15 @@ model Medication { /// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any /// ATC code, never instead of the free-text `.text` anchor. rxNormCode String? @map("rxnorm_code") + /// v1.16.11 — low-stock notification stamp. Set when the daily + /// low-stock pass dispatched a notification for the current + /// threshold crossing; cleared (re-armed) when the projected runway + /// rises back to / above the user's threshold (refill). NULL = armed. + lowStockNotifiedAt DateTime? @map("low_stock_notified_at") + /// v1.16.11 — the user threshold (days) the stamp was written + /// against. A changed threshold re-arms the medication even while + /// the runway stays low, so the user hears about the new crossing. + lowStockNotifiedThresholdDays Int? @map("low_stock_notified_threshold_days") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -2362,8 +2389,19 @@ model MoodTagCategory { /// Soft-hide a category without deleting its historical links. isActive Boolean @default(true) @map("is_active") - tags MoodTag[] + /// v1.17.0 — owner of a per-user custom group. NULL = the global seeded + /// catalogue (resolves `labelKey` against the locale). Set = a user's own + /// group, carrying a `customcat:` key and a free-text label held in + /// `labelEncrypted` (mirrors the MoodTag custom-tag precedent). + userId String? @map("user_id") + /// v1.17.0 — AES-256-GCM ciphertext of a custom group's free-text label + /// (user prose, possibly health-adjacent). NULL for seeded rows. + labelEncrypted String? @map("label_encrypted") + tags MoodTag[] + user User? @relation("UserMoodTagCategories", fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) @@map("mood_tag_categories") } diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 9b14c2aab..a2eefaa4f 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -230,6 +230,50 @@ async function main() { results.push(r); } + // ───── MoodTag.labelEncrypted ───── + // v1.13.0 — AES-256-GCM ciphertext of a custom mood tag's free-text + // label (catalogue rows carry NULL and are skipped by `shouldRotate`). + const moodTags = await prisma.moodTag.findMany({ + where: { labelEncrypted: { not: null } }, + select: { id: true, labelEncrypted: true }, + }); + results.push( + await rotateField( + "MoodTag", + "labelEncrypted", + moodTags, + (t) => t.labelEncrypted, + async (id, ciphertext) => { + await prisma.moodTag.update({ + where: { id }, + data: { labelEncrypted: ciphertext }, + }); + }, + ), + ); + + // ───── MoodTagCategory.labelEncrypted ───── + // v1.17.0 — AES-256-GCM ciphertext of a custom mood-tag group's + // free-text label (seeded rows carry NULL and are skipped). + const moodTagCategories = await prisma.moodTagCategory.findMany({ + where: { labelEncrypted: { not: null } }, + select: { id: true, labelEncrypted: true }, + }); + results.push( + await rotateField( + "MoodTagCategory", + "labelEncrypted", + moodTagCategories, + (c) => c.labelEncrypted, + async (id, ciphertext) => { + await prisma.moodTagCategory.update({ + where: { id }, + data: { labelEncrypted: ciphertext }, + }); + }, + ), + ); + // ───── NotificationChannel.config ───── // Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON. // Skipping these on rotation would leave channels permanently undecryptable diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index aed76afe3..ec774e363 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -177,8 +177,11 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { const src = readFileSync(PAGE_PATH, "utf8"); // The placeholder count keys off the layout config minus the // already-mounted (layout-gated) cells, and only while loading. + // `mounted &&` pins the hydration render to the SSR output — the + // auth query can settle before the page boundary hydrates, and an + // extra BMI silhouette then mismatched the server HTML (React #418). expect(src).toMatch( - /const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*Boolean\(user\?\.heightCm\)/, + /const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*mounted\s*&&\s*Boolean\(user\?\.heightCm\)/, ); expect(src).toMatch(/-\s*charts\.length/); // The reserved cells render as aria-hidden ChartSkeletons in their diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 3137ecb03..afae11aea 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), }, }, - toJson: (v: T) => v, + toJson: (v: T) => v, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -88,10 +88,20 @@ describe("GET /api/auth/me/notification-prefs", () => { data: { medication: { clientManaged: boolean } }; }; expect(env.data).toEqual({ - medication: { clientManaged: false, deliveryDefault: "server" }, + medication: { + clientManaged: false, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -109,10 +119,20 @@ describe("GET /api/auth/me/notification-prefs", () => { data: { medication: { clientManaged: boolean } }; }; expect(env.data).toEqual({ - medication: { clientManaged: true, deliveryDefault: "server" }, + medication: { + clientManaged: true, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -132,10 +152,20 @@ describe("GET /api/auth/me/notification-prefs", () => { data: { medication: { clientManaged: boolean } }; }; expect(env.data).toEqual({ - medication: { clientManaged: false, deliveryDefault: "server" }, + medication: { + clientManaged: false, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); }); @@ -166,20 +196,40 @@ describe("PATCH /api/auth/me/notification-prefs", () => { data: { medication: { clientManaged: boolean } }; }; expect(env.data).toEqual({ - medication: { clientManaged: true, deliveryDefault: "server" }, + medication: { + clientManaged: true, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, data: { notificationPrefs: { - medication: { clientManaged: true, deliveryDefault: "server" }, + medication: { + clientManaged: true, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }, }, }); @@ -190,16 +240,36 @@ describe("PATCH /api/auth/me/notification-prefs", () => { userId: "user-1", details: expect.objectContaining({ previous: { - medication: { clientManaged: false, deliveryDefault: "server" }, + medication: { + clientManaged: false, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }, next: { - medication: { clientManaged: true, deliveryDefault: "server" }, + medication: { + clientManaged: true, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }, changed: ["medication"], }), @@ -210,14 +280,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => { it("rejects malformed JSON with 422", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - const req = new Request( - "http://localhost/api/auth/me/notification-prefs", - { - method: "PATCH", - body: "{ not valid json", - headers: { "Content-Type": "application/json" }, - }, - ); + const req = new Request("http://localhost/api/auth/me/notification-prefs", { + method: "PATCH", + body: "{ not valid json", + headers: { "Content-Type": "application/json" }, + }); const res = await (PATCH as (r: Request) => Promise)(req); expect(res.status).toBe(422); @@ -258,10 +325,20 @@ describe("PATCH /api/auth/me/notification-prefs", () => { where: { id: "user-1" }, data: { notificationPrefs: { - medication: { clientManaged: true, deliveryDefault: "server" }, + medication: { + clientManaged: true, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }, }, }); @@ -276,28 +353,46 @@ describe("PATCH /api/auth/me/notification-prefs", () => { } as never); vi.mocked(prisma.user.update).mockResolvedValue({} as never); - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({}), - ); + const res = await (PATCH as (r: Request) => Promise)(mkPatch({})); expect(res.status).toBe(200); const env = (await res.json()) as { data: { medication: { clientManaged: boolean } }; }; expect(env.data).toEqual({ - medication: { clientManaged: true, deliveryDefault: "server" }, + medication: { + clientManaged: true, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, data: { notificationPrefs: { - medication: { clientManaged: true, deliveryDefault: "server" }, + medication: { + clientManaged: true, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }, }, }); @@ -323,10 +418,20 @@ describe("PATCH /api/auth/me/notification-prefs", () => { where: { id: "user-1" }, data: { notificationPrefs: { - medication: { clientManaged: false, deliveryDefault: "server" }, + medication: { + clientManaged: false, + deliveryDefault: "server", + lowStockRunwayDays: 7, + }, mood: { reminderHour: 9 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }, }, }); @@ -342,6 +447,65 @@ describe("PATCH /api/auth/me/notification-prefs", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); + it("v1.16.11 — round-trips a custom lowStockRunwayDays", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ medication: { lowStockRunwayDays: 14 } }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { medication: { lowStockRunwayDays: number | null } }; + }; + expect(env.data.medication.lowStockRunwayDays).toBe(14); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + notificationPrefs: expect.objectContaining({ + medication: { + clientManaged: false, + deliveryDefault: "server", + lowStockRunwayDays: 14, + }, + }), + }, + }); + }); + + it("v1.16.11 — persists the explicit null (alert off)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { lowStockRunwayDays: 14 } }, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ medication: { lowStockRunwayDays: null } }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { medication: { lowStockRunwayDays: number | null } }; + }; + expect(env.data.medication.lowStockRunwayDays).toBe(null); + }); + + it("v1.16.11 — rejects a lowStockRunwayDays outside 1..60 with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + for (const value of [0, 61]) { + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ medication: { lowStockRunwayDays: value } }), + ); + expect(res.status).toBe(422); + } + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + it("returns 429 when the per-user rate-limit fires", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(checkRateLimit).mockResolvedValueOnce({ diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index acd4d896d..f0d999a04 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -277,7 +277,9 @@ export async function buildComprehensiveResponse(user: AuthedUser) { // ── Medication compliance ──────────────────────────────── // Bounded reads — left untouched per the directive. const medications = await prisma.medication.findMany({ - where: { userId, active: true }, + // v1.16.11 — as-needed (PRN) medications never surface a compliance + // rate (no expected doses). + where: { userId, active: true, asNeeded: false }, // v1.15.20 — schedules through the shared compliance select so the // configured per-dose windows reach this surface like every other. include: { diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index feaf66162..4b83a14b6 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -305,7 +305,9 @@ async function buildTargetsResponse(user: AuthedUser) { // inside this slot while riding the outer fan-out. limit(async () => { const activeMedications = await prisma.medication.findMany({ - where: { userId, active: true }, + // v1.16.11 — as-needed (PRN) medications never surface a + // compliance rate (no expected doses). + where: { userId, active: true, asNeeded: false }, // v1.15.20 — schedules through the shared compliance select so the // configured per-dose windows reach this surface like every other. include: { diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 8ca809f0a..4c79e1b06 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -593,3 +593,172 @@ describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => { expect(prisma.medicationSchedule.update).not.toHaveBeenCalled(); }); }); + +describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => { + /** Existing-row shape the as-needed invariants read. */ + function mockExisting(overrides: { + asNeeded?: boolean; + scheduleCount?: number; + }) { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "user-1", + active: true, + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + asNeeded: overrides.asNeeded ?? false, + _count: { schedules: overrides.scheduleCount ?? 1 }, + } as never); + } + + it("422s when asNeeded:true carries a non-empty schedules array (Zod)", async () => { + mockExisting({ scheduleCount: 1 }); + const res = await PUT( + putReq({ + asNeeded: true, + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + expect(prisma.medication.update).not.toHaveBeenCalled(); + }); + + it("422s when asNeeded:true would keep the existing schedules (no replace list)", async () => { + mockExisting({ scheduleCount: 2 }); + const res = await PUT(putReq({ asNeeded: true }), ROUTE_CTX); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("schedules: []"); + expect(prisma.medication.update).not.toHaveBeenCalled(); + }); + + it("flips to as-needed with schedules:[] — old rows deleted, none recreated", async () => { + mockExisting({ scheduleCount: 1 }); + // Re-stub deps the outer beforeEach's resetAllMocks cleared. + vi.mocked(getMedicationCategories).mockResolvedValue({} as never); + vi.mocked(prisma.medication.update).mockResolvedValue({ + id: "m1", + userId: "user-1", + asNeeded: true, + schedules: [], + } as never); + const res = await PUT( + putReq({ asNeeded: true, schedules: [] }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + // Wholesale replace ran: delete-all, recreate none. + expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({ + where: { medicationId: "m1" }, + }); + const updateData = vi.mocked(prisma.medication.update).mock + .calls[0][0] as { data: Record }; + expect(updateData.data.asNeeded).toBe(true); + expect(updateData.data.schedules).toEqual({ create: [] }); + }); + + it("PUT on an already-as-needed medication never re-acquires schedules silently", async () => { + mockExisting({ asNeeded: true, scheduleCount: 0 }); + // Body without asNeeded but WITH schedules: the effective flag stays + // true (existing row), so the entries are refused. + const res = await PUT( + putReq({ schedules: [{ windowStart: "08:00", windowEnd: "09:00" }] }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + expect(prisma.medication.update).not.toHaveBeenCalled(); + }); + + it("422s a flip back to scheduled without at least one schedule", async () => { + mockExisting({ asNeeded: true, scheduleCount: 0 }); + const res = await PUT(putReq({ asNeeded: false }), ROUTE_CTX); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("at least one schedule"); + expect(prisma.medication.update).not.toHaveBeenCalled(); + }); + + it("flips back to scheduled when the body carries the new schedule list", async () => { + mockExisting({ asNeeded: true, scheduleCount: 0 }); + vi.mocked(getMedicationCategories).mockResolvedValue({} as never); + vi.mocked(prisma.medication.update).mockResolvedValue({ + id: "m1", + userId: "user-1", + asNeeded: false, + schedules: [{ id: "s1" }], + } as never); + const res = await PUT( + putReq({ + asNeeded: false, + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + const updateData = vi.mocked(prisma.medication.update).mock + .calls[0][0] as { data: Record }; + expect(updateData.data.asNeeded).toBe(false); + }); + + it("archives an EMPTY era for the as-needed stretch on the flip back", async () => { + // Without the empty revision the live era would start at the previous + // revision boundary (or createdAt) and the NEW schedule's slots would + // retro-paint every schedule-less as-needed day as missed. + mockExisting({ asNeeded: true, scheduleCount: 0 }); + vi.mocked(getMedicationCategories).mockResolvedValue({} as never); + const previousBoundary = new Date("2026-05-01T08:00:00Z"); + vi.mocked(prisma.medicationScheduleRevision.findFirst).mockResolvedValue({ + validUntil: previousBoundary, + } as never); + vi.mocked(prisma.medication.update).mockResolvedValue({ + id: "m1", + userId: "user-1", + asNeeded: false, + schedules: [{ id: "s1" }], + } as never); + const res = await PUT( + putReq({ + asNeeded: false, + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(prisma.medicationScheduleRevision.create).toHaveBeenCalledTimes(1); + const createArg = vi.mocked(prisma.medicationScheduleRevision.create).mock + .calls[0][0] as { + data: { payload: unknown; validFrom: Date; validUntil: Date }; + }; + expect(createArg.data.payload).toEqual([]); + expect(createArg.data.validFrom).toEqual(previousBoundary); + expect(createArg.data.validUntil).toBeInstanceOf(Date); + }); + + it("does NOT archive an era when a scheduled medication replaces schedules from zero rows", async () => { + // A non-as-needed medication with no previous rows (fresh create path) + // keeps the v1.16.3 behaviour: nothing to archive. + mockExisting({ asNeeded: false, scheduleCount: 0 }); + vi.mocked(getMedicationCategories).mockResolvedValue({} as never); + vi.mocked(prisma.medication.update).mockResolvedValue({ + id: "m1", + userId: "user-1", + asNeeded: false, + schedules: [{ id: "s1" }], + } as never); + const res = await PUT( + putReq({ + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + }); + + it("422s an empty schedules array WITHOUT asNeeded:true (Zod)", async () => { + mockExisting({ scheduleCount: 1 }); + const res = await PUT(putReq({ schedules: [] }), ROUTE_CTX); + expect(res.status).toBe(422); + expect(prisma.medication.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 4f7f52e5a..e2cb4676e 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -561,3 +561,123 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)" ); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.16.11 (#316) — as-needed (PRN, schedule-less) intakes +// ──────────────────────────────────────────────────────────────────── + +describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medication", () => { + function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/med-1/intake", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("records an ad-hoc row (scheduledFor = takenAt) and consumes inventory", async () => { + // One hour in the past — relative instant, no slot semantics. + const takenAt = new Date(Date.now() - 60 * 60 * 1000); + const createdEvent = { + id: "evt-prn-1", + userId: "user-1", + medicationId: "med-1", + scheduledFor: takenAt, + takenAt, + skipped: false, + }; + vi.mocked(prisma.$transaction).mockResolvedValue([createdEvent] as never); + // Slot-snap resolver loads the med: an as-needed medication carries + // ZERO schedules, so band attribution yields no slot → standalone + // ad-hoc insert (the documented PRN path). + vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + schedules: [], + } as never); + // Dedup probe — no recent duplicate. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce( + null as never, + ); + // One-shot reconcile probe — not a one-shot. + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + oneShot: false, + active: true, + } as never); + vi.mocked(prisma.medication.updateMany).mockResolvedValue({ + count: 0, + } as never); + + const res = await POST( + postReq({ takenAt: takenAt.toISOString(), skipped: false }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(201); + + // Standalone insert anchored on the intake instant — the ad-hoc + // contract (`scheduledFor = takenAt`), no slot attribution. + const txArg = vi.mocked(prisma.$transaction).mock.calls[0][0]; + expect(Array.isArray(txArg)).toBe(true); + + // Inventory consumption fires exactly as for a scheduled medication — + // the seam is medication-level (eventId + intakeAt), no schedule input. + const { consumeForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + expect(consumeForIntake).toHaveBeenCalledTimes(1); + expect(consumeForIntake).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + medicationId: "med-1", + eventId: "evt-prn-1", + intakeAt: takenAt, + }), + ); + }); + + it("a skipped ad-hoc write never consumes", async () => { + const skipEvent = { + id: "evt-prn-2", + userId: "user-1", + medicationId: "med-1", + scheduledFor: new Date(), + takenAt: null, + skipped: true, + }; + vi.mocked(prisma.$transaction).mockResolvedValue([skipEvent] as never); + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + schedules: [], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + oneShot: false, + active: true, + } as never); + vi.mocked(prisma.medication.updateMany).mockResolvedValue({ + count: 0, + } as never); + + const res = await POST(postReq({ skipped: true }), ROUTE_PARAMS); + expect(res.status).toBe(201); + + const { consumeForIntake, restoreForIntake } = await import( + "@/lib/medications/inventory/consumption" + ); + expect(consumeForIntake).not.toHaveBeenCalled(); + expect(restoreForIntake).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index d8b7b4263..596a358ee 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -199,7 +199,15 @@ export const PUT = apiHandler( if (guard) return guard; const existing = await prisma.medication.findUnique({ where: { id }, - select: { active: true, createdAt: true }, + // v1.16.11 — `asNeeded` + the schedule count feed the as-needed + // invariants below: a flip to as-needed must end schedule-less, + // a flip back to scheduled must end with at least one schedule. + select: { + active: true, + createdAt: true, + asNeeded: true, + _count: { select: { schedules: true } }, + }, }); if (!existing) { return apiError("Medication not found", 404); @@ -236,9 +244,43 @@ export const PUT = apiHandler( startsOn, endsOn, oneShot, + asNeeded, reminderGraceMinutes: topLevelGraceMinutes, } = parsed.data; + // ── v1.16.11 (#316) — as-needed invariants ────────────────────── + // + // An as-needed medication ends schedule-less, always. The Zod layer + // already rejects a populated `schedules` array alongside + // `asNeeded: true`; the route covers the cross-row cases the schema + // cannot see: + // - flipping an existing scheduled medication to as-needed + // requires `schedules: []` in the same request (keeping the old + // rows alongside the flag is a 422, per the feature contract); + // - flipping as-needed OFF requires the medication to end with at + // least one schedule (the wizard always sends the full array). + const effectiveAsNeeded = asNeeded ?? existing.asNeeded; + if (effectiveAsNeeded) { + const endsScheduleless = schedules + ? schedules.length === 0 + : existing._count.schedules === 0; + if (!endsScheduleless) { + return apiError( + "An as-needed medication cannot carry schedules (send schedules: [] to clear them)", + 422, + ); + } + } else if ( + asNeeded === false && + existing.asNeeded && + (!schedules || schedules.length === 0) + ) { + return apiError( + "A scheduled medication requires at least one schedule", + 422, + ); + } + // ── v1.5.5 — primary-schedule grace bridge ────────────────────── // // The detail-page settings section saves the reminder-window in @@ -452,6 +494,42 @@ export const PUT = apiHandler( }, meta: { schedule_revision_rows: previousEntries.length }, }); + } else if ( + previousRows.length === 0 && + existing.asNeeded && + asNeeded === false && + normalisedSchedules.length > 0 + ) { + // v1.16.11 — flipping as-needed OFF. The medication carried ZERO + // schedule rows while as-needed, so the wholesale-replace archive + // above never fires — and without a revision the live era would + // start at the PREVIOUS revision's `validUntil` (or `createdAt`), + // retro-painting the schedule-less as-needed stretch with the NEW + // schedule's expected slots: every PRN day would read as missed. + // Archive an EMPTY era covering the as-needed stretch instead; an + // empty payload expands to zero schedules, so era-aware compliance + // expects nothing there — exactly the as-needed contract. + const lastRevision = await prisma.medicationScheduleRevision.findFirst({ + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + await prisma.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: [] as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { schedule_revision_rows: 0 }, + }); } // A schedule replace invalidates the open slot anchors the projector / // reminder worker minted for the OLD times: a pending 08:00 row for a @@ -540,6 +618,9 @@ export const PUT = apiHandler( ...(startsOn !== undefined && { startsOn }), ...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }), ...(oneShot !== undefined && { oneShot }), + // v1.16.11 — as-needed flag, field-by-field (the invariants above + // already guaranteed the medication ends schedule-consistent). + ...(asNeeded !== undefined && { asNeeded }), ...(normalisedSchedules && { schedules: { create: normalisedSchedules.map((n) => n.createData), diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 5f54b423e..af28f18f0 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -417,3 +417,85 @@ describe("GET /api/medications — todayEventCount counts only actioned rows (v1 expect(where.OR).toEqual([{ takenAt: { not: null } }, { skipped: true }]); }); }); + +describe("POST /api/medications — as-needed (v1.16.11, #316)", () => { + function lastCreateData(): Record & { + schedules: { create: Record[] }; + } { + const calls = vi.mocked(prisma.medication.create).mock.calls; + expect(calls.length).toBeGreaterThan(0); + return (calls[calls.length - 1][0] as { data: never }).data; + } + + it("422s when asNeeded:true carries a schedules array", async () => { + const res = await POST( + postReq({ + name: "Ibuprofen", + dose: "400 mg", + asNeeded: true, + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + }; + expect( + body.details.issues.some((i) => + i.message.includes("as-needed medication cannot carry schedules"), + ), + ).toBe(true); + expect(prisma.medication.create).not.toHaveBeenCalled(); + }); + + it("422s when asNeeded:true is combined with oneShot:true", async () => { + const res = await POST( + postReq({ + name: "Ibuprofen", + dose: "400 mg", + asNeeded: true, + oneShot: true, + startsOn: "2026-06-01", + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ message: string }> }; + }; + expect( + body.details.issues.some((i) => + i.message.includes("mutually exclusive"), + ), + ).toBe(true); + }); + + it("creates an as-needed medication with ZERO schedules", async () => { + const res = await POST( + postReq({ + name: "Ibuprofen", + dose: "400 mg", + category: "PAIN_RELIEF", + unitsPerDose: 2, + asNeeded: true, + }), + ); + expect(res.status).toBe(201); + const data = lastCreateData(); + expect(data.asNeeded).toBe(true); + expect(data.oneShot).toBeUndefined(); + expect(data.schedules.create).toEqual([]); + // Dose fields stay first-class — they feed inventory consumption. + expect(data.unitsPerDose).toBe(2); + }); + + it("still 422s a scheduled create without any schedule (legacy contract)", async () => { + const res = await POST(postReq({ name: "Ramipril", dose: "5 mg" })); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + }; + expect( + body.details.issues.some((i) => i.path.includes("schedules")), + ).toBe(true); + }); +}); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 11ad38d1e..d4d66f175 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -46,7 +46,10 @@ export const GET = apiHandler(async () => { // Same ordering as the medications list so the page's card order and // this payload walk the same sequence. const medications = await prisma.medication.findMany({ - where: { userId: user.id }, + // v1.16.11 — as-needed (PRN) medications carry no compliance entry: + // the cards/table render a last-taken presentation for them instead + // of rates, and an empty expected set must not read as 0% or 100%. + where: { userId: user.id, asNeeded: false }, include: { schedules: true, // v1.16.3 — archived schedule eras for era-aware compliance. diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index a09280791..d1c15d0f9 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -528,6 +528,25 @@ describe("v1.15.9 — schedule-anchored compliance buckets (BUG #1)", () => { // engine's expected-dose count per day (not the count of logged intake // rows). With partial adherence the per-day rate `taken / scheduled` // genuinely reflects taken-of-expected — it is NOT pinned at ~100%. + // + // The clock is pinned to a fixed midday instant: the fixtures mint one + // event per UTC day, but the route buckets by the user's local + // (Europe/Berlin) day — with a live clock, a run between 00:00 and + // ~01:59 local (UTC date still "yesterday") left the local TODAY + // bucket with `scheduled = 1, taken = 0` and failed the full-adherence + // assertion. At 12:00 UTC the UTC day and the Berlin day coincide, so + // the per-UTC-day fixture math is exact. Only `Date` is faked — the + // route awaits real promises. + beforeEach(() => { + vi.useFakeTimers({ + now: new Date("2026-06-10T12:00:00Z"), + toFake: ["Date"], + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); function activeDailyMed(createdAt: Date) { return { id: "m1", diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 219c633f4..74273246a 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -309,6 +309,7 @@ export const POST = apiHandler(async (request: NextRequest) => { startsOn, endsOn, oneShot, + asNeeded, } = parsed.data; // ── v1.5 route invariants for the new scheduling primitives ─────── @@ -335,15 +336,24 @@ export const POST = apiHandler(async (request: NextRequest) => { // always sees a populated `timesOfDay` while legacy iOS clients // (v0.6.x) keep encoding the `windowStart` / `windowEnd` / // `daysOfWeek` shape. Both shapes coexist through v1.5.x. + // 4. **As-needed (v1.16.11, #316)** — when `asNeeded === true` the + // medication carries ZERO schedules (the Zod refine already + // rejects a populated array, mutual exclusion with `oneShot` + // included). Every "never due / never reminded / never scored" + // surface follows structurally from the empty schedule list. + const scheduleInputs = schedules ?? []; if (oneShot === true) { - if (schedules.length > 1) { + if (scheduleInputs.length > 1) { return apiError( "A one-shot medication can have at most one schedule", 422, ); } - const s = schedules[0]; - if (s.rrule !== undefined || s.rollingIntervalDays !== undefined) { + const s = scheduleInputs[0]; + if ( + s && + (s.rrule !== undefined || s.rollingIntervalDays !== undefined) + ) { return apiError( "A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)", 422, @@ -387,8 +397,12 @@ export const POST = apiHandler(async (request: NextRequest) => { ...(startsOn !== undefined && { startsOn }), ...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }), ...(oneShot !== undefined && { oneShot }), + // v1.16.11 — as-needed flag, field-by-field. An asNeeded create + // carries an empty `scheduleInputs`, so the nested create below + // persists zero schedule rows. + ...(asNeeded !== undefined && { asNeeded }), schedules: { - create: schedules.map((s) => { + create: scheduleInputs.map((s) => { // Invariant 2 — default to FREQ=DAILY when nothing else is set. // v1.7.0 — PRN schedules carry no cadence, so never default // them to FREQ=DAILY (they would otherwise project + remind). diff --git a/src/app/api/mood/tags/__tests__/custom-crud.test.ts b/src/app/api/mood/tags/__tests__/custom-crud.test.ts index 161ff255e..e9d3a3ded 100644 --- a/src/app/api/mood/tags/__tests__/custom-crud.test.ts +++ b/src/app/api/mood/tags/__tests__/custom-crud.test.ts @@ -14,6 +14,7 @@ const db = vi.hoisted(() => ({ update: vi.fn(), delete: vi.fn(), }, + moodTagCategory: { findFirst: vi.fn() }, moodTagHidden: { upsert: vi.fn(), deleteMany: vi.fn() }, })); @@ -74,6 +75,42 @@ describe("POST /api/mood/tags/custom", () => { expect(res.status).toBe(422); expect(db.moodTag.create).not.toHaveBeenCalled(); }); + + it("defaults to the seeded custom category without a categoryKey (v1.13 contract)", async () => { + db.moodTag.count.mockResolvedValue(0); + db.moodTag.create.mockResolvedValue({ + key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, + }); + await POST(jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" })); + expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled(); + expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe("mtc_custom"); + }); + + it("resolves categoryKey to a seeded OR own group (v1.17.0) and 422s an unknown one", async () => { + db.moodTag.count.mockResolvedValue(0); + db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" }); + db.moodTag.create.mockResolvedValue({ + key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, + }); + const ok = await POST( + jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X", categoryKey: "customcat:g1" }), + ); + expect(ok.status).toBe(201); + expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe("cg1"); + // Resolution is owner-scoped: seeded (userId null) OR the caller's own. + const catWhere = db.moodTagCategory.findFirst.mock.calls[0][0].where; + expect(catWhere).toEqual({ + key: "customcat:g1", + isActive: true, + OR: [{ userId: null }, { userId: "user-1" }], + }); + + db.moodTagCategory.findFirst.mockResolvedValue(null); + const bad = await POST( + jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X", categoryKey: "customcat:theirs" }), + ); + expect(bad.status).toBe(422); + }); }); describe("PATCH/DELETE /api/mood/tags/custom/:key", () => { @@ -94,6 +131,32 @@ describe("PATCH/DELETE /api/mood/tags/custom/:key", () => { expect(db.moodTag.findFirst).not.toHaveBeenCalled(); }); + it("moves a custom tag to another group via categoryKey (v1.17.0)", async () => { + db.moodTag.findFirst.mockResolvedValue({ id: "id1" }); + db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" }); + db.moodTag.update.mockResolvedValue({ + key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5, + inverse: false, isActive: true, labelEncrypted: "enc:X", + }); + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { categoryKey: "customcat:g1" }), + params("custom:x"), + ); + expect(res.status).toBe(200); + expect(db.moodTag.update.mock.calls[0][0].data).toEqual({ categoryId: "cg1" }); + }); + + it("422s a move to an unknown / foreign group without writing", async () => { + db.moodTag.findFirst.mockResolvedValue({ id: "id1" }); + db.moodTagCategory.findFirst.mockResolvedValue(null); + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { categoryKey: "ghost" }), + params("custom:x"), + ); + expect(res.status).toBe(422); + expect(db.moodTag.update).not.toHaveBeenCalled(); + }); + it("soft-deactivates by default and hard-deletes on ?purge=true", async () => { db.moodTag.findFirst.mockResolvedValue({ id: "id1" }); const soft = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/custom:abc"), params("custom:abc")); diff --git a/src/app/api/mood/tags/__tests__/groups-crud.test.ts b/src/app/api/mood/tags/__tests__/groups-crud.test.ts new file mode 100644 index 000000000..b4441dc31 --- /dev/null +++ b/src/app/api/mood/tags/__tests__/groups-crud.test.ts @@ -0,0 +1,230 @@ +/** + * v1.17.0 — custom mood-tag group CRUD guards: create cap, ownership 404 on + * edit/delete, and the non-destructive delete (re-home own tags → seeded + * `custom`, strip the group from the layout blob, soft-deactivate by default + * vs hard-delete on ?purge=true). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const db = vi.hoisted(() => { + const base = { + moodTagCategory: { + count: vi.fn(), + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + moodTag: { updateMany: vi.fn() }, + user: { findUnique: vi.fn(), update: vi.fn() }, + $transaction: vi.fn(), + }; + // Interactive tx: hand the same mock surface to the callback. + base.$transaction.mockImplementation( + async (fn: (tx: typeof base) => Promise) => fn(base), + ); + return base; +}); + +vi.mock("@/lib/db", () => ({ + prisma: db, + toJson: (v: T) => v, +})); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (s: string) => s.replace(/^enc:/, ""), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined) })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined) })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), +})); + +import { POST } from "../groups/route"; +import { PATCH, DELETE } from "../groups/[key]/route"; +import { getSession } from "@/lib/auth/session"; +import { CUSTOM_CATEGORY_ID } from "@/lib/mood/custom-tags"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "t", role: "USER" as const }, +}; + +function jsonReq(url: string, method: string, body: unknown) { + return new NextRequest(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const params = (key: string) => ({ params: Promise.resolve({ key }) }); + +beforeEach(() => { + vi.clearAllMocks(); + db.$transaction.mockImplementation( + async (fn: (tx: typeof db) => Promise) => fn(db), + ); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("POST /api/mood/tags/groups", () => { + it("creates a group and returns it (custom:true, decrypted label)", async () => { + db.moodTagCategory.count.mockResolvedValue(2); + db.moodTagCategory.create.mockResolvedValue({ + key: "customcat:abc", + icon: "Stethoscope", + }); + const res = await POST( + jsonReq("http://localhost/api/mood/tags/groups", "POST", { + label: "Therapie", + icon: "Stethoscope", + }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { key: string; label: string; labelKey: null; custom: boolean }; + }; + expect(body.data).toMatchObject({ + key: "customcat:abc", + label: "Therapie", + labelKey: null, + custom: true, + }); + const data = db.moodTagCategory.create.mock.calls[0][0].data; + // Label is stored encrypted, never plaintext; owner pinned from session. + expect(data.labelEncrypted).toBe("enc:Therapie"); + expect(data.userId).toBe("user-1"); + expect(data.key.startsWith("customcat:")).toBe(true); + }); + + it("rejects over the per-user cap with 422", async () => { + db.moodTagCategory.count.mockResolvedValue(12); + const res = await POST( + jsonReq("http://localhost/api/mood/tags/groups", "POST", { label: "X" }), + ); + expect(res.status).toBe(422); + expect(db.moodTagCategory.create).not.toHaveBeenCalled(); + }); +}); + +describe("PATCH /api/mood/tags/groups/:key", () => { + it("404s a seeded category key without touching the DB", async () => { + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/groups/feelings", "PATCH", { label: "x" }), + params("feelings"), + ); + expect(res.status).toBe(404); + expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled(); + }); + + it("404s when the key is not the caller's own group", async () => { + db.moodTagCategory.findFirst.mockResolvedValue(null); + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", { label: "x" }), + params("customcat:x"), + ); + expect(res.status).toBe(404); + }); + + it("renames (re-encrypts) and returns the updated group", async () => { + db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" }); + db.moodTagCategory.update.mockResolvedValue({ + key: "customcat:x", + icon: null, + isActive: true, + labelEncrypted: "enc:Neu", + }); + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", { label: "Neu" }), + params("customcat:x"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { label: string; isActive: boolean } }; + expect(body.data).toMatchObject({ label: "Neu", isActive: true }); + expect(db.moodTagCategory.update.mock.calls[0][0].data).toEqual({ + labelEncrypted: "enc:Neu", + }); + }); +}); + +describe("DELETE /api/mood/tags/groups/:key", () => { + beforeEach(() => { + db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" }); + db.moodTag.updateMany.mockResolvedValue({ count: 2 }); + db.user.findUnique.mockResolvedValue({ + moodTagLayoutJson: { + groupOrder: ["customcat:x", "feelings"], + placements: { "customcat:x": ["happy"], feelings: ["sad"] }, + }, + }); + }); + + it("re-homes own tags, strips the layout, soft-deactivates by default", async () => { + const res = await DELETE( + new NextRequest("http://localhost/api/mood/tags/groups/customcat:x"), + params("customcat:x"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { purged: boolean; rehomedCount: number }; + }; + expect(body.data).toMatchObject({ purged: false, rehomedCount: 2 }); + + // Own custom tags re-home to the seeded custom category. + expect(db.moodTag.updateMany).toHaveBeenCalledWith({ + where: { categoryId: "cg1", userId: "user-1" }, + data: { categoryId: CUSTOM_CATEGORY_ID }, + }); + // Layout blob drops the group from order + placements, keeps the rest. + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + moodTagLayoutJson: { + groupOrder: ["feelings"], + placements: { feelings: ["sad"] }, + }, + }, + }); + // Default = retire, never delete. + expect(db.moodTagCategory.update).toHaveBeenCalledWith({ + where: { id: "cg1" }, + data: { isActive: false }, + }); + expect(db.moodTagCategory.delete).not.toHaveBeenCalled(); + }); + + it("hard-deletes the emptied group row on ?purge=true", async () => { + const res = await DELETE( + new NextRequest("http://localhost/api/mood/tags/groups/customcat:x?purge=true"), + params("customcat:x"), + ); + expect(res.status).toBe(200); + expect(db.moodTagCategory.delete).toHaveBeenCalledWith({ + where: { id: "cg1" }, + }); + expect(db.moodTagCategory.update).not.toHaveBeenCalled(); + // Re-home still ran first — purge removes the group, never the tags. + expect(db.moodTag.updateMany).toHaveBeenCalled(); + }); + + it("404s a non-custom key and a foreign group", async () => { + const seeded = await DELETE( + new NextRequest("http://localhost/api/mood/tags/groups/custom"), + params("custom"), + ); + expect(seeded.status).toBe(404); + + db.moodTagCategory.findFirst.mockResolvedValue(null); + const foreign = await DELETE( + new NextRequest("http://localhost/api/mood/tags/groups/customcat:theirs"), + params("customcat:theirs"), + ); + expect(foreign.status).toBe(404); + expect(db.moodTag.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mood/tags/__tests__/layout.test.ts b/src/app/api/mood/tags/__tests__/layout.test.ts new file mode 100644 index 000000000..044b1dacb --- /dev/null +++ b/src/app/api/mood/tags/__tests__/layout.test.ts @@ -0,0 +1,121 @@ +/** + * v1.17.0 — `GET/PUT /api/mood/tags/layout`: resolved groupOrder over the + * effective category set, preserve-when-absent merge on PUT, and the Zod + * bounds on the blob. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const db = vi.hoisted(() => ({ + user: { findUnique: vi.fn(), update: vi.fn() }, + moodTagCategory: { findMany: vi.fn() }, +})); + +vi.mock("@/lib/db", () => ({ + prisma: db, + toJson: (v: T) => v, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined) })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined) })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), +})); + +import { GET, PUT } from "../layout/route"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "t", role: "USER" as const }, +}; + +const CATEGORY_KEYS = [ + { key: "feelings" }, + { key: "custom" }, + { key: "customcat:g1" }, +]; + +function putReq(body: unknown) { + return new NextRequest("http://localhost/api/mood/tags/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + db.moodTagCategory.findMany.mockResolvedValue(CATEGORY_KEYS); + db.user.findUnique.mockResolvedValue({ moodTagLayoutJson: null }); +}); + +describe("GET /api/mood/tags/layout", () => { + it("returns seeded defaults when nothing is stored", async () => { + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { groupOrder: string[]; placements: Record }; + }; + expect(body.data).toEqual({ + groupOrder: ["feelings", "custom", "customcat:g1"], + placements: {}, + }); + }); + + it("resolves the stored order: layout first, unknown dropped, missing appended", async () => { + db.user.findUnique.mockResolvedValue({ + moodTagLayoutJson: { + groupOrder: ["customcat:g1", "ghost", "feelings"], + placements: { "customcat:g1": ["happy"] }, + }, + }); + const res = await GET(); + const body = (await res.json()) as { + data: { groupOrder: string[]; placements: Record }; + }; + expect(body.data.groupOrder).toEqual(["customcat:g1", "feelings", "custom"]); + expect(body.data.placements).toEqual({ "customcat:g1": ["happy"] }); + }); +}); + +describe("PUT /api/mood/tags/layout", () => { + it("merges preserve-when-absent: a groupOrder-only PUT keeps stored placements", async () => { + db.user.findUnique.mockResolvedValue({ + moodTagLayoutJson: { placements: { feelings: ["sad"] } }, + }); + const res = await PUT(putReq({ groupOrder: ["custom", "feelings"] })); + expect(res.status).toBe(200); + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + moodTagLayoutJson: { + groupOrder: ["custom", "feelings"], + placements: { feelings: ["sad"] }, + }, + }, + }); + const body = (await res.json()) as { + data: { groupOrder: string[]; placements: Record }; + }; + expect(body.data.groupOrder).toEqual(["custom", "feelings", "customcat:g1"]); + expect(body.data.placements).toEqual({ feelings: ["sad"] }); + }); + + it("422s an over-bound blob without writing", async () => { + const res = await PUT( + putReq({ groupOrder: Array.from({ length: 51 }, (_, i) => `g${i}`) }), + ); + expect(res.status).toBe(422); + expect(db.user.update).not.toHaveBeenCalled(); + }); + + it("422s a malformed body (non-array groupOrder)", async () => { + const res = await PUT(putReq({ groupOrder: "feelings" })); + expect(res.status).toBe(422); + expect(db.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mood/tags/__tests__/route.test.ts b/src/app/api/mood/tags/__tests__/route.test.ts index 6e300d3c2..96c11e5e8 100644 --- a/src/app/api/mood/tags/__tests__/route.test.ts +++ b/src/app/api/mood/tags/__tests__/route.test.ts @@ -1,9 +1,11 @@ /** - * v1.12.0 / v1.13.0 — `GET /api/mood/tags` exposes the rated-factor metadata - * (`kind` / `scaleMin` / `scaleMax` / `inverse`) AND the v1.13.0 effective + * v1.12.0 / v1.13.0 / v1.17.0 — `GET /api/mood/tags` exposes the rated-factor + * metadata (`kind` / `scaleMin` / `scaleMax` / `inverse`) AND the effective * per-user set: own custom tags merged in (label decrypted, `custom: true`), * hidden catalogue tags omitted by default and surfaced under - * `?include=hidden`. + * `?include=hidden`. v1.17.0 layers custom groups, the layout blob (group + * order + placements), `include=archived` (own inactive customs) and + * `include=usage` (per-tag live-entry counts) on top. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; @@ -11,12 +13,16 @@ import { NextRequest } from "next/server"; const categoryFindMany = vi.fn(); const tagFindMany = vi.fn(); const hiddenFindMany = vi.fn(); +const userFindUnique = vi.fn(); +const linkGroupBy = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { moodTagCategory: { findMany: (...a: unknown[]) => categoryFindMany(...a) }, moodTag: { findMany: (...a: unknown[]) => tagFindMany(...a) }, moodTagHidden: { findMany: (...a: unknown[]) => hiddenFindMany(...a) }, + user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, + moodEntryTagLink: { groupBy: (...a: unknown[]) => linkGroupBy(...a) }, }, })); @@ -52,16 +58,40 @@ const SESSION_OK = { }; const CATEGORIES = [ - { id: "c1", key: "feelings", labelKey: "mood.tagCategory.feelings", icon: "Smile" }, - { id: "mtc_custom", key: "custom", labelKey: "mood.tagCategory.custom", icon: "Tag" }, + { id: "c1", key: "feelings", labelKey: "mood.tagCategory.feelings", icon: "Smile", userId: null, labelEncrypted: null }, + { id: "mtc_custom", key: "custom", labelKey: "mood.tagCategory.custom", icon: "Tag", userId: null, labelEncrypted: null }, ]; +const OWN_GROUP = { + id: "cg1", + key: "customcat:g1", + labelKey: "customcat:g1", + icon: "Stethoscope", + userId: "user-1", + labelEncrypted: "enc:Therapie", +}; + const TAGS = [ - { id: "t_happy", categoryId: "c1", key: "happy", labelKey: "mood.tag.happy", icon: "Smile", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: null, labelEncrypted: null }, - { id: "t_sad", categoryId: "c1", key: "sad", labelKey: "mood.tag.sad", icon: "Frown", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: null, labelEncrypted: null }, - { id: "t_custom", categoryId: "mtc_custom", key: "custom:abc", labelKey: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: "user-1", labelEncrypted: "enc:Migräne" }, + { id: "t_happy", categoryId: "c1", key: "happy", labelKey: "mood.tag.happy", icon: "Smile", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: null, labelEncrypted: null }, + { id: "t_sad", categoryId: "c1", key: "sad", labelKey: "mood.tag.sad", icon: "Frown", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: null, labelEncrypted: null }, + { id: "t_custom", categoryId: "mtc_custom", key: "custom:abc", labelKey: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: "user-1", labelEncrypted: "enc:Migräne" }, ]; +const ARCHIVED_TAG = { + id: "t_arch", + categoryId: "mtc_custom", + key: "custom:arch", + labelKey: "custom:arch", + icon: null, + kind: "BINARY", + scaleMin: 1, + scaleMax: 5, + inverse: false, + isActive: false, + userId: "user-1", + labelEncrypted: "enc:Alt", +}; + interface TagOut { key: string; labelKey: string | null; @@ -72,9 +102,19 @@ interface TagOut { scaleMax: number; inverse: boolean; hidden?: boolean; + archived?: boolean; + usageCount?: number; +} +interface CategoryOut { + key: string; + labelKey: string | null; + label: string | null; + icon: string | null; + custom: boolean; + tags: TagOut[]; } interface Body { - data: { categories: Array<{ key: string; tags: TagOut[] }> }; + data: { categories: CategoryOut[] }; } function req(url = "http://localhost/api/mood/tags") { @@ -87,6 +127,8 @@ beforeEach(() => { categoryFindMany.mockResolvedValue(CATEGORIES); tagFindMany.mockResolvedValue(TAGS); hiddenFindMany.mockResolvedValue([{ moodTagId: "t_sad" }]); + userFindUnique.mockResolvedValue({ moodTagLayoutJson: null }); + linkGroupBy.mockResolvedValue([]); }); function flat(body: Body): Record { @@ -126,6 +168,9 @@ describe("GET /api/mood/tags — effective per-user set (v1.13.0)", () => { // Tag query scopes to catalogue OR the caller's own customs. const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where; expect(tagWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]); + + // Plain picker read never pays the usage groupBy. + expect(linkGroupBy).not.toHaveBeenCalled(); }); it("surfaces hidden catalogue tags with hidden:true under ?include=hidden", async () => { @@ -137,3 +182,113 @@ describe("GET /api/mood/tags — effective per-user set (v1.13.0)", () => { expect(tags["custom:abc"].hidden).toBeUndefined(); }); }); + +describe("GET /api/mood/tags — groups + layout + archived + usage (v1.17.0)", () => { + it("returns own custom groups with decrypted label and custom:true on categories", async () => { + categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]); + tagFindMany.mockResolvedValue([ + ...TAGS, + { ...TAGS[2], id: "t_c2", categoryId: "cg1", key: "custom:ing1", labelKey: "custom:ing1", labelEncrypted: "enc:Sitzung" }, + ]); + const res = await GET(req()); + const body = (await res.json()) as Body; + const group = body.data.categories.find((c) => c.key === "customcat:g1"); + expect(group).toMatchObject({ + custom: true, + label: "Therapie", + labelKey: null, + icon: "Stethoscope", + }); + expect(group?.tags.map((t) => t.key)).toEqual(["custom:ing1"]); + // Seeded categories stay labelKey-driven. + const feelings = body.data.categories.find((c) => c.key === "feelings"); + expect(feelings).toMatchObject({ + custom: false, + label: null, + labelKey: "mood.tagCategory.feelings", + }); + // Category query scopes to seeded OR own groups. + const catWhere = categoryFindMany.mock.calls[0]?.[0]?.where; + expect(catWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]); + }); + + it("applies the layout blob: group order + catalogue-tag placement", async () => { + categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]); + userFindUnique.mockResolvedValue({ + moodTagLayoutJson: { + groupOrder: ["customcat:g1", "custom", "feelings"], + placements: { "customcat:g1": ["happy"] }, + }, + }); + const res = await GET(req()); + const body = (await res.json()) as Body; + // `feelings` ends empty (happy placed away, sad hidden) → dropped. + expect(body.data.categories.map((c) => c.key)).toEqual([ + "customcat:g1", + "custom", + ]); + // Catalogue tag `happy` rendered inside the user's group (placement, + // not a categoryId change) — gone from its home category. + const group = body.data.categories.find((c) => c.key === "customcat:g1"); + expect(group?.tags.map((t) => t.key)).toEqual(["happy"]); + const feelings = body.data.categories.find((c) => c.key === "feelings"); + expect(feelings).toBeUndefined(); // only hidden `sad` left → dropped + }); + + it("drops a stale layout placement (hidden tag) silently", async () => { + categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]); + userFindUnique.mockResolvedValue({ + moodTagLayoutJson: { + placements: { "customcat:g1": ["sad", "ghost"] }, // sad is hidden + }, + }); + const res = await GET(req()); + const body = (await res.json()) as Body; + expect( + body.data.categories.find((c) => c.key === "customcat:g1"), + ).toBeUndefined(); // nothing visible placed → empty → dropped (plain read) + }); + + it("returns archived own customs with archived:true under include=archived and keeps empty own groups", async () => { + categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]); + tagFindMany.mockResolvedValue([...TAGS, ARCHIVED_TAG]); + const res = await GET( + req("http://localhost/api/mood/tags?include=hidden,archived"), + ); + const body = (await res.json()) as Body; + const tags = flat(body); + expect(tags["custom:arch"]).toMatchObject({ + custom: true, + archived: true, + label: "Alt", + }); + expect(tags["custom:abc"]).toMatchObject({ archived: false }); + // Catalogue rows never carry the archived flag. + expect(tags.happy.archived).toBeUndefined(); + // The archived read lifts isActive only for the caller's own rows. + const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where; + expect(tagWhere).toEqual({ + OR: [{ userId: null, isActive: true }, { userId: "user-1" }], + }); + // Empty own group kept on a management read. + expect( + body.data.categories.find((c) => c.key === "customcat:g1"), + ).toMatchObject({ custom: true, tags: [] }); + }); + + it("emits usageCount per tag under include=usage (live entries only)", async () => { + linkGroupBy.mockResolvedValue([ + { moodTagId: "t_happy", _count: { _all: 7 } }, + ]); + const res = await GET(req("http://localhost/api/mood/tags?include=usage")); + const tags = flat((await res.json()) as Body); + expect(tags.happy.usageCount).toBe(7); + expect(tags["custom:abc"].usageCount).toBe(0); + expect(linkGroupBy).toHaveBeenCalledWith( + expect.objectContaining({ + by: ["moodTagId"], + where: { moodEntry: { userId: "user-1", deletedAt: null } }, + }), + ); + }); +}); diff --git a/src/app/api/mood/tags/custom/[key]/route.ts b/src/app/api/mood/tags/custom/[key]/route.ts index 697489bbf..3b28228e7 100644 --- a/src/app/api/mood/tags/custom/[key]/route.ts +++ b/src/app/api/mood/tags/custom/[key]/route.ts @@ -15,20 +15,27 @@ import { decryptCustomLabel, isCustomTagKey, } from "@/lib/mood/custom-tags"; +import { resolveCategoryKeyForUser } from "@/lib/mood/tag-groups"; export const dynamic = "force-dynamic"; type RouteParams = { params: Promise<{ key: string }> }; /** - * v1.13.0 — update / delete a per-user custom mood tag. + * v1.13.0 / v1.17.0 — update / delete a per-user custom mood tag. * * Both handlers resolve the `custom:`-prefixed key against the CALLER's own * rows only — another user's key (or a catalogue key) is a 404, so a tag can * never be edited or removed across the ownership boundary. */ -/** `PATCH /api/mood/tags/custom/:key` — rename / recolour / (de)activate. */ +/** + * `PATCH /api/mood/tags/custom/:key` — rename / re-icon / (de)activate / + * move to another group (v1.17.0 `categoryKey`: any seeded category or an + * own `customcat:` group; unknown / foreign → 422). The move is a real + * `categoryId` update — custom tags belong relationally, only catalogue + * tags "move" via the layout blob. + */ export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -48,6 +55,16 @@ export const PATCH = apiHandler( }); if (!owned) return apiError("Custom tag not found", 404); + let categoryId: string | undefined; + if (parsed.data.categoryKey !== undefined) { + const resolved = await resolveCategoryKeyForUser( + parsed.data.categoryKey, + user.id, + ); + if (!resolved) return apiError("Unknown category", 422); + categoryId = resolved; + } + const updated = await prisma.moodTag.update({ where: { id: owned.id }, data: { @@ -58,6 +75,7 @@ export const PATCH = apiHandler( ...(parsed.data.isActive !== undefined ? { isActive: parsed.data.isActive } : {}), + ...(categoryId !== undefined ? { categoryId } : {}), }, select: { key: true, diff --git a/src/app/api/mood/tags/custom/route.ts b/src/app/api/mood/tags/custom/route.ts index d5cf6d30e..4aece227e 100644 --- a/src/app/api/mood/tags/custom/route.ts +++ b/src/app/api/mood/tags/custom/route.ts @@ -16,15 +16,20 @@ import { CUSTOM_CATEGORY_ID, MAX_CUSTOM_TAGS_PER_USER, } from "@/lib/mood/custom-tags"; +import { resolveCategoryKeyForUser } from "@/lib/mood/tag-groups"; export const dynamic = "force-dynamic"; /** - * v1.13.0 — create a per-user custom mood tag. + * v1.13.0 / v1.17.0 — create a per-user custom mood tag. * * `POST /api/mood/tags/custom` — body `{ label, icon?, categoryKey? }`. Mints * a `custom:` key, encrypts the label at rest, and stores a BINARY tag - * under the global `custom` category owned by the caller. Capped per user. + * owned by the caller. `categoryKey` (v1.17.0) picks the home group — any + * seeded category key or one of the caller's own `customcat:` groups; an + * unknown or foreign key is a 422. Omitted → the seeded `custom` category + * (the v1.13.0 behaviour, byte-identical for existing clients). Capped per + * user. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -46,10 +51,20 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } + let categoryId = CUSTOM_CATEGORY_ID; + if (parsed.data.categoryKey !== undefined) { + const resolved = await resolveCategoryKeyForUser( + parsed.data.categoryKey, + user.id, + ); + if (!resolved) return apiError("Unknown category", 422); + categoryId = resolved; + } + const key = mintCustomTagKey(); const created = await prisma.moodTag.create({ data: { - categoryId: CUSTOM_CATEGORY_ID, + categoryId, key, // Catalogue rows resolve `labelKey` against the locale; a custom tag // renders its decrypted `label` instead, so labelKey just mirrors the diff --git a/src/app/api/mood/tags/groups/[key]/route.ts b/src/app/api/mood/tags/groups/[key]/route.ts new file mode 100644 index 000000000..cc94d9483 --- /dev/null +++ b/src/app/api/mood/tags/groups/[key]/route.ts @@ -0,0 +1,152 @@ +import { NextRequest } from "next/server"; + +import { prisma, toJson } from "@/lib/db"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + updateCustomGroupSchema, + encryptCustomLabel, + decryptCustomLabel, + isCustomCategoryKey, + CUSTOM_CATEGORY_ID, +} from "@/lib/mood/custom-tags"; +import { + parseStoredMoodTagLayout, + stripGroupFromLayout, +} from "@/lib/mood/tag-layout"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ key: string }> }; + +/** + * v1.17.0 — update / delete a per-user custom mood-tag group. + * + * Both handlers resolve the `customcat:`-prefixed key against the CALLER's + * own rows only — another user's key (or a seeded category key) is a 404, + * so a group can never be edited or removed across the ownership boundary. + */ + +/** `PATCH /api/mood/tags/groups/:key` — rename / re-icon / (de)activate. */ +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { key } = await params; + if (!isCustomCategoryKey(key)) return apiError("Not a custom group", 404); + + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = updateCustomGroupSchema.safeParse(rawJsonBody); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const owned = await prisma.moodTagCategory.findFirst({ + where: { key, userId: user.id }, + select: { id: true }, + }); + if (!owned) return apiError("Custom group not found", 404); + + const updated = await prisma.moodTagCategory.update({ + where: { id: owned.id }, + data: { + ...(parsed.data.label !== undefined + ? { labelEncrypted: encryptCustomLabel(parsed.data.label) } + : {}), + ...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}), + ...(parsed.data.isActive !== undefined + ? { isActive: parsed.data.isActive } + : {}), + }, + select: { + key: true, + icon: true, + isActive: true, + labelEncrypted: true, + }, + }); + + annotate({ action: { name: "mood.tag.group.update" } }); + + return apiSuccess({ + key: updated.key, + labelKey: null, + label: decryptCustomLabel(updated.labelEncrypted), + icon: updated.icon, + isActive: updated.isActive, + custom: true, + }); + }, +); + +/** + * `DELETE /api/mood/tags/groups/:key` — non-destructive by construction: + * the group's own custom tags re-home to the seeded `custom` category + * (catalogue-tag placements simply evaporate back to their seeded category + * when the layout entry is stripped), the layout blob drops the group, and + * the row soft-deactivates. `?purge=true` hard-deletes the (now empty) + * group row instead. No tag and no entry link is ever deleted here. + */ +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { key } = await params; + if (!isCustomCategoryKey(key)) return apiError("Not a custom group", 404); + + const owned = await prisma.moodTagCategory.findFirst({ + where: { key, userId: user.id }, + select: { id: true }, + }); + if (!owned) return apiError("Custom group not found", 404); + + const purge = request.nextUrl.searchParams.get("purge") === "true"; + + const rehomedCount = await prisma.$transaction(async (tx) => { + // 1. Re-home the caller's custom tags living in this group. Only own + // tags can reference an own group (the categoryKey resolvers + // enforce it), so this empties the group. + const rehomed = await tx.moodTag.updateMany({ + where: { categoryId: owned.id, userId: user.id }, + data: { categoryId: CUSTOM_CATEGORY_ID }, + }); + + // 2. Strip the group from the layout blob (groupOrder + placements). + const userRow = await tx.user.findUnique({ + where: { id: user.id }, + select: { moodTagLayoutJson: true }, + }); + const layout = parseStoredMoodTagLayout(userRow?.moodTagLayoutJson); + if (layout.groupOrder !== undefined || layout.placements !== undefined) { + await tx.user.update({ + where: { id: user.id }, + data: { moodTagLayoutJson: toJson(stripGroupFromLayout(layout, key)) }, + }); + } + + // 3. Retire (default) or hard-delete (purge) the empty group row. + if (purge) { + await tx.moodTagCategory.delete({ where: { id: owned.id } }); + } else { + await tx.moodTagCategory.update({ + where: { id: owned.id }, + data: { isActive: false }, + }); + } + + return rehomed.count; + }); + + annotate({ + action: { name: "mood.tag.group.delete" }, + meta: { purge, rehomed_count: rehomedCount }, + }); + + return apiSuccess({ key, purged: purge, rehomedCount }); + }, +); diff --git a/src/app/api/mood/tags/groups/route.ts b/src/app/api/mood/tags/groups/route.ts new file mode 100644 index 000000000..b0b385809 --- /dev/null +++ b/src/app/api/mood/tags/groups/route.ts @@ -0,0 +1,85 @@ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + createCustomGroupSchema, + mintCustomCategoryKey, + encryptCustomLabel, + MAX_CUSTOM_GROUPS_PER_USER, +} from "@/lib/mood/custom-tags"; + +export const dynamic = "force-dynamic"; + +/** + * v1.17.0 — create a per-user custom mood-tag group. + * + * `POST /api/mood/tags/groups` — body `{ label, icon? }`. Mints a + * `customcat:` key, encrypts the label at rest, and stores a + * category owned by the caller. Capped per user. The group is + * presentation-plus-home only: entry writes are key-based and never see + * categories, so no group change can break `tagKeys` / `ratedFactors` + * ingestion. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createCustomGroupSchema.safeParse(rawJsonBody); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const activeCount = await prisma.moodTagCategory.count({ + where: { userId: user.id, isActive: true }, + }); + if (activeCount >= MAX_CUSTOM_GROUPS_PER_USER) { + return apiError( + `Custom group limit reached (${MAX_CUSTOM_GROUPS_PER_USER})`, + 422, + ); + } + + const key = mintCustomCategoryKey(); + const created = await prisma.moodTagCategory.create({ + data: { + key, + // Seeded rows resolve `labelKey` against the locale; a custom group + // renders its decrypted `label` instead, so labelKey just mirrors the + // key for a stable, non-empty value. + labelKey: key, + labelEncrypted: encryptCustomLabel(parsed.data.label), + icon: parsed.data.icon ?? null, + // After the seeded set (the `custom` category sits at 100) so a fresh + // group appends rather than jumping the seeded categories; the + // per-user layout blob owns the real display order. + sortOrder: 101 + activeCount, + userId: user.id, + }, + select: { key: true, icon: true }, + }); + + annotate({ + action: { name: "mood.tag.group.create" }, + meta: { icon: created.icon }, + }); + + return apiSuccess( + { + key: created.key, + labelKey: null, + label: parsed.data.label, + icon: created.icon, + custom: true, + }, + 201, + ); +}); diff --git a/src/app/api/mood/tags/layout/route.ts b/src/app/api/mood/tags/layout/route.ts new file mode 100644 index 000000000..fee4b79d4 --- /dev/null +++ b/src/app/api/mood/tags/layout/route.ts @@ -0,0 +1,109 @@ +import { NextRequest } from "next/server"; + +import { prisma, toJson } from "@/lib/db"; +import { + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + moodTagLayoutSchema, + parseStoredMoodTagLayout, + mergeMoodTagLayout, + resolveGroupOrder, + type MoodTagLayout, +} from "@/lib/mood/tag-layout"; + +export const dynamic = "force-dynamic"; + +/** + * v1.17.0 — per-user mood-tag layout (group order + tag placements). + * + * GET returns the stored blob merged over defaults: `groupOrder` fully + * resolved against the user's effective category set (layout order first, + * unmentioned categories appended in seeded order) + the raw `placements`. + * PUT updates with preserve-when-absent semantics — a groupOrder-only PUT + * keeps the stored placements and vice versa. Mirrors + * `/api/medications/layout`; the blob lives on its own `User` column + * (`mood_tag_layout_json`) per the per-surface-column convention. + * + * Display-only: keys here are opaque, unknown / stale keys are dropped at + * read time by `GET /api/mood/tags`, so the schema bounds size and shape + * but does not assert ownership of every key. + */ + +async function resolveLayoutResponse( + userId: string, + layout: MoodTagLayout, +): Promise<{ + groupOrder: string[]; + placements: Record; +}> { + const categories = await prisma.moodTagCategory.findMany({ + where: { isActive: true, OR: [{ userId: null }, { userId }] }, + orderBy: { sortOrder: "asc" }, + select: { key: true }, + }); + return { + groupOrder: resolveGroupOrder( + categories.map((c) => c.key), + layout.groupOrder, + ), + placements: layout.placements ?? {}, + }; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { moodTagLayoutJson: true }, + }); + const stored = parseStoredMoodTagLayout(row?.moodTagLayoutJson); + return apiSuccess(await resolveLayoutResponse(user.id, stored)); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = moodTagLayoutSchema.safeParse(rawJsonBody); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + // Preserve-when-absent: a PUT carrying only `groupOrder` must not wipe + // the stored placements, and a placements-only PUT must not reset the + // group order. + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { moodTagLayoutJson: true }, + }); + const stored = parseStoredMoodTagLayout(row?.moodTagLayoutJson); + const merged: MoodTagLayout = mergeMoodTagLayout(stored, { + ...(parsed.data.groupOrder !== undefined + ? { groupOrder: parsed.data.groupOrder } + : {}), + ...(parsed.data.placements !== undefined + ? { placements: parsed.data.placements } + : {}), + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { moodTagLayoutJson: toJson(merged) }, + }); + + annotate({ + action: { name: "mood.tag.layout.update" }, + meta: { + group_order_count: merged.groupOrder?.length ?? 0, + placement_group_count: Object.keys(merged.placements ?? {}).length, + }, + }); + + return apiSuccess(await resolveLayoutResponse(user.id, merged)); +}); diff --git a/src/app/api/mood/tags/route.ts b/src/app/api/mood/tags/route.ts index c4589267d..d5b784515 100644 --- a/src/app/api/mood/tags/route.ts +++ b/src/app/api/mood/tags/route.ts @@ -5,110 +5,196 @@ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { decryptCustomLabel } from "@/lib/mood/custom-tags"; +import { + parseStoredMoodTagLayout, + resolveMoodTagPlacement, +} from "@/lib/mood/tag-layout"; export const dynamic = "force-dynamic"; /** - * v1.8.5 / v1.13.0 — the EFFECTIVE per-user mood-tag taxonomy. + * v1.8.5 / v1.13.0 / v1.17.0 — the EFFECTIVE per-user mood-tag taxonomy. * - * Returns the active Category → Tag tree the mood-logging form renders as a - * pick-from-catalogue surface. The catalogue is global reference data (seeded - * by migration); v1.13.0 layers two per-user adjustments on top so the read - * is now user-scoped: - * - the user's own custom tags (`mood_tags.user_id = me`, `custom:` keys) - * appear under the seeded `custom` category, their label decrypted; + * Returns the fully-resolved, ordered Category → Tag tree the mood-logging + * form renders as a pick-from-catalogue surface — for web AND iOS, in one + * read. The catalogue is global reference data (seeded by migration); + * layered per-user adjustments: + * - the user's own custom tags (`mood_tags.user_id = me`, `custom:` keys), + * label decrypted; + * - the user's own custom groups (`mood_tag_categories.user_id = me`, + * `customcat:` keys), label decrypted, `custom: true` on the DTO; + * - the per-user layout blob (`User.moodTagLayoutJson`): group display + * order + per-group tag placement. A CATALOGUE tag "moved" into a group + * is placement only — its `categoryId` never changes, and entry writes + * are key-based, so no grouping change can break `tagKeys` / + * `ratedFactors` ingestion; * - catalogue tags the user hid (`mood_tag_hidden`) are omitted — unless - * `?include=hidden` is set, in which case they're returned with - * `hidden: true` so the management screen can toggle them back on. + * `include` contains `hidden`, in which case they return `hidden: true`. + * + * `?include=` is a comma list (v1.17.0; the bare v1.13 `hidden` still + * works): `hidden` (hidden catalogue tags), `archived` (the user's own + * inactive custom tags, `archived: true`), `usage` (per-tag `usageCount` + * over the user's live entries — management read only). When any flag is + * set the user's own EMPTY groups are kept (the management screen needs + * them); the plain picker read keeps dropping empty categories. * * Every tag carries `custom: boolean` + `label: string | null`: render * `label` for a custom tag, resolve `labelKey` against the locale for a - * catalogue tag. Backward-compatible — a client that ignores the new fields - * just sees the same categories → tags shape. + * catalogue tag. Backward-compatible — a v1.13 client ignoring the new + * fields sees the same tree, now per-user ordered. */ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const includeHidden = - request.nextUrl.searchParams.get("include") === "hidden"; - const [categories, tags, hiddenRows] = await Promise.all([ - prisma.moodTagCategory.findMany({ - where: { isActive: true }, - orderBy: { sortOrder: "asc" }, - select: { id: true, key: true, labelKey: true, icon: true }, - }), - // Catalogue tags (user_id NULL) + this user's own custom tags. Another - // user's customs are excluded by construction. - prisma.moodTag.findMany({ - where: { isActive: true, OR: [{ userId: null }, { userId: user.id }] }, - orderBy: { sortOrder: "asc" }, - select: { - id: true, - categoryId: true, - key: true, - labelKey: true, - icon: true, - kind: true, - scaleMin: true, - scaleMax: true, - inverse: true, - userId: true, - labelEncrypted: true, - }, - }), - prisma.moodTagHidden.findMany({ - where: { userId: user.id }, - select: { moodTagId: true }, - }), - ]); + const includeFlags = new Set( + (request.nextUrl.searchParams.get("include") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + const includeHidden = includeFlags.has("hidden"); + const includeArchived = includeFlags.has("archived"); + const includeUsage = includeFlags.has("usage"); + const managementRead = includeFlags.size > 0; + + const [categories, tags, hiddenRows, layoutRow, usageRows] = + await Promise.all([ + // Seeded categories + this user's own custom groups. + prisma.moodTagCategory.findMany({ + where: { isActive: true, OR: [{ userId: null }, { userId: user.id }] }, + orderBy: { sortOrder: "asc" }, + select: { + id: true, + key: true, + labelKey: true, + icon: true, + userId: true, + labelEncrypted: true, + }, + }), + // Catalogue tags (user_id NULL) + this user's own custom tags. Another + // user's customs are excluded by construction. `archived` lifts the + // isActive pin for the user's OWN rows only — retired catalogue tags + // (0126 precedent) never resurface. + prisma.moodTag.findMany({ + where: includeArchived + ? { OR: [{ userId: null, isActive: true }, { userId: user.id }] } + : { isActive: true, OR: [{ userId: null }, { userId: user.id }] }, + orderBy: { sortOrder: "asc" }, + select: { + id: true, + categoryId: true, + key: true, + labelKey: true, + icon: true, + kind: true, + scaleMin: true, + scaleMax: true, + inverse: true, + isActive: true, + userId: true, + labelEncrypted: true, + }, + }), + prisma.moodTagHidden.findMany({ + where: { userId: user.id }, + select: { moodTagId: true }, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { moodTagLayoutJson: true }, + }), + includeUsage + ? prisma.moodEntryTagLink.groupBy({ + by: ["moodTagId"], + _count: { _all: true }, + where: { moodEntry: { userId: user.id, deletedAt: null } }, + }) + : Promise.resolve( + [] as Array<{ moodTagId: string; _count: { _all: number } }>, + ), + ]); const hiddenIds = new Set(hiddenRows.map((r) => r.moodTagId)); + const usageByTagId = new Map( + usageRows.map((r) => [r.moodTagId, r._count._all]), + ); + const categoryKeyById = new Map(categories.map((c) => [c.id, c.key])); - const tagsByCategory = new Map(); - for (const t of tags) { + // Visible tag rows (global sortOrder preserved by the query). + const visibleTags = tags.filter((t) => { const isCustom = t.userId !== null; const isHidden = !isCustom && hiddenIds.has(t.id); - if (isHidden && !includeHidden) continue; - const list = tagsByCategory.get(t.categoryId) ?? []; - list.push(t); - tagsByCategory.set(t.categoryId, list); - } + if (isHidden && !includeHidden) return false; + return categoryKeyById.has(t.categoryId); + }); - const responseCategories = categories - .map((c) => { - const catTags = (tagsByCategory.get(c.id) ?? []).map((t) => { - const isCustom = t.userId !== null; - return { - key: t.key, - labelKey: isCustom ? null : t.labelKey, - label: isCustom ? decryptCustomLabel(t.labelEncrypted) : null, - icon: t.icon, - kind: t.kind, - scaleMin: t.scaleMin, - scaleMax: t.scaleMax, - inverse: t.inverse, - custom: isCustom, - ...(includeHidden && !isCustom - ? { hidden: hiddenIds.has(t.id) } - : {}), - }; - }); + const layout = parseStoredMoodTagLayout(layoutRow?.moodTagLayoutJson); + const { orderedCategoryKeys, tagKeysByCategory } = resolveMoodTagPlacement({ + categoryKeysInSeededOrder: categories.map((c) => c.key), + tags: visibleTags.map((t) => ({ + key: t.key, + homeCategoryKey: categoryKeyById.get(t.categoryId) as string, + })), + layout, + }); + + const tagByKey = new Map(visibleTags.map((t) => [t.key, t])); + const categoryByKey = new Map(categories.map((c) => [c.key, c])); + + const responseCategories = orderedCategoryKeys + .map((categoryKey) => { + const c = categoryByKey.get(categoryKey); + if (!c) return null; + const isCustomCategory = c.userId !== null; + const catTags = (tagKeysByCategory.get(categoryKey) ?? []) + .map((tagKey) => tagByKey.get(tagKey)) + .filter((t) => t !== undefined) + .map((t) => { + const isCustom = t.userId !== null; + return { + key: t.key, + labelKey: isCustom ? null : t.labelKey, + label: isCustom ? decryptCustomLabel(t.labelEncrypted) : null, + icon: t.icon, + kind: t.kind, + scaleMin: t.scaleMin, + scaleMax: t.scaleMax, + inverse: t.inverse, + custom: isCustom, + ...(includeHidden && !isCustom + ? { hidden: hiddenIds.has(t.id) } + : {}), + ...(includeArchived && isCustom + ? { archived: !t.isActive } + : {}), + ...(includeUsage + ? { usageCount: usageByTagId.get(t.id) ?? 0 } + : {}), + }; + }); return { key: c.key, - labelKey: c.labelKey, + labelKey: isCustomCategory ? null : c.labelKey, + label: isCustomCategory ? decryptCustomLabel(c.labelEncrypted) : null, icon: c.icon, + custom: isCustomCategory, tags: catTags, }; }) + .filter((c) => c !== null) // Drop a category with no visible tags (e.g. `custom` for a user with - // none yet) so the picker grid stays tight. - .filter((c) => c.tags.length > 0); + // none yet) so the picker grid stays tight — EXCEPT the user's own + // groups on a management read, which must surface even when empty. + .filter((c) => c.tags.length > 0 || (managementRead && c.custom)); annotate({ action: { name: "mood.tags.catalog.read" }, meta: { category_count: responseCategories.length, include_hidden: includeHidden, + include_archived: includeArchived, + include_usage: includeUsage, }, }); diff --git a/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts b/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts new file mode 100644 index 000000000..a9fb93cab --- /dev/null +++ b/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts @@ -0,0 +1,116 @@ +/** + * v1.16.11 — reminder-thresholds GET: the operator-level late / missed + * minutes ride along unchanged; the new `lowStockRunwayDays` field is + * PER-USER (from `notificationPrefs.medication`), so the endpoint + * reflects whatever the notification-prefs PATCH persisted — the read + * half of the settings round-trip. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + }, + appSettings: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +function mkGet(): Request { + return new Request("http://localhost/api/settings/reminder-thresholds"); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + } as never); +}); + +describe("GET /api/settings/reminder-thresholds", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await (GET as (r: Request) => Promise)(mkGet()); + expect(res.status).toBe(401); + }); + + it("returns the default low-stock threshold (7) for a fresh user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: null, + } as never); + + const res = await (GET as (r: Request) => Promise)(mkGet()); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { + lateMinutes: number; + missedMinutes: number; + lowStockRunwayDays: number | null; + }; + }; + expect(env.data).toEqual({ + lateMinutes: 120, + missedMinutes: 240, + lowStockRunwayDays: 7, + }); + }); + + it("reflects the per-user threshold persisted by the prefs PATCH", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { lowStockRunwayDays: 21 } }, + } as never); + + const res = await (GET as (r: Request) => Promise)(mkGet()); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { lowStockRunwayDays: number | null }; + }; + expect(env.data.lowStockRunwayDays).toBe(21); + }); + + it("reflects the OFF state as null", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { lowStockRunwayDays: null } }, + } as never); + + const res = await (GET as (r: Request) => Promise)(mkGet()); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { lowStockRunwayDays: number | null }; + }; + expect(env.data.lowStockRunwayDays).toBe(null); + }); +}); diff --git a/src/app/api/settings/reminder-thresholds/route.ts b/src/app/api/settings/reminder-thresholds/route.ts index 0f2bb76da..8432fc3db 100644 --- a/src/app/api/settings/reminder-thresholds/route.ts +++ b/src/app/api/settings/reminder-thresholds/route.ts @@ -2,15 +2,31 @@ import { apiSuccess } from "@/lib/api-response"; import { getReminderThresholds } from "@/lib/app-settings"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { resolveLowStockRunwayDays } from "@/lib/validations/notification-prefs"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - await requireAuth(); + const { user } = await requireAuth(); const thresholds = await getReminderThresholds(); + // v1.16.11 — the low-stock runway threshold rides along so every + // threshold consumer reads one endpoint. Unlike `lateMinutes` / + // `missedMinutes` (operator-level singleton) this one is PER-USER: + // it lives in `notificationPrefs.medication.lowStockRunwayDays` + // (1–60 days, `null` = alert off, default 7) and is written through + // the established `PATCH /api/auth/me/notification-prefs` path. + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { notificationPrefs: true }, + }); + const lowStockRunwayDays = resolveLowStockRunwayDays( + row?.notificationPrefs ?? null, + ); + annotate({ action: { name: "settings.reminder-thresholds.get" } }); - return apiSuccess(thresholds); + return apiSuccess({ ...thresholds, lowStockRunwayDays }); }); diff --git a/src/app/medications/__tests__/list-view-toggle.test.tsx b/src/app/medications/__tests__/list-view-toggle.test.tsx index 305737dd9..418e65477 100644 --- a/src/app/medications/__tests__/list-view-toggle.test.tsx +++ b/src/app/medications/__tests__/list-view-toggle.test.tsx @@ -111,29 +111,22 @@ describe("/medications — view renders from the persisted preference", () => { expect(html).not.toContain("]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-cards"/, - ); - expect(html).toMatch( - /data-slot="medications-view-table"[^>]*aria-pressed="false"|aria-pressed="false"[^>]*data-slot="medications-view-table"/, - ); + // v1.16.11 — the view choice lives in /settings/medications only; + // the page header carries no toggle. + expect(html).not.toContain('data-slot="medications-view-cards"'); }); it("renders the table for view=table", () => { const html = renderPage({ version: 1, view: "table", order: [] }); expect(html).toContain("]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-table"/, - ); + expect(html).not.toContain('data-slot="medications-view-table"'); }); it("links the header customize glyph to /settings/medications", () => { - // v1.16.10 — same entry-point logic as the dashboard and insights - // headers: the Settings2 ghost icon is a plain link to the page's - // settings section (view preference + manual order live there). + // v1.16.11 — same entry-point logic as the dashboard and insights + // headers: one wrench ghost icon linking to the page's settings + // section (view preference + manual order live there). const html = renderPage({ version: 1, view: "cards", order: [] }); expect(html).toContain('href="/settings/medications"'); expect(html).toContain('aria-label="Customize medications"'); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index dcd1a4e54..7e18fd891 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -11,11 +11,12 @@ import { MedicationWizardDialog } from "@/components/medications/wizard/Medicati import { MedicationCard } from "@/components/medications/medication-card"; import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; import { LogIntakeDialog } from "@/components/medications/log-intake-dialog"; +import { TakeAllDueDialog } from "@/components/medications/take-all-due-dialog"; +import { deriveDueMedications } from "@/components/medications/take-all-due"; import { MedicationTable, MedicationTableSkeleton, } from "@/components/medications/medication-table"; -import { MedicationViewToggle } from "@/components/medications/medication-view-toggle"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { @@ -26,8 +27,16 @@ import { } from "@/components/ui/dropdown-menu"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; -import { CheckCircle2, Loader2, Pill, Plus, Settings2 } from "lucide-react"; +import { + CheckCheck, + CheckCircle2, + Loader2, + Pill, + Plus, + Wrench, +} from "lucide-react"; import { apiGet } from "@/lib/api/api-fetch"; +import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary"; import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; import { applyMedicationOrder } from "@/lib/medications/medication-order"; @@ -46,6 +55,8 @@ interface Schedule { rollingIntervalDays?: number | null; /** v1.5 — reminder grace window in minutes. */ reminderGraceMinutes?: number | null; + /** v1.16.1 — explicit per-dose on-time windows (band model). */ + doseWindows?: { timeOfDay: string; start: string; end: string }[] | null; } interface Medication { @@ -67,18 +78,24 @@ interface Medication { notificationsEnabled: boolean; pausedAt: string | null; lastTakenAt: string | null; + /** List-only count of today's ACTIONED intake events (taken/skipped). */ + todayEventCount?: number; /** * v1.8.4 — server-computed next due instant (canonical recurrence * engine). Threaded straight through to both card variants, which render * it instead of re-deriving the timestamp client-side. */ nextDueAt?: string | null; + /** v1.16.4 — true when `nextDueAt` is an OPEN overdue slot. */ + nextDueOverdue?: boolean; /** v1.5 — medication-level course start date (ISO string). */ startsOn?: string | null; /** v1.5 — medication-level course end date (ISO string). */ endsOn?: string | null; /** v1.5 — single-administration medication. */ oneShot?: boolean; + /** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */ + asNeeded?: boolean; /** v1.9.0 — optional WHO ATC classification code for the FHIR export. */ atcCode?: string | null; /** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */ @@ -133,7 +150,7 @@ function MedicationCardSkeleton() { } export default function MedicationsPage() { - const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); @@ -149,13 +166,15 @@ export default function MedicationsPage() { // two paths: log an intake (incl. a backdated one) against an existing // medication, or create a new medication (the existing wizard). const [logIntakeOpen, setLogIntakeOpen] = useState(false); + // v1.16.11 (#316) — "Alle fälligen einnehmen" confirm dialog. The header + // button earns its slot only while ≥ 2 medications are currently due. + const [takeAllOpen, setTakeAllOpen] = useState(false); // v1.16.10 — the persisted list presentation: cards vs table plus the // manual medication order, server-side per user // (`GET`/`PUT /api/medications/layout`). The toggle writes // optimistically; the order editor lives at /settings/medications. - const { layout, isLayoutLoading, setView } = - useMedicationListLayout(isAuthenticated); + const { layout, isLayoutLoading } = useMedicationListLayout(isAuthenticated); useEffect(() => { if (shouldOpenFromUrl) { @@ -178,6 +197,35 @@ export default function MedicationsPage() { enabled: isAuthenticated, }); + // v1.16.11 — the same reminder-thresholds read the cards make (shared + // key + cache), so the header's due-set derivation tiers late / + // very_late exactly like the card pills. Null on failure keeps the + // derivation on the 120/240 defaults the cards also fall back to. + const { data: thresholds } = useQuery({ + queryKey: queryKeys.settingsReminderThresholds(), + queryFn: async () => { + try { + return await apiGet<{ + lateMinutes: number; + missedMinutes: number; + lowStockRunwayDays: number | null; + }>("/api/settings/reminder-thresholds"); + } catch { + return null; + } + }, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + + // v1.16.11 — the SAME batched compliance rows the cards / table read + // (shared key + cache, no extra round trip). The due-set derivation + // needs each medication's compliance dose status so an escalated + // (overdue / missed past its band tail) medication — which still + // renders an active take button on its red card — joins the + // take-all-due set. + const { data: complianceRows } = useMedicationComplianceSummaryAll(); + function openCreate() { setDialogOpen(true); } @@ -250,6 +298,23 @@ export default function MedicationsPage() { ); const tableView = layout.view === "table"; + // v1.16.11 (#316) — the currently-due set, derived from the list payload + // the page already holds via the SAME pipeline a card's pill runs (band + // model + server display-due gate + taken-early downgrade), plus the + // batched compliance dose status so escalated (overdue / missed) + // medications past their band tail stay takeable from the batch. Page + // order is preserved so the confirm dialog lists medications as rendered. + const dueMeds = deriveDueMedications(activeMeds, { + tz: user?.timezone || "Europe/Berlin", + thresholds: thresholds ?? undefined, + doseStatusById: new Map( + (complianceRows ?? []).map((row) => [ + row.medicationId, + row.complianceDisplay?.currentDose.status ?? "upcoming", + ]), + ), + }); + return (
@@ -264,14 +329,28 @@ export default function MedicationsPage() {

- {/* v1.16.10 — card ⇄ table view toggle, persisted server-side - per user. The Settings2 glyph next to it is the customize - shortcut to /settings/medications (view preference + the - manual-order editor), exactly like the dashboard and - insights headers link to their settings sections, in the - same slot (left of the add button) and with the same - responsive 44-px mobile tap floor. */} - + {/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a + permanent fixture: it renders only while at least TWO + medications are currently due (a single due dose is the + card's own one-tap job). Calm outline variant so the primary + Add button keeps the visual lead; same responsive 44-px + mobile tap floor as its neighbours. */} + {dueMeds.length >= 2 && ( + + )} + {/* v1.16.11 — the wrench is the one customize entry point: + it links to /settings/medications, which owns the view + preference (cards ⇄ table) and the manual-order editor. + Same glyph, slot (left of the add button) and responsive + 44-px mobile tap floor as the dashboard and insights + headers. */} {/* v1.14.0 — the "Add" button is now a choice: log an intake @@ -380,7 +459,10 @@ export default function MedicationsPage() {
{activeMeds.map((med) => - med.treatmentClass === "GLP1" ? ( + // v1.16.11 — an as-needed medication always renders the + // generic card: the GLP-1 variant is built around the + // rolling injection cadence an as-needed med doesn't have. + med.treatmentClass === "GLP1" && !med.asNeeded ? (
{inactiveMeds.map((med) => - med.treatmentClass === "GLP1" ? ( + med.treatmentClass === "GLP1" && !med.asNeeded ? ( + )} + {logIntakeOpen && ( @@ -66,10 +74,34 @@ export default function MoodPage() { {t("mood.subtitle")}

- +
+ {/* v1.17 — the wrench is the one customize entry point: it + links to /settings/mood, which owns the tag groups, custom + tags, visibility, and picker order. Same glyph, slot (left + of the add button) and responsive 44-px mobile tap floor + as the medications header. */} + + +
, api: ApiSection, diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 51c48967f..d46f4a6d5 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -14,7 +14,7 @@ */ import { useMemo } from "react"; import Link from "next/link"; -import { Activity, Pill, Plus, Settings2, Waves } from "lucide-react"; +import { Activity, Pill, Plus, Waves, Wrench } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -114,7 +114,7 @@ export function DashboardHeader({ aria-label={t("dashboard.customizeDashboard")} title={t("dashboard.customizeDashboard")} > -