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
-
+
## 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 ? (
+ {/* 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")}
>
-
+
diff --git a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx
index 0e5996a25..49ade7738 100644
--- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx
+++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx
@@ -104,7 +104,14 @@ describe(" status mount", () => {
/>,
);
- expect(html).toContain("Your HRV has trended upward");
+ // The server pass renders the card's loading skeleton even when the
+ // mocked query reports settled data: `` pins SSR +
+ // the hydration render to the loading branch (`!mounted`) so a query
+ // that settles before a late-hydrating boundary replays its first
+ // render cannot mismatch the server HTML (React #418). The settled
+ // text swaps in on the first client re-render.
+ expect(html).toContain('data-testid="insight-status-card-loading"');
+ expect(html).not.toContain("Your HRV has trended upward");
// The card fetch is enabled (statusMetric present + data present).
expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", true);
});
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx
index adbea4c47..f54d00086 100644
--- a/src/components/insights/insights-tab-strip.tsx
+++ b/src/components/insights/insights-tab-strip.tsx
@@ -9,7 +9,7 @@ import {
ChevronRight,
Loader2,
RefreshCw,
- Settings2,
+ Wrench,
} from "lucide-react";
import { toast } from "sonner";
@@ -806,7 +806,7 @@ function InsightsTabStripImpl({
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
)}
>
-
+
)}
{onRegenerate && (
diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx
index bfade4a0d..ef0f73f83 100644
--- a/src/components/insights/layout-coach-fab.tsx
+++ b/src/components/insights/layout-coach-fab.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
-import { usePathname, useRouter } from "next/navigation";
+import { usePathname } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
@@ -58,7 +58,6 @@ export function isNudgeUnread(
export function LayoutCoachFab() {
const { t } = useTranslations();
- const router = useRouter();
const pathname = usePathname();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
@@ -137,7 +136,10 @@ export function LayoutCoachFab() {
// Best effort — see above.
}
}
- router.push("/insights/coach");
+ // v1.16.11 — open the side drawer in place (the launch context the
+ // layout mount consumes) instead of navigating to the chat page;
+ // the conversation arrives without losing the page underneath.
+ launch?.askCoach();
};
return (
@@ -157,7 +159,7 @@ export function LayoutCoachFab() {
// not `lg:` — keeps the FAB from floating mid-air 768-1023px).
// The right inset mirrors the desktop bottom inset so the
// corner gap is even.
- "fixed right-6 z-40 size-12 rounded-full shadow-lg",
+ "fixed right-6 z-40 size-14 rounded-full shadow-lg",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-6",
// Dark glyph on the purple/pink gradient — white sat at
// ≈2.3:1 against the gradient midpoint; the background token
@@ -185,7 +187,7 @@ export function LayoutCoachFab() {
"[body:has([data-testid=onboarding-tour])_&]:invisible",
)}
>
-
+
{unread ? (
`: the hook is
+ // `enabled: isAuthenticated && enabled`, and both inputs can settle
+ // before a late-hydrating boundary replays its first render — the
+ // loading branch then disagrees with the server HTML (React #418).
+ loading={!mounted || isLoading}
preparing={status?.preparing ?? false}
/>
);
diff --git a/src/components/insights/mood/__tests__/mood-better-days.test.tsx b/src/components/insights/mood/__tests__/mood-better-days.test.tsx
index 5f7c048b1..e39efb3aa 100644
--- a/src/components/insights/mood/__tests__/mood-better-days.test.tsx
+++ b/src/components/insights/mood/__tests__/mood-better-days.test.tsx
@@ -55,6 +55,25 @@ describe("", () => {
expect(html).toContain('data-direction="up"');
});
+ it("prefers a custom tag's decrypted label over its raw labelKey", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Migraine");
+ expect(html).not.toContain("custom:abc-123");
+ });
+
it("shows a metric factor with its labelled name and r value", () => {
const html = render();
// sleep maps to the localized correlation title, not the raw key
diff --git a/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx b/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx
index e08d4f1b9..565098528 100644
--- a/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx
+++ b/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx
@@ -79,6 +79,26 @@ describe("", () => {
expect(html).not.toContain("charts.weekdaysFull.mon");
});
+ it("prefers a custom tag's decrypted label over its raw labelKey", () => {
+ // A custom tag's labelKey mirrors its raw `custom:` key — the
+ // decrypted label must win or the row prints the uuid.
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Migraine");
+ expect(html).not.toContain("custom:abc-123");
+ });
+
it("does not render a local generic disclaimer", () => {
const html = render(
,
diff --git a/src/components/insights/mood/mood-better-days.tsx b/src/components/insights/mood/mood-better-days.tsx
index 047b6f099..8247b6ef8 100644
--- a/src/components/insights/mood/mood-better-days.tsx
+++ b/src/components/insights/mood/mood-better-days.tsx
@@ -23,6 +23,13 @@ export interface MoodBetterDayFactor {
source: "tag" | "metric";
key: string;
labelKey: string | null;
+ /**
+ * v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
+ * tag's `labelKey` mirrors its raw `custom:` key, so the label
+ * takes precedence over `t(labelKey)`. Null for catalogue / flat /
+ * metric rows.
+ */
+ label?: string | null;
categoryKey: string | null;
icon: string | null;
direction: "up" | "down";
@@ -78,6 +85,11 @@ export function MoodBetterDays({
let Icon: ReturnType | null = null;
if (factor.source === "metric") {
label = t(METRIC_LABEL_KEY[factor.key] ?? factor.key);
+ } else if (factor.label) {
+ // Custom tag — render the decrypted label verbatim (its
+ // labelKey is the raw `custom:` key).
+ label = factor.label;
+ Icon = moodTagIcon(factor.icon);
} else if (factor.labelKey) {
label = t(factor.labelKey);
Icon = moodTagIcon(factor.icon);
diff --git a/src/components/insights/mood/mood-structured-tag-breakdown.tsx b/src/components/insights/mood/mood-structured-tag-breakdown.tsx
index fd98d2c5e..ca6e3cec3 100644
--- a/src/components/insights/mood/mood-structured-tag-breakdown.tsx
+++ b/src/components/insights/mood/mood-structured-tag-breakdown.tsx
@@ -16,6 +16,12 @@ export interface MoodStructuredTagRow {
key: string;
categoryKey: string;
labelKey: string;
+ /**
+ * v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
+ * tag's `labelKey` mirrors its raw `custom:` key, so the label
+ * takes precedence over `t(labelKey)`. Null for catalogue tags.
+ */
+ label?: string | null;
icon: string | null;
count: number;
avgScore: number;
@@ -47,7 +53,7 @@ export function MoodStructuredTagBreakdown({
aria-hidden="true"
/>
- {t(row.labelKey)}
+ {row.label ?? t(row.labelKey)}
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}`
diff --git a/src/components/insights/mood/mood-tag-influence.tsx b/src/components/insights/mood/mood-tag-influence.tsx
index 1db777339..99cf293ce 100644
--- a/src/components/insights/mood/mood-tag-influence.tsx
+++ b/src/components/insights/mood/mood-tag-influence.tsx
@@ -21,6 +21,12 @@ export type MoodInfluenceConfidence = "low" | "medium" | "high";
export interface MoodTagInfluenceRow {
tag: string;
labelKey: string | null;
+ /**
+ * v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
+ * tag's `labelKey` mirrors its raw `custom:` key, so the label
+ * takes precedence over `t(labelKey)`. Null for catalogue / flat tags.
+ */
+ label?: string | null;
categoryKey: string | null;
icon: string | null;
withDays: number;
@@ -67,7 +73,8 @@ export function MoodTagInfluence({
{rows.map((row) => {
const Icon = moodTagIcon(row.icon);
- const tagLabel = t(row.labelKey);
+ const tagLabel = row.label ?? t(row.labelKey);
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx
index c61f3fa66..c337e25c5 100644
--- a/src/components/insights/sleep-hypnogram.tsx
+++ b/src/components/insights/sleep-hypnogram.tsx
@@ -209,7 +209,15 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
// need any positive per-stage minutes. A legacy stage-summary night (no
// segment spans) still shows its breakdown rather than collapsing the
// whole card. "Unavailable" is reserved for a truly empty session.
- const hasTrack = spans.length > 0;
+ // Spans must also carry more than one distinct end instant: a summary-
+ // shaped writer (per-stage night totals all stamped on the sleep end)
+ // reconstructs into bars that all touch the night's right edge — that
+ // is a breakdown wearing a timeline costume, so paint it as the
+ // breakdown footer instead.
+ const hasTrack =
+ spans.length > 0 &&
+ (spans.length === 1 ||
+ new Set(spans.map((s) => s.x2)).size > 1);
const hasBreakdown = breakdown.length > 0;
return (
diff --git a/src/components/insights/slug-insight-status-card.tsx b/src/components/insights/slug-insight-status-card.tsx
index 39c00311e..a0d30de28 100644
--- a/src/components/insights/slug-insight-status-card.tsx
+++ b/src/components/insights/slug-insight-status-card.tsx
@@ -6,6 +6,7 @@ import {
useInsightStatus,
type InsightStatusMetric,
} from "@/hooks/use-insight-status";
+import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
@@ -32,6 +33,7 @@ export function SlugInsightStatusCard({
icon,
}: SlugInsightStatusCardProps) {
const { t } = useTranslations();
+ const mounted = useMounted();
const { data: status, isLoading } = useInsightStatus(slug);
return (
@@ -41,7 +43,12 @@ export function SlugInsightStatusCard({
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
- loading={isLoading}
+ // `!mounted ||` keeps the SSR pass and the hydration render on the
+ // same skeleton branch. The status query is `enabled: isAuthenticated`,
+ // and the auth query resolves from the early-hydrating shell — a
+ // late-hydrating page boundary then saw `isLoading` flip true while
+ // the server HTML carried the settled card (React #418).
+ loading={!mounted || isLoading}
preparing={status?.preparing ?? false}
/>
);
diff --git a/src/components/medications/__tests__/inventory-section-actions.test.tsx b/src/components/medications/__tests__/inventory-section-actions.test.tsx
new file mode 100644
index 000000000..f691f49c0
--- /dev/null
+++ b/src/components/medications/__tests__/inventory-section-actions.test.tsx
@@ -0,0 +1,124 @@
+/**
+ * v1.16.11 — Bestand item-row action cluster.
+ *
+ * Renders `` with a seeded query cache and pins the
+ * per-container row contract:
+ *
+ * - the adjust trigger is a visible secondary affordance
+ * (`variant="outline"`), not a bare text label;
+ * - every row carries a delete affordance behind a destructive
+ * confirm (AlertDialog trigger, 44px touch target);
+ * - the state badge sits inline on the meta line next to the
+ * dose/unit figures — a non-interactive span, never a button.
+ *
+ * Project convention is SSR-only component tests (`renderToStaticMarkup`).
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+const useQueryMock = vi.fn();
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: (...args: unknown[]) => useQueryMock(...args),
+ useQueryClient: () => ({
+ invalidateQueries: vi.fn().mockResolvedValue(undefined),
+ }),
+}));
+
+vi.mock("sonner", () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
+const { InventorySection } = await import("../sections/inventory-section");
+
+function render(node: React.ReactNode): string {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+function seedItems(items: unknown[]) {
+ useQueryMock.mockReturnValue({
+ data: { items, meta: { total: items.length } },
+ isLoading: false,
+ });
+}
+
+describe(" — item-row actions", () => {
+ it("renders the adjust trigger as an outline button, not a bare label", () => {
+ seedItems([
+ {
+ id: "i1",
+ state: "ACTIVE",
+ containerType: "PEN",
+ unitsTotal: 4,
+ unitsRemaining: 4,
+ },
+ ]);
+ const html = render(
+ ,
+ );
+ const adjust = html.match(
+ /
diff --git a/src/components/medications/card-parts/medication-status-pill.tsx b/src/components/medications/card-parts/medication-status-pill.tsx
index b937e315d..93ef08cc5 100644
--- a/src/components/medications/card-parts/medication-status-pill.tsx
+++ b/src/components/medications/card-parts/medication-status-pill.tsx
@@ -26,6 +26,14 @@ interface MedicationStatusPillProps {
* with the card pill — only the suffix is dropped.
*/
compact?: boolean;
+ /**
+ * v1.16.11 — inline variant: renders a `` root instead of the
+ * block `
` so the pill can sit as the right-aligned VALUE of the
+ * "next intake" row (the take-now prompt lives on that row now, not
+ * on its own line above the slot). A `
` inside the row's ``
+ * would be invalid nesting.
+ */
+ inline?: boolean;
}
/**
@@ -42,12 +50,22 @@ export function MedicationStatusPill({
windowEnd,
takenEarlyDaysAgo = null,
compact = false,
+ inline = false,
}: MedicationStatusPillProps) {
const { t, locale } = useTranslations();
+ const Root = inline ? "span" : "p";
+
+ // v1.16.11 — the inline (next-row) variant keeps its window-range
+ // suffix visible on mobile: the suffix is the row's only time anchor
+ // there, and hiding it left every prompt tier time-less below `sm`.
+ // The standalone `
+
);
}
diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx
index b90ee22b1..41ba790a6 100644
--- a/src/components/medications/detail/MedicationDetailTabs.tsx
+++ b/src/components/medications/detail/MedicationDetailTabs.tsx
@@ -136,6 +136,8 @@ export interface MedicationDetailSnapshot {
startsOn?: string | null;
endsOn?: string | null;
oneShot?: boolean;
+ /** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */
+ asNeeded?: boolean;
atcCode?: string | null;
rxNormCode?: string | null;
/**
@@ -189,6 +191,7 @@ function snapshotToWizardPayload(
startsOn: med.startsOn ? new Date(med.startsOn) : null,
endsOn: med.endsOn ? new Date(med.endsOn) : null,
oneShot: med.oneShot ?? false,
+ asNeeded: med.asNeeded ?? false,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
@@ -226,6 +229,7 @@ export function MedicationDetailTabs({
const id = medication.id;
const oneShot = medication.oneShot === true;
+ const asNeeded = medication.asNeeded === true;
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
const isInjectable = medication.deliveryForm === "INJECTION";
@@ -238,9 +242,18 @@ export function MedicationDetailTabs({
// whatever `?tab=` carries — Injektion is absent for non-injectable
// medications, so a stale `?tab=injektion` deep-link falls back to the
// landing tab instead of rendering an empty surface.
+ // v1.16.11 — an as-needed medication has no schedule and never
+ // reminds, so the Zeitplan tab (times editor + reminder card +
+ // plan-history) does not apply and is dropped from the registry; a
+ // stale `?tab=zeitplan` deep-link falls back to the landing tab.
const availableTabs = useMemo(
- () => TAB_SLUGS.filter((slug) => slug !== "injektion" || isInjectable),
- [isInjectable],
+ () =>
+ TAB_SLUGS.filter(
+ (slug) =>
+ (slug !== "injektion" || isInjectable) &&
+ (slug !== "zeitplan" || !asNeeded),
+ ),
+ [isInjectable, asNeeded],
);
const requestedRaw = searchParams?.get("tab");
@@ -296,7 +309,9 @@ export function MedicationDetailTabs({
return null;
}
},
- enabled: activeTab === "uebersicht",
+ // v1.16.11 — no compliance read for an as-needed medication (the
+ // section is hidden; there is no rate to fetch).
+ enabled: activeTab === "uebersicht" && !asNeeded,
staleTime: 30_000,
});
@@ -368,6 +383,7 @@ export function MedicationDetailTabs({
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
+ asNeeded={asNeeded}
startsOn={medication.startsOn}
/>
@@ -407,25 +423,35 @@ export function MedicationDetailTabs({
value={
!medication.active
? t("medications.detail.uebersicht.pausedHint")
- : medication.nextDueAt
- ? fmt.dateTime(medication.nextDueAt)
- : t("medications.detail.uebersicht.nextDoseNone")
+ : asNeeded
+ ? t("medications.detail.uebersicht.nextDoseAsNeeded")
+ : medication.nextDueAt
+ ? fmt.dateTime(medication.nextDueAt)
+ : t("medications.detail.uebersicht.nextDoseNone")
}
- jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
- onJump={() => onTabChange("zeitplan")}
+ jumpLabel={
+ asNeeded
+ ? t("medications.detail.uebersicht.jumpToVerlauf")
+ : t("medications.detail.uebersicht.jumpToZeitplan")
+ }
+ onJump={() => onTabChange(asNeeded ? "verlauf" : "zeitplan")}
dataSlot="uebersicht-next-dose"
/>
- onTabChange("zeitplan")}
- dataSlot="uebersicht-reminder"
- />
+ {/* v1.16.11 — as-needed never reminds; the row would only
+ mislead (the cron skips schedule-less medications). */}
+ {!asNeeded && (
+ onTabChange("zeitplan")}
+ dataSlot="uebersicht-reminder"
+ />
+ )}
{inventoryItems.length > 0 && (
+ {!asNeeded && (
)}
+ )}
{/* ZEITPLAN — inline edit of the everyday levers: dose times +
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index a54846022..015d49e3d 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -26,6 +26,10 @@ import {
toZonedDate,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
+import {
+ estimateDailyDoseCount,
+ estimateRunwayDays,
+} from "@/components/medications/detail/supply-runway";
import { useAuth } from "@/hooks/use-auth";
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
@@ -61,6 +65,9 @@ interface ScheduleLite {
*/
timesOfDay?: string[];
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
+ /** Cadence fields the supply-runway estimate reads (v1.16.11). */
+ rrule?: string | null;
+ rollingIntervalDays?: number | null;
}
interface DoseChangeLite {
@@ -108,6 +115,8 @@ export interface Glp1Medication {
* renders it as "overdue — still takeable" instead of a future date.
*/
nextDueOverdue?: boolean;
+ /** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
+ stockDosesRemaining?: number | null;
schedules: ScheduleLite[];
}
@@ -212,9 +221,11 @@ export function Glp1MedicationCard({
queryKey: queryKeys.settingsReminderThresholds(),
queryFn: async () => {
try {
- return await apiGet<{ lateMinutes: number; missedMinutes: number }>(
- "/api/settings/reminder-thresholds",
- );
+ return await apiGet<{
+ lateMinutes: number;
+ missedMinutes: number;
+ lowStockRunwayDays: number | null;
+ }>("/api/settings/reminder-thresholds");
} catch {
return null;
}
@@ -328,6 +339,31 @@ export function Glp1MedicationCard({
timeZone: userTz,
});
+ // v1.16.11 — low-stock card context, identical to the generic card:
+ // runway days from the list payload's dose-derived stock, surfaced
+ // ONLY below the user's runway threshold (`lowStockRunwayDays`,
+ // default 7, null = alert off → no card line). Stock 0 with a
+ // consuming schedule is runway 0 (mirrors `evaluateMedicationRunway`).
+ const lowStockThreshold =
+ thresholds == null ? 7 : thresholds.lowStockRunwayDays;
+ const stockRunwayDays =
+ medication.stockDosesRemaining == null
+ ? null
+ : medication.stockDosesRemaining > 0
+ ? estimateRunwayDays(
+ medication.stockDosesRemaining,
+ medication.schedules,
+ )
+ : estimateDailyDoseCount(medication.schedules) > 0
+ ? 0
+ : null;
+ const lowStockRunwayDays =
+ stockRunwayDays !== null &&
+ lowStockThreshold !== null &&
+ stockRunwayDays < lowStockThreshold
+ ? stockRunwayDays
+ : null;
+
// v1.15.9 — the recent-injection list now feeds ONLY the post-dose
// injection-site dialog's rotation history; the card surface no longer
// shows where the last shot landed. The operator: "where I injected
@@ -459,6 +495,7 @@ export function Glp1MedicationCard({
complianceError={complianceError}
onRetryCompliance={refetchCompliance}
currentCycle={display?.currentCycle ?? null}
+ lowStockRunwayDays={lowStockRunwayDays}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
>
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx
index 30a7a8ec2..de2b4fc22 100644
--- a/src/components/medications/medication-card.tsx
+++ b/src/components/medications/medication-card.tsx
@@ -16,6 +16,10 @@ import {
toZonedDate,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
+import {
+ estimateDailyDoseCount,
+ estimateRunwayDays,
+} from "@/components/medications/detail/supply-runway";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -44,6 +48,9 @@ interface Schedule {
*/
timesOfDay?: string[];
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
+ /** Cadence fields the supply-runway estimate reads (v1.16.11). */
+ rrule?: string | null;
+ rollingIntervalDays?: number | null;
}
interface Medication {
@@ -85,6 +92,15 @@ interface Medication {
* still takeable" instead of the regular upcoming-intake phrasing.
*/
nextDueOverdue?: boolean;
+ /**
+ * v1.16.11 (#316) — as-needed (PRN) medication: no schedules, never
+ * due, never reminded, no compliance. The card renders a calm
+ * "Bei Bedarf" marker where next-due normally sits and replaces the
+ * compliance bars with the last-taken oriented presentation.
+ */
+ asNeeded?: boolean;
+ /** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
+ stockDosesRemaining?: number | null;
schedules: Schedule[];
}
@@ -151,9 +167,11 @@ export function MedicationCard({
queryKey: queryKeys.settingsReminderThresholds(),
queryFn: async () => {
try {
- return await apiGet<{ lateMinutes: number; missedMinutes: number }>(
- "/api/settings/reminder-thresholds",
- );
+ return await apiGet<{
+ lateMinutes: number;
+ missedMinutes: number;
+ lowStockRunwayDays: number | null;
+ }>("/api/settings/reminder-thresholds");
} catch {
return null;
}
@@ -257,6 +275,33 @@ export function MedicationCard({
timeZone: userTz,
});
+ // v1.16.11 — low-stock card context. Runway days from the list
+ // payload's dose-derived stock over the same estimate the table /
+ // detail Übersicht / notification engine use; surfaced ONLY below the
+ // user's runway threshold (`lowStockRunwayDays`, default 7, null =
+ // alert off → no card line either). Stock 0 with a consuming schedule
+ // is runway 0 (mirrors `evaluateMedicationRunway`); as-needed has no
+ // runway, ever.
+ const lowStockThreshold =
+ thresholds == null ? 7 : thresholds.lowStockRunwayDays;
+ const stockRunwayDays =
+ medication.asNeeded || medication.stockDosesRemaining == null
+ ? null
+ : medication.stockDosesRemaining > 0
+ ? estimateRunwayDays(
+ medication.stockDosesRemaining,
+ medication.schedules,
+ )
+ : estimateDailyDoseCount(medication.schedules) > 0
+ ? 0
+ : null;
+ const lowStockRunwayDays =
+ stockRunwayDays !== null &&
+ lowStockThreshold !== null &&
+ stockRunwayDays < lowStockThreshold
+ ? stockRunwayDays
+ : null;
+
function formatLastTakenAt(value: string): string {
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
// string-comparable for the today / yesterday / older bucketing below.
@@ -300,6 +345,15 @@ export function MedicationCard({
/>
);
+ // v1.16.11 — as-needed: a calm marker where next-due normally sits.
+ // No due pill, no overdue escalation, ever (structurally there is no
+ // schedule, so the window status is already null).
+ const asNeededLine = medication.asNeeded ? (
+
+ {t("medications.asNeededMarker")}
+
+ ) : null;
+
// The upcoming-intake line value — a day label + window range + optional
// dose accent. The card owns this VALUE content (a daily med reads as a
// clock-time window); the structure / labels live in the shared body.
@@ -396,19 +450,21 @@ export function MedicationCard({
}
: null
}
- doseStatus={doseStatus}
- nextLine={nextLine}
+ doseStatus={medication.asNeeded ? "upcoming" : doseStatus}
+ nextLine={asNeededLine ?? nextLine}
lastLine={
medication.lastTakenAt
? formatLastTakenAt(medication.lastTakenAt)
: null
}
+ asNeeded={medication.asNeeded === true}
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
}
complianceError={complianceError}
onRetryCompliance={refetchCompliance}
currentCycle={display?.currentCycle ?? null}
+ lowStockRunwayDays={lowStockRunwayDays}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
>
diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx
index 9ceb3f407..f86b1206c 100644
--- a/src/components/medications/medication-detail-summary.tsx
+++ b/src/components/medications/medication-detail-summary.tsx
@@ -31,6 +31,8 @@ export interface MedicationDetailSummaryProps {
/** Wizard payload used to derive the plain-language cadence line. */
payload: MedicationPayload;
oneShot: boolean;
+ /** v1.16.11 — as-needed (PRN): the cadence line reads "Bei Bedarf". */
+ asNeeded?: boolean;
startsOn?: string | null;
}
@@ -53,6 +55,7 @@ export function MedicationDetailSummary({
endsOn,
payload,
oneShot,
+ asNeeded = false,
startsOn,
}: MedicationDetailSummaryProps) {
const { t } = useTranslations();
@@ -83,7 +86,9 @@ export function MedicationDetailSummary({
date: formatters.dateTime(startsOn),
})
: t("medications.detail.cadence.oneShotPending")
- : summariseCadence(hydrateWizardPayload(payload), t, formatters.date);
+ : asNeeded
+ ? t("medications.detail.cadence.asNeeded")
+ : summariseCadence(hydrateWizardPayload(payload), t, formatters.date);
return (
diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx
index 9cd4658fd..1eccd66ed 100644
--- a/src/components/medications/medication-table.tsx
+++ b/src/components/medications/medication-table.tsx
@@ -26,6 +26,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
+import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
import {
reduceCurrentWindowStatus,
@@ -70,6 +71,9 @@ interface TableSchedule {
daysOfWeek: string | null;
timesOfDay?: string[];
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
+ /** Cadence fields the supply-runway estimate reads (v1.16.11). */
+ rrule?: string | null;
+ rollingIntervalDays?: number | null;
}
export interface TableMedication {
@@ -83,6 +87,11 @@ export interface TableMedication {
todayEventCount?: number;
nextDueAt?: string | null;
nextDueOverdue?: boolean;
+ /**
+ * v1.16.11 (#316) — as-needed (PRN): the next-dose cell shows a calm
+ * "Bei Bedarf" marker, the compliance column shows "–".
+ */
+ asNeeded?: boolean;
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
stockDosesRemaining?: number | null;
schedules: TableSchedule[];
@@ -158,8 +167,6 @@ export function sortMedicationRows(
});
}
-const LOW_STOCK_DOSES = 4;
-
interface MedicationTableProps {
/** Active medications, already in the page's manual order. */
activeMedications: TableMedication[];
@@ -181,14 +188,19 @@ export function MedicationTable({
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
// Same reminder-thresholds source as the cards so the status pill
- // tiers identically on both presentations.
+ // tiers identically on both presentations. The read also carries the
+ // per-user low-stock runway threshold (v1.16.11) so the stock cell's
+ // warn tier fires on the SAME days-of-runway predicate as the
+ // low-stock notification, not a legacy dose count.
const { data: thresholds } = useQuery({
queryKey: queryKeys.settingsReminderThresholds(),
queryFn: async () => {
try {
- return await apiGet<{ lateMinutes: number; missedMinutes: number }>(
- "/api/settings/reminder-thresholds",
- );
+ return await apiGet<{
+ lateMinutes: number;
+ missedMinutes: number;
+ lowStockRunwayDays: number | null;
+ }>("/api/settings/reminder-thresholds");
} catch {
return null;
}
@@ -228,6 +240,11 @@ export function MedicationTable({
const lateMinutes = thresholds?.lateMinutes ?? 120;
const missedMinutes = thresholds?.missedMinutes ?? 240;
+ // Per-user low-stock runway threshold: explicit `null` = the alert is
+ // OFF (no amber tier); a missing / failed read falls back to the
+ // server default (7 days), mirroring `resolveLowStockRunwayDays`.
+ const lowStockRunwayDays =
+ thresholds == null ? 7 : thresholds.lowStockRunwayDays;
const columns: Array<{
key: MedicationTableSortColumn | "status" | "actions";
@@ -262,7 +279,12 @@ export function MedicationTable({
}
return (
-
+ // `overflow-hidden` is load-bearing: the sticky name column paints
+ // an opaque `bg-card` cell background that would otherwise square
+ // off the wrapper's rounded corners on the left edge. Horizontal
+ // scrolling lives on the inner table-container (`overflow-x-auto`),
+ // so clipping here costs nothing.
+
{t("medications.tableCaption")}
@@ -321,6 +343,7 @@ export function MedicationTable({
userTz={userTz}
lateMinutes={lateMinutes}
missedMinutes={missedMinutes}
+ lowStockRunwayDays={lowStockRunwayDays}
/>
))}
{sortedInactive.map((med) => (
@@ -330,6 +353,7 @@ export function MedicationTable({
userTz={userTz}
lateMinutes={lateMinutes}
missedMinutes={missedMinutes}
+ lowStockRunwayDays={lowStockRunwayDays}
/>
))}
@@ -343,6 +367,8 @@ interface MedicationTableRowItemProps {
userTz: string;
lateMinutes: number;
missedMinutes: number;
+ /** Per-user low-stock runway threshold in days; null = alert off. */
+ lowStockRunwayDays: number | null;
}
function MedicationTableRowItem({
@@ -350,6 +376,7 @@ function MedicationTableRowItem({
userTz,
lateMinutes,
missedMinutes,
+ lowStockRunwayDays,
}: MedicationTableRowItemProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
@@ -413,8 +440,13 @@ function MedicationTableRowItem({
timeZone: userTz,
});
- // Status cell — the card body's exact precedence: last-dose context
- // outranks the overdue escalation, which outranks the window pill.
+ // Status cell — the card body's precedence with one DELIBERATE
+ // divergence: the card suppresses the taken-early pill on its
+ // next-intake row (the "last intake" line below already names the
+ // dose), while the table keeps it — the table has a dedicated Status
+ // column with no neighbouring last-intake context. Order here:
+ // last-dose context outranks the overdue escalation, which outranks
+ // the window pill.
const overdueLabel =
medication.active && doseStatus === "missed"
? t("medications.veryOverdue")
@@ -454,7 +486,18 @@ function MedicationTableRowItem({
let nextCell: React.ReactNode = (
–
);
- if (medication.active) {
+ if (medication.asNeeded) {
+ // v1.16.11 — a calm marker where next-due normally sits; no due
+ // pill, no overdue escalation, ever.
+ nextCell = (
+
+ {t("medications.asNeededMarker")}
+
+ );
+ } else if (medication.active) {
if (medication.nextDueOverdue && nextAt) {
nextCell = (
@@ -542,23 +585,48 @@ function MedicationTableRowItem({
);
const stock = medication.stockDosesRemaining;
+ // Projected days the stock covers — the same coarse estimate the
+ // detail Übersicht renders, so the column answers "do I need a
+ // refill" without opening the medication.
+ const runwayDays =
+ stock === null || stock === undefined
+ ? null
+ : estimateRunwayDays(stock, medication.schedules);
+ // Warn tier (amber) on the SAME predicate as the low-stock
+ // notification: projected runway days strictly below the user's
+ // threshold. Threshold OFF (null) → no amber tier; an exhausted stock
+ // (0) stays destructive red regardless.
+ const lowStock =
+ runwayDays !== null &&
+ lowStockRunwayDays !== null &&
+ runwayDays < lowStockRunwayDays;
const stockCell =
stock === null || stock === undefined ? (
–
) : (
-
+
+ {stock === 1
+ ? t("medications.tableStockDoseOne")
+ : t("medications.tableStockDoses", { count: stock })}
+
+ {runwayDays !== null && (
+
+ {t("medications.tableStockRunway", { days: runwayDays })}
+
)}
- >
- {stock === 1
- ? t("medications.tableStockDoseOne")
- : t("medications.tableStockDoses", { count: stock })}
);
@@ -583,7 +651,7 @@ function MedicationTableRowItem({
{statusCell}{nextCell}
- {medication.active ? complianceCell : –}
+ {medication.active && !medication.asNeeded ? complianceCell : –}{stockCell}
{medication.active ? (
@@ -636,7 +704,10 @@ function MedicationTableRowItem({
*/
export function MedicationTableSkeleton({ rows = 4 }: { rows?: number }) {
return (
-
+
diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx
index 16114f651..5e87ec361 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -15,6 +15,10 @@
* the count is set absolutely, clamped server-side to the item's
* capacity, and the canonical state machine derives the next state
* (0 ⇒ used up).
+ * - DELETE: a per-item trash affordance behind a destructive confirm
+ * (`DELETE …/inventory/[itemId]`). Consumption stamps on intake
+ * events that referenced the container stay in place; a later
+ * restore skips the missing item.
*
* v1.16.10 — items count UNITS (tablets / ampoules / puffs);
* `Medication.unitsPerDose` maps units to doses. Every dose-facing
@@ -32,12 +36,14 @@
*/
import { useState, type FormEvent } from "react";
+import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, PackageOpen, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { DeleteButton } from "@/components/data-list/delete-button";
import {
Dialog,
DialogContent,
@@ -58,7 +64,13 @@ import {
import { SettingsGroup } from "@/components/medications/settings-group";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
-import { apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
+import {
+ apiDelete,
+ apiGet,
+ apiPatch,
+ apiPost,
+ apiPut,
+} from "@/lib/api/api-fetch";
import { summariseSupply } from "@/lib/medications/inventory/summary";
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
@@ -115,8 +127,22 @@ export function InventorySection({
deliveryForm?: string;
}) {
const { t } = useTranslations();
+ const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const [adjustItem, setAdjustItem] = useState(null);
+ const [packagingOpen, setPackagingOpen] = useState(false);
+
+ async function deleteItem(item: InventoryItem) {
+ try {
+ await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`);
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.medicationInventory(medicationId),
+ });
+ toast.success(t("medications.detail.bestand.deleteSuccess"));
+ } catch {
+ toast.error(t("medications.detail.bestand.deleteFailed"));
+ }
+ }
const perDose = Math.max(1, unitsPerDose ?? 1);
@@ -128,6 +154,26 @@ export function InventorySection({
staleTime: 30_000,
});
+ // v1.16.11 — the low-stock alert threshold, for the cross-link row
+ // below the supply summary (same shared key + cache the cards read).
+ // Null on failure falls back to the server default (7 days).
+ 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;
+ }
+ },
+ staleTime: 5 * 60 * 1000,
+ });
+ const lowStockDays = thresholds == null ? 7 : thresholds.lowStockRunwayDays;
+
if (isLoading) {
return (
+ {/* v1.16.11 — cross-link to the low-stock alert setting: the
+ threshold lives in Settings → Notifications, but the question
+ "when will it warn me?" comes up here, where the stock lives. */}
+
+
+
+ {/* v1.16.11 — packaging economics surfaced where the stock lives:
+ the wizard's dose step owns these on create/edit, but a
+ manufacturer switch (a different blister size) happens while
+ looking at the supply, so the supply tab carries them too. */}
+
+