Merge pull request #318 from MBombeck/release/v1.16.11

Release v1.16.11 — the supply speaks up, tags find their groups, and nothing asks twice
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-13 01:07:59 +02:00
commit 5ce3e81972
153 files changed
+13852 -628

No files matched your search

+25
View File
@@ -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
+2 -2
View File
@@ -22,13 +22,13 @@
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
</p>
<img width="2560" height="1600" alt="HealthLog dashboard: health metric tiles with 7-day trends and a weight chart, dark theme" src="docs/assets/dashboard-hero.png" />
<img width="2434" height="1247" alt="HealthLog dashboard: health metric tiles with 7-day trends, blood-pressure and weight charts, dark theme" src="docs/assets/dashboard-hero.jpeg" />
## 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
+1125 -6
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

+8
View File
@@ -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");
+48 -8
View File
@@ -19,8 +19,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* bottom-sheet (`data-variant="bottom-sheet"`) on `<sm`, as a
* right-side sheet (`data-variant="side-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 });
+118 -4
View File
@@ -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?"
+118 -4
View File
@@ -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?"
+118 -4
View File
@@ -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?"
+118 -4
View File
@@ -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 ?"
+118 -4
View File
@@ -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?"
+118 -4
View File
@@ -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?"
+2 -2
View File
@@ -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",
+118 -113
View File
@@ -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
@@ -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;
@@ -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:<uuid>` 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;
@@ -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;
+39 -1
View File
@@ -145,6 +145,13 @@ model User {
// once the user has explicitly toggled the view or saved an order.
// Shape: { "version": 1, "view": "table", "order": ["<medicationId>", ...] }
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": ["<categoryKey>", ...],
// "placements": { "<categoryKey>": ["<tagKey>", ...] } }
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:<uuid>` 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")
}
+44
View File
@@ -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
@@ -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
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
},
},
toJson: <T,>(v: T) => v,
toJson: <T>(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<Response>)(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<Response>)(
mkPatch({}),
);
const res = await (PATCH as (r: Request) => Promise<Response>)(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<Response>)(
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<Response>)(
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<Response>)(
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({
+3 -1
View File
@@ -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: {
+3 -1
View File
@@ -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: {
@@ -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<string, unknown> };
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<string, unknown> };
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();
});
});
@@ -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);
});
});
+82 -1
View File
@@ -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),
@@ -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<string, unknown> & {
schedules: { create: Record<string, unknown>[] };
} {
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);
});
});
+4 -1
View File
@@ -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.
@@ -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",
+18 -4
View File
@@ -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).
@@ -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"));
@@ -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<unknown>) => fn(base),
);
return base;
});
vi.mock("@/lib/db", () => ({
prisma: db,
toJson: <T,>(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<unknown>) => 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();
});
});
@@ -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: <T,>(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<string, string[]> };
};
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<string, string[]> };
};
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<string, string[]> };
};
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();
});
});
+164 -9
View File
@@ -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<string, TagOut> {
@@ -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 } },
}),
);
});
});
+20 -2
View File
@@ -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,
+18 -3
View File
@@ -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:<uuid>` 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
+152
View File
@@ -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 });
},
);
+85
View File
@@ -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:<uuid>` 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,
);
});
+109
View File
@@ -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<string, string[]>;
}> {
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));
});
+158 -72
View File
@@ -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<string, typeof tags>();
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,
},
});
@@ -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<Response>)(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<Response>)(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<Response>)(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<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { lowStockRunwayDays: number | null };
};
expect(env.data.lowStockRunwayDays).toBe(null);
});
});
@@ -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 });
});
@@ -111,29 +111,22 @@ describe("/medications — view renders from the persisted preference", () => {
expect(html).not.toContain("<table");
expect(html).toContain('data-slot="card"');
// Toggle present, group named, the cards segment pressed.
expect(html).toContain('aria-label="View"');
expect(html).toMatch(
/data-slot="medications-view-cards"[^>]*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("<table");
expect(html).toMatch(
/data-slot="medications-view-table"[^>]*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"');
+110 -16
View File
@@ -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 (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -264,14 +329,28 @@ export default function MedicationsPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* 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. */}
<MedicationViewToggle view={layout.view} onChange={setView} />
{/* 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 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
className="min-h-11 sm:min-h-9"
>
<CheckCheck className="h-4 w-4" aria-hidden="true" />
{t("medications.takeAllDue.button")}
</Button>
)}
{/* 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. */}
<Button
asChild
variant="ghost"
@@ -283,7 +362,7 @@ export default function MedicationsPage() {
aria-label={t("medications.customize")}
title={t("medications.customize")}
>
<Settings2 className="h-4 w-4" aria-hidden="true" />
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
{/* v1.14.0 — the "Add" button is now a choice: log an intake
@@ -380,7 +459,10 @@ export default function MedicationsPage() {
<div className="space-y-3.5">
<div className="grid gap-4 sm:grid-cols-2">
{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 ? (
<Glp1MedicationCard
key={med.id}
medication={med}
@@ -408,7 +490,7 @@ export default function MedicationsPage() {
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{inactiveMeds.map((med) =>
med.treatmentClass === "GLP1" ? (
med.treatmentClass === "GLP1" && !med.asNeeded ? (
<Glp1MedicationCard
key={med.id}
medication={med}
@@ -457,6 +539,18 @@ export default function MedicationsPage() {
date+time that supports backdating. Submits to the existing
per-medication intake route (same slot upsert as a live "Taken"
tap) so the one-row-per-dose-slot invariant holds. */}
{/* v1.16.11 (#316) — confirm-all dialog for the due set. Records
through the per-medication intake route in a loop (slot
attribution + inventory consumption identical to N individual
taps — see take-all-due.ts); failed medications stay due. */}
{takeAllOpen && (
<TakeAllDueDialog
open={takeAllOpen}
onOpenChange={setTakeAllOpen}
dueMedications={dueMeds}
/>
)}
{logIntakeOpen && (
<LogIntakeDialog
open={logIntakeOpen}
+38 -6
View File
@@ -1,21 +1,24 @@
"use client";
import { useCallback, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { MoodForm } from "@/components/mood/mood-form";
import { MoodList } from "@/components/mood/mood-list";
import { Button } from "@/components/ui/button";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Loader2 } from "lucide-react";
import { Plus, Loader2, Wrench } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
export default function MoodPage() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
@@ -44,7 +47,12 @@ export default function MoodPage() {
}
}, [isLoading, isAuthenticated, router]);
if (isLoading) {
// `!mounted` keeps the hydration render identical to the SSR HTML: the
// auth query is fired by the early-hydrating shell and can settle before
// this page boundary hydrates, so `isLoading` alone flipped the branch
// and React logged hydration error #418 — same fix as the measurements
// page; see `useMounted`.
if (!mounted || isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
@@ -66,10 +74,34 @@ export default function MoodPage() {
{t("mood.subtitle")}
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
<div className="flex shrink-0 items-center gap-2">
{/* 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. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/mood"
aria-label={t("mood.customize")}
title={t("mood.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
</div>
</div>
<ResponsiveSheet
+7 -1
View File
@@ -1429,11 +1429,17 @@ export default function DashboardPage() {
// already mounted) so the page holds roughly its final
// footprint from first paint and the snapshot landing swaps
// content instead of growing the page.
// `mounted &&` pins the hydration render to the SSR output: the
// auth query is fired by the early-hydrating shell, so `user`
// can already carry a heightCm when this late-hydrating page
// boundary replays its first render — the extra BMI silhouette
// then mismatched the server HTML (React #418). The count may
// only personalise from the first client re-render.
const chartRowPlaceholderCount = primaryLoading
? Math.max(
0,
resolveChartRowPlaceholderCount(layout, {
hasHeightCm: Boolean(user?.heightCm),
hasHeightCm: mounted && Boolean(user?.heightCm),
}) - charts.length,
)
: 0;
+2
View File
@@ -11,6 +11,7 @@ import { ExportSection } from "@/components/settings/export-section";
import { InsightsSection } from "@/components/settings/insights-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { MedicationsSection } from "@/components/settings/medications-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
@@ -53,6 +54,7 @@ const SECTION_COMPONENTS: Record<
dashboard: DashboardSection,
insights: InsightsSection,
medications: MedicationsSection,
mood: MoodSection,
thresholds: ThresholdsSection,
sources: () => <SourcesSection />,
api: ApiSection,
@@ -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")}
>
<Settings2 className="h-4 w-4" aria-hidden="true" />
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
@@ -104,7 +104,14 @@ describe("<HealthKitMetricPage> 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: `<MetricStatusCard>` 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);
});
@@ -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",
)}
>
<Settings2 className="h-4 w-4" aria-hidden="true" />
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
)}
{onRegenerate && (
+7 -5
View File
@@ -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",
)}
>
<Sparkles className="size-5" aria-hidden="true" />
<Sparkles className="size-6" aria-hidden="true" />
{unread ? (
<span
data-slot="coach-fab-unread"
@@ -3,6 +3,7 @@
import type { ReactNode } from "react";
import { useInsightMetricStatus } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
@@ -35,6 +36,7 @@ export function MetricStatusCard({
enabled,
}: MetricStatusCardProps) {
const { t } = useTranslations();
const mounted = useMounted();
const { data: status, isLoading } = useInsightMetricStatus(metric, enabled);
return (
@@ -44,7 +46,11 @@ export function MetricStatusCard({
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isLoading}
// Same hydration gate as `<SlugInsightStatusCard>`: 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}
/>
);
@@ -55,6 +55,25 @@ describe("<MoodBetterDays>", () => {
expect(html).toContain('data-direction="up"');
});
it("prefers a custom tag's decrypted label over its raw labelKey", () => {
const html = render(
<MoodBetterDays
factors={[
{
...tagFactor,
key: "custom:abc-123",
labelKey: "custom:abc-123",
label: "Migraine",
categoryKey: "custom",
icon: "Tag",
},
]}
/>,
);
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(<MoodBetterDays factors={[metricFactor]} />);
// sleep maps to the localized correlation title, not the raw key
@@ -79,6 +79,26 @@ describe("<MoodTagInfluence>", () => {
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:<uuid>` key — the
// decrypted label must win or the row prints the uuid.
const html = render(
<MoodTagInfluence
rows={[
flatRow({
tag: "custom:abc-123",
labelKey: "custom:abc-123",
label: "Migraine",
categoryKey: "custom",
icon: "Tag",
}),
]}
/>,
);
expect(html).toContain("Migraine");
expect(html).not.toContain("custom:abc-123");
});
it("does not render a local generic disclaimer", () => {
const html = render(
<MoodTagInfluence rows={[flatRow(), flatRow({ tag: "social" })]} />,
@@ -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:<uuid>` 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<typeof moodTagIcon> | 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:<uuid>` key).
label = factor.label;
Icon = moodTagIcon(factor.icon);
} else if (factor.labelKey) {
label = t(factor.labelKey);
Icon = moodTagIcon(factor.icon);
@@ -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:<uuid>` 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"
/>
<span className="text-foreground w-28 shrink-0 truncate">
{t(row.labelKey)}
{row.label ?? t(row.labelKey)}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}`
@@ -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:<uuid>` 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({
<ul className="divide-border divide-y">
{rows.map((row) => {
const Icon = row.labelKey ? moodTagIcon(row.icon) : null;
const label = row.labelKey ? t(row.labelKey) : row.tag;
const label =
row.label ?? (row.labelKey ? t(row.labelKey) : row.tag);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${row.delta.toFixed(1)}`;
return (
@@ -24,6 +24,12 @@ export type MoodCrosstabMode = "sameDay" | "nextDay";
export interface MoodTagMetricCrosstabRow {
tag: string;
labelKey: string;
/**
* v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
* tag's `labelKey` mirrors its raw `custom:<uuid>` key, so the label
* takes precedence over `t(labelKey)`. Null for catalogue tags.
*/
label?: string | null;
categoryKey: string;
icon: string | null;
metricKey: string;
@@ -88,7 +94,7 @@ export function MoodTagMetricCrosstab({
<ul className="divide-border divide-y">
{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,
);
+9 -1
View File
@@ -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 (
@@ -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}
/>
);
@@ -0,0 +1,124 @@
/**
* v1.16.11 — Bestand item-row action cluster.
*
* Renders `<InventorySection>` 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(
<I18nProvider initialLocale="de">{node}</I18nProvider>,
);
}
function seedItems(items: unknown[]) {
useQueryMock.mockReturnValue({
data: { items, meta: { total: items.length } },
isLoading: false,
});
}
describe("<InventorySection> — 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(
<InventorySection medicationId="med-1" unitsPerDose={1} />,
);
const adjust = html.match(
/<button[^>]*data-slot="inventory-adjust-button"[^>]*>/,
);
expect(adjust).not.toBeNull();
expect(adjust![0]).toContain('data-variant="outline"');
});
it("renders a per-row delete affordance behind a confirm trigger", () => {
seedItems([
{
id: "i1",
state: "ACTIVE",
containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 4,
},
{
id: "i2",
state: "IN_USE",
containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 2,
},
]);
const html = render(
<InventorySection medicationId="med-1" unitsPerDose={1} />,
);
// One AlertDialog trigger per row, labelled with the shared delete copy.
const triggers = html.match(/aria-label="Löschen"/g) ?? [];
expect(triggers).toHaveLength(2);
expect(html).toContain("alert-dialog-trigger");
});
it("places the state badge inline on the meta line as a non-interactive span", () => {
seedItems([
{
id: "i1",
state: "ACTIVE",
containerType: "PEN",
unitsTotal: 4,
unitsRemaining: 4,
},
]);
const html = render(
<InventorySection medicationId="med-1" unitsPerDose={1} />,
);
const badge = html.match(
/<(\w+)[^>]*data-slot="inventory-state-badge"[^>]*>/,
);
expect(badge).not.toBeNull();
expect(badge![1]).toBe("span");
expect(badge![0]).toContain("text-xs");
// The badge renders before the action cluster — inline with the
// meta figures, not floated next to the buttons.
const badgeIdx = html.indexOf('data-slot="inventory-state-badge"');
const dosesIdx = html.indexOf("4 / 4 Dosen");
const adjustIdx = html.indexOf('data-slot="inventory-adjust-button"');
expect(dosesIdx).toBeGreaterThan(-1);
expect(badgeIdx).toBeGreaterThan(dosesIdx);
expect(badgeIdx).toBeLessThan(adjustIdx);
});
});
@@ -295,3 +295,75 @@ describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
expect(html).not.toContain("can still be taken");
});
});
/**
* v1.16.11 (#316) — as-needed (PRN) card presentation: a calm
* "As needed" marker where next-due normally sits, no due pill, no
* overdue escalation, and NO compliance block (neither bars nor an
* eternal skeleton — the batched compliance read excludes PRN rows).
* The last-intake line stays: the card is last-taken oriented.
*/
describe("<MedicationCard> — as-needed marker (v1.16.11, #316)", () => {
function makeAsNeededMed(overrides: Record<string, unknown> = {}) {
const lastTaken = new Date();
lastTaken.setHours(lastTaken.getHours() - 3);
return {
id: "med-prn-1",
name: "Ibuprofen",
dose: "400 mg",
category: "PAIN_RELIEF",
treatmentClass: undefined as string | undefined,
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: lastTaken.toISOString(),
todayEventCount: 1,
nextDueAt: null,
nextDueOverdue: false,
asNeeded: true,
schedules: [],
...overrides,
};
}
it("renders the calm marker, the last-intake line, and no compliance block", () => {
const med = makeAsNeededMed();
const client = makeClient();
// Deliberately NOT seeding the compliance cache: the batched read
// excludes as-needed medications, so the card must not fall back to
// the loading skeleton.
const html = render(
<MedicationCard
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
/>,
client,
);
expect(html).toContain('data-slot="medication-as-needed-marker"');
expect(html).toContain("As needed");
expect(html).toContain("Last intake:");
// No due/overdue presentation, ever.
expect(html).not.toContain("Overdue");
expect(html).not.toContain("Take now");
// No compliance bars and no skeleton placeholder for them.
expect(html).not.toContain("Adherence (");
});
it("keeps the quick-log action row (intakes still record)", () => {
const med = makeAsNeededMed();
const client = makeClient();
const html = render(
<MedicationCard
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
/>,
client,
);
// The shared intake actions render (take/skip) — an as-needed dose
// is loggable from the card exactly like a scheduled one.
expect(html).toContain("Taken");
});
});
@@ -65,6 +65,20 @@ function seedCompliance(
]);
}
// Seed the SAME reminder-thresholds key the cards read — the stock
// cell's amber tier rides the per-user low-stock runway threshold
// (v1.16.11); `null` = the alert (and the tier) is off.
function seedThresholds(
client: QueryClient,
lowStockRunwayDays: number | null,
) {
client.setQueryData(["settings", "reminder-thresholds"], {
lateMinutes: 120,
missedMinutes: 240,
lowStockRunwayDays,
});
}
// A schedule whose window has already passed for today (01:00–02:00) so
// the row shows the next-intake value rather than the take-now pill,
// regardless of host clock.
@@ -217,6 +231,54 @@ describe("<MedicationTable> — structure + shared payloads", () => {
expect(html).toContain("text-warning");
});
it("warns on runway DAYS below the user's threshold, not a legacy dose count", () => {
const client = makeClient();
seedCompliance(client, "m1", 90, 88);
seedCompliance(client, "m2", 90, 88);
// Per-user threshold: 3 runway days (the same value the low-stock
// notification fires on).
seedThresholds(client, 3);
const html = render(
<MedicationTable
activeMedications={[
// Daily schedule → 5 doses = 5 runway days ≥ 3 → calm, even
// though the retired 4-dose constant would have tinted it.
med({ id: "m1", name: "Aspirin", stockDosesRemaining: 5 }),
// 2 doses = 2 runway days < 3 → amber.
med({ id: "m2", name: "Mounjaro", stockDosesRemaining: 2 }),
]}
inactiveMedications={[]}
/>,
client,
);
expect((html.match(/text-warning/g) ?? []).length).toBe(1);
expect(html.indexOf("text-warning")).toBeGreaterThan(
html.indexOf("Mounjaro"),
);
});
it("threshold OFF (null) disables the amber tier; an exhausted stock stays destructive", () => {
const client = makeClient();
seedCompliance(client, "m1", 90, 88);
seedCompliance(client, "m2", 90, 88);
seedThresholds(client, null);
const html = render(
<MedicationTable
activeMedications={[
med({ id: "m1", name: "Aspirin", stockDosesRemaining: 1 }),
med({ id: "m2", name: "Mounjaro", stockDosesRemaining: 0 }),
]}
inactiveMedications={[]}
/>,
client,
);
expect(html).not.toContain("text-warning");
// Stock 0 is destructive red regardless of the threshold switch.
expect(html).toContain("text-destructive");
});
it("pins inactive rows after the active block, muted and without actions", () => {
const client = makeClient();
seedCompliance(client, "m1", 90, 88);
@@ -261,6 +323,43 @@ describe("<MedicationTable> — structure + shared payloads", () => {
expect(html).toContain('aria-label="Take – Mounjaro"');
expect(html).toContain("4 doses");
});
it("renders an as-needed row: calm marker, no compliance bars (v1.16.11 #316)", () => {
const client = makeClient();
// Deliberately NOT seeding compliance for the PRN row — the batched
// payload excludes as-needed medications, and the column must show
// the en-dash instead of an eternal skeleton.
const lastTaken = new Date();
lastTaken.setHours(lastTaken.getHours() - 3);
const html = render(
<MedicationTable
activeMedications={[
med({
id: "prn1",
name: "Ibuprofen",
asNeeded: true,
schedules: [],
nextDueAt: null,
lastTakenAt: lastTaken.toISOString(),
stockDosesRemaining: 10,
}),
]}
inactiveMedications={[]}
/>,
client,
);
expect(html).toContain('data-slot="medication-table-as-needed-marker"');
expect(html).toContain("As needed");
// No due/overdue presentation in the status or next-dose cells.
expect(html).not.toContain("Overdue");
// Compliance column shows the en-dash, not bars or a skeleton.
expect(html).not.toContain("Adherence (");
// The quick actions stay — an as-needed dose is loggable inline.
expect(html).toContain('aria-label="Take – Ibuprofen"');
// Stock column still renders (inventory tracking works for PRN).
expect(html).toContain("10 doses");
});
});
describe("<MedicationTable> — manual order + aria-sort", () => {
@@ -0,0 +1,584 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { QueryClient as QueryClientType } from "@tanstack/react-query";
import { toast } from "sonner";
import { I18nProvider } from "@/lib/i18n/context";
import { medicationDependentKeys } from "@/lib/query-keys";
import {
deriveDueMedications,
runTakeAllDue,
type DueDerivationMedication,
type DueMedication,
} from "@/components/medications/take-all-due";
/**
* v1.16.11 (#316) — "take all due at once" coverage.
*
* 1. `deriveDueMedications` — the due set mirrors the card pills by
* construction: only ACTIVE, UNPAUSED medications with a non-null
* current-window status that is not the day-scale taken-early
* downgrade, gated on the server display-due exactly like the cards.
* 2. `runTakeAllDue` — per-medication POST loop with failure isolation:
* each due medication posts to ITS OWN intake route with the displayed
* slot's `scheduledFor`, the summary toast reports the partial-failure
* counts, and the dependent-key bundle invalidates once when at least
* one take landed.
* 3. `<TakeAllDueDialog>` — SSR list rendering (name + dose + window) per
* the repo's SSR-only component-test convention, plus source-string
* pins for the interactive plumbing static markup can't exercise.
*
* Fixtures use relative instants anchored on the host's current date
* (today at a fixed UTC wall-clock hour) — no hardcoded calendar dates.
*/
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// The Radix Dialog portals at runtime, so its body never materialises in
// static markup. Collapse the primitives to plain wrappers (same trick as
// the LogInjectionSiteDialog suite) so the list + footer are reachable.
vi.mock("@/components/ui/dialog", () => {
const Pass = ({ children }: { children?: React.ReactNode }) => (
<div>{children}</div>
);
return {
Dialog: ({
open,
children,
}: {
open: boolean;
children: React.ReactNode;
}) => (open ? <div data-slot="mock-dialog">{children}</div> : null),
DialogContent: Pass,
DialogDescription: Pass,
DialogFooter: Pass,
DialogHeader: Pass,
DialogTitle: Pass,
};
});
const { TakeAllDueDialog } = await import("../take-all-due-dialog");
beforeEach(() => {
vi.mocked(toast.success).mockClear();
vi.mocked(toast.error).mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
/** Today at the given UTC wall-clock time — relative to the host date. */
function todayAtUtc(hours: number, minutes = 0): Date {
const d = new Date();
d.setUTCHours(hours, minutes, 0, 0);
return d;
}
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}:${JSON.stringify(params)}` : key;
function fakeQueryClient(): QueryClientType {
return {
invalidateQueries: vi.fn().mockResolvedValue(undefined),
} as unknown as QueryClientType;
}
function makeMed(
overrides: Partial<DueDerivationMedication> & { id: string },
): DueDerivationMedication {
return {
name: `Med ${overrides.id}`,
dose: "10 mg",
active: true,
pausedAt: null,
lastTakenAt: null,
todayEventCount: 0,
schedules: [
{
windowStart: "12:00",
windowEnd: "12:00",
daysOfWeek: null,
timesOfDay: ["12:00"],
dose: null,
},
],
...overrides,
};
}
describe("deriveDueMedications — only currently-due, active, unpaused", () => {
// Noon UTC today; the 12:00 dose band (±60 min default half-width) is
// open, the 20:00 band has not opened, the 08:00 band sits in its
// very_late tail (ended 09:00, missedMinutes 240 → until 13:00).
const now = todayAtUtc(12, 0);
const opts = { now, tz: "UTC" };
it("includes an in-window medication with its matched band and slot instant", () => {
const due = deriveDueMedications([makeMed({ id: "a" })], opts);
expect(due).toHaveLength(1);
expect(due[0]).toMatchObject({
id: "a",
name: "Med a",
dose: "10 mg",
window: { start: "11:00", end: "13:00" },
});
// The displayed slot instant — today's 12:00 UTC dose, the same value
// the card's own button would thread as `scheduledFor`.
expect(due[0].scheduledFor?.toISOString()).toBe(
todayAtUtc(12, 0).toISOString(),
);
});
it("includes an overdue (very_late) dose still inside its catch-up tail", () => {
const due = deriveDueMedications(
[
makeMed({
id: "b",
schedules: [
{
windowStart: "08:00",
windowEnd: "08:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
dose: "5 mg",
},
],
}),
],
opts,
);
expect(due).toHaveLength(1);
// The matched schedule's dose wins over the medication-level dose.
expect(due[0].dose).toBe("5 mg");
expect(due[0].scheduledFor?.toISOString()).toBe(
todayAtUtc(8, 0).toISOString(),
);
});
it("excludes inactive and paused medications", () => {
const pausedAt = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
const due = deriveDueMedications(
[
makeMed({ id: "inactive", active: false }),
makeMed({ id: "paused", pausedAt }),
makeMed({ id: "due" }),
],
opts,
);
expect(due.map((d) => d.id)).toEqual(["due"]);
});
it("excludes a medication whose dose band is not currently open", () => {
const due = deriveDueMedications(
[
makeMed({
id: "evening",
schedules: [
{
windowStart: "20:00",
windowEnd: "20:00",
daysOfWeek: null,
timesOfDay: ["20:00"],
dose: null,
},
],
}),
],
opts,
);
expect(due).toEqual([]);
});
it("excludes an as-needed medication (no schedules, never due) — v1.16.11 #316", () => {
// An as-needed (PRN) medication persists ZERO schedules and the
// server computes no next-due, so it can NEVER enter the due set —
// even with a recent ad-hoc intake on record.
const due = deriveDueMedications(
[
makeMed({
id: "prn",
schedules: [],
nextDueAt: null,
lastTakenAt: new Date(now.getTime() - 60 * 60 * 1000).toISOString(),
todayEventCount: 1,
}),
makeMed({ id: "scheduled" }),
],
opts,
);
expect(due.map((m) => m.id)).toEqual(["scheduled"]);
});
it("excludes a dose already covered by an actioned intake in its band", () => {
// Taken 30 minutes ago — inside the open 12:00 band; the in-window
// suppression (same as the card pill) keeps it out of the due set.
const due = deriveDueMedications(
[
makeMed({
id: "covered",
lastTakenAt: new Date(now.getTime() - 30 * 60 * 1000).toISOString(),
todayEventCount: 1,
}),
],
opts,
);
expect(due).toEqual([]);
});
it("honours the server display-due gate: a future non-overdue next-due suppresses", () => {
const tomorrowNoon = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const due = deriveDueMedications(
[
makeMed({
id: "rolling",
nextDueAt: tomorrowNoon.toISOString(),
nextDueOverdue: false,
}),
],
opts,
);
expect(due).toEqual([]);
});
it("excludes a day-scale dose already taken earlier in its period (taken-early downgrade)", () => {
// Weekly cadence on today's weekday; the shot landed yesterday — the
// dose is on board, a "take all" inclusion would prompt a double dose.
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const due = deriveDueMedications(
[
makeMed({
id: "weekly",
lastTakenAt: yesterday.toISOString(),
schedules: [
{
windowStart: "12:00",
windowEnd: "12:00",
daysOfWeek: String(now.getUTCDay()),
timesOfDay: ["12:00"],
dose: null,
},
],
}),
],
opts,
);
expect(due).toEqual([]);
});
it("preserves the input (page) order", () => {
const due = deriveDueMedications(
[makeMed({ id: "z" }), makeMed({ id: "a" })],
opts,
);
expect(due.map((d) => d.id)).toEqual(["z", "a"]);
});
});
describe("deriveDueMedications — compliance-escalated medications (band tail closed)", () => {
// 14:00 UTC: the 08:00 dose band's catch-up tail closed at 13:00
// (missedMinutes 240), so the window-derived status is null — but the
// card still renders the red escalation value with an ACTIVE take
// button when the batched compliance dose status says overdue/missed.
const now = todayAtUtc(14, 0);
const morning = () =>
makeMed({
id: "morning",
schedules: [
{
windowStart: "08:00",
windowEnd: "08:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
dose: null,
},
],
// The server still serves the open overdue slot as next-due.
nextDueAt: todayAtUtc(8, 0).toISOString(),
nextDueOverdue: true,
});
it("includes a missed dose past its catch-up tail, recording the card's own displayed slot", () => {
const due = deriveDueMedications([morning()], {
now,
tz: "UTC",
doseStatusById: new Map([["morning", "missed"]]),
});
expect(due).toHaveLength(1);
expect(due[0].id).toBe("morning");
// Band closed → no matched window for the dialog line.
expect(due[0].window).toBeNull();
// EXACTLY what the card's take button records: the same
// `resolveDisplayedSlotInstant` call resolves (status null) to the
// server's next-due instant.
expect(due[0].scheduledFor?.toISOString()).toBe(
todayAtUtc(8, 0).toISOString(),
);
});
it("matches the count of visible red/due cards: dedupes window-due + escalated, excludes calm doses", () => {
const inWindow = makeMed({
id: "in-window",
schedules: [
{
windowStart: "14:00",
windowEnd: "14:00",
daysOfWeek: null,
timesOfDay: ["14:00"],
dose: null,
},
],
});
const evening = makeMed({
id: "evening",
schedules: [
{
windowStart: "20:00",
windowEnd: "20:00",
daysOfWeek: null,
timesOfDay: ["20:00"],
dose: null,
},
],
});
const due = deriveDueMedications([inWindow, morning(), evening], {
now,
tz: "UTC",
doseStatusById: new Map([
// The open-window med is ALSO compliance-escalated — it must
// appear exactly once (the two branches are exclusive).
["in-window", "overdue"],
["morning", "missed"],
["evening", "upcoming"],
]),
});
expect(due.map((d) => d.id)).toEqual(["in-window", "morning"]);
});
it("never includes an as-needed medication, even with a (bogus) escalated compliance row", () => {
const prn = makeMed({
id: "prn",
asNeeded: true,
schedules: [],
nextDueAt: null,
});
const due = deriveDueMedications([prn], {
now,
tz: "UTC",
doseStatusById: new Map([["prn", "missed"]]),
});
expect(due).toEqual([]);
});
it("does not escalate without a compliance verdict (window-only derivation stays the floor)", () => {
const due = deriveDueMedications([morning()], { now, tz: "UTC" });
expect(due).toEqual([]);
});
});
describe("runTakeAllDue — per-medication loop with failure isolation", () => {
const slotA = new Date(Date.now() - 60 * 60 * 1000);
const slotB = new Date(Date.now() - 30 * 60 * 1000);
const dueTwo: DueMedication[] = [
{ id: "med-a", name: "A", dose: "10 mg", window: null, scheduledFor: slotA },
{ id: "med-b", name: "B", dose: "5 mg", window: null, scheduledFor: slotB },
];
function okResponse(id: string) {
return {
ok: true,
json: vi.fn().mockResolvedValue({ data: { id } }),
text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id } })),
};
}
it("posts each due medication to ITS intake route with the displayed slot, exactly like a card tap", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(okResponse("evt-a"))
.mockResolvedValueOnce(okResponse("evt-b"));
vi.stubGlobal("fetch", fetchMock);
const queryClient = fakeQueryClient();
const result = await runTakeAllDue({ medications: dueTwo, t, queryClient });
expect(result).toEqual({ taken: 2, failed: 0 });
expect(fetchMock.mock.calls[0][0]).toBe("/api/medications/med-a/intake");
expect(fetchMock.mock.calls[1][0]).toBe("/api/medications/med-b/intake");
const bodyA = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
);
expect(bodyA).toEqual({
skipped: false,
scheduledFor: slotA.toISOString(),
});
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
'medications.takeAllDue.successToast:{"count":2}',
);
vi.unstubAllGlobals();
});
it("omits scheduledFor for a PRN due entry (null slot), preserving the server now-snap path", async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse("evt-prn"));
vi.stubGlobal("fetch", fetchMock);
await runTakeAllDue({
medications: [
{ id: "med-p", name: "P", dose: "", window: null, scheduledFor: null },
],
t,
queryClient: fakeQueryClient(),
});
const body = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
);
expect(body).toEqual({ skipped: false });
expect(body).not.toHaveProperty("scheduledFor");
vi.unstubAllGlobals();
});
it("reports a partial failure ('1 recorded, 1 failed') and still invalidates for the landed take", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(okResponse("evt-a"))
.mockResolvedValueOnce({ ok: false, json: vi.fn(), text: vi.fn() });
vi.stubGlobal("fetch", fetchMock);
const queryClient = fakeQueryClient();
const result = await runTakeAllDue({ medications: dueTwo, t, queryClient });
expect(result).toEqual({ taken: 1, failed: 1 });
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
'medications.takeAllDue.partialToast:{"taken":1,"failed":1}',
);
expect(vi.mocked(toast.success)).not.toHaveBeenCalledWith(
expect.stringContaining("successToast"),
);
// The landed take must reach every dependent consumer.
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(
medicationDependentKeys.length,
);
vi.unstubAllGlobals();
});
it("reports total failure without invalidating (nothing changed server-side)", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: false, json: vi.fn(), text: vi.fn() }),
);
const queryClient = fakeQueryClient();
const result = await runTakeAllDue({ medications: dueTwo, t, queryClient });
expect(result).toEqual({ taken: 0, failed: 2 });
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
"medications.takeAllDue.failedToast",
);
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("invalidates exactly the medicationDependentKeys bundle (source pin)", () => {
// The bundle includes the dashboard snapshot key (v1.16.11), so a
// batch take refreshes the hero dose tally too. Pin the source so a
// refactor to a hand-rolled key list cannot slip through.
const src = readFileSync(
resolve(__dirname, "../take-all-due.ts"),
"utf8",
);
expect(src).toContain(
"await invalidateKeys(queryClient, medicationDependentKeys)",
);
});
});
describe("<TakeAllDueDialog> — confirm dialog lists the due medications", () => {
const dialogSrc = readFileSync(
resolve(__dirname, "../take-all-due-dialog.tsx"),
"utf8",
);
const pageSrc = readFileSync(
resolve(__dirname, "../../../app/medications/page.tsx"),
"utf8",
);
function render(node: React.ReactNode): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<I18nProvider initialLocale="de">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
const due: DueMedication[] = [
{
id: "med-a",
name: "Metoprolol",
dose: "47,5 mg",
window: { start: "07:00", end: "09:00" },
scheduledFor: new Date(Date.now() - 60 * 60 * 1000),
},
{
id: "med-b",
name: "Vitamin D",
dose: "20 000 IE",
window: { start: "11:00", end: "13:00" },
scheduledFor: new Date(Date.now() - 5 * 60 * 1000),
},
];
it("renders each due medication with name, dose and window plus the confirm/cancel footer", () => {
const html = render(
<TakeAllDueDialog open onOpenChange={vi.fn()} dueMedications={due} />,
);
expect(html).toContain("Metoprolol");
expect(html).toContain("47,5 mg");
expect(html).toContain("Vitamin D");
// DE window formatting via the shared formatter.
expect(html).toContain("07:00 bis 09:00 Uhr");
expect(html).toContain("11:00 bis 13:00 Uhr");
// Title + confirm carrying the count, German du-Form copy.
expect(html).toContain("Fällige Medikamente einnehmen");
expect(html).toContain("Alle einnehmen (2)");
expect(html).toContain("Abbrechen");
});
it("does not render when closed", () => {
const html = render(
<TakeAllDueDialog
open={false}
onOpenChange={vi.fn()}
dueMedications={due}
/>,
);
expect(html).toBe("");
});
it("guards the take loop behind a submitting state and refuses to close mid-flight (source pin)", () => {
expect(dialogSrc).toContain(
"const [submitting, setSubmitting] = useState(false)",
);
expect(dialogSrc).toMatch(
/async function handleConfirm\(\) \{[\s\S]*setSubmitting\(true\)[\s\S]*await runTakeAllDue\([\s\S]*finally \{[\s\S]*setSubmitting\(false\)/,
);
expect(dialogSrc).toContain("if (submitting && !next) return;");
// 44-px mobile tap floor on both footer actions.
expect(dialogSrc).toContain('className="min-h-11 sm:min-h-9"');
});
it("earns its medications-page header slot only when at least two medications are due (source pin)", () => {
expect(pageSrc).toContain("dueMeds.length >= 2 && (");
expect(pageSrc).toContain("deriveDueMedications(activeMeds");
expect(pageSrc).toContain('t("medications.takeAllDue.button")');
});
});
@@ -5,6 +5,7 @@ import { AlertTriangle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import {
@@ -111,6 +112,25 @@ export interface MedicationCardBodyProps {
/** The open-cycle descriptor, or null when the display block is absent. */
currentCycle: CurrentCycle | null;
/**
* v1.16.11 (#316) — as-needed (PRN) medication. True suppresses the
* compliance block entirely (no bars, no skeleton, no error fallback —
* the batched compliance read excludes as-needed medications, so a
* skeleton would spin forever) and the open-cycle slot. The next/last
* lines carry the "Bei Bedarf" + last-taken presentation instead.
*/
asNeeded?: boolean;
/**
* v1.16.11 — projected supply runway in whole days, set ONLY while it
* sits below the user's low-stock threshold (the variants gate it).
* Non-null renders one muted warning-toned "Vorrat: ≈ N Tage" line
* under the next/last slot; null keeps the card free of stock noise.
* Never set for as-needed medications (no consuming schedule, no
* runway).
*/
lowStockRunwayDays?: number | null;
/** "take" | "skip" while the matching request is in flight, else null. */
intakeLoading: string | null;
/** Record the displayed dose (the card binds the slot instant). */
@@ -137,11 +157,13 @@ export function MedicationCardBody({
complianceError = false,
onRetryCompliance,
currentCycle,
asNeeded = false,
lowStockRunwayDays = null,
intakeLoading,
onRecordIntake,
children,
}: MedicationCardBodyProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
// Overdue escalation line: a dose past its on-time window. `missed` (at /
// past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable
@@ -154,6 +176,53 @@ export function MedicationCardBody({
? t("medications.overdue")
: null;
// v1.16.11 — EVERY dose state rides the "next intake" row as its
// right-aligned value: green take-now, amber late, red escalation.
// The former standalone status line above the slot is gone — it
// reserved a blank row on every settled card (the overdue tier rides
// the async compliance query) and broke the card's label/value
// grammar. Urgency still reads at a glance through the value's
// colour + glyph.
//
// Taken-early (v1.16.9 double-dose guard) suppresses every prompt
// tier: the row falls through to the plain schedule value and the
// "last intake" line directly below already names the recent dose —
// the guard's job is that no take prompt renders, not a louder line.
//
// The escalation value keeps the due time: the red badge alone evicted
// the window / time the amber tier used to carry, leaving the user
// with urgency but no anchor. The matched window rides along when one
// is still open; otherwise the card's own next-intake value (the
// overdue slot time) fills the gap.
const nextValueOverride =
windowStatus?.takenEarlyDaysAgo != null ? null : overdueLabel ? (
<span className="inline-flex flex-wrap items-center gap-x-1 gap-y-0.5 text-sm">
<span className="text-destructive inline-flex items-center gap-1 font-medium">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
{overdueLabel}
</span>
{windowStatus !== null ? (
<span className="text-muted-foreground">
—{" "}
{formatTimeWindowRange(
windowStatus.windowStart,
windowStatus.windowEnd,
locale,
)}
</span>
) : nextLine !== null ? (
<span className="text-muted-foreground">— {nextLine}</span>
) : null}
</span>
) : windowStatus !== null ? (
<MedicationStatusPill
status={windowStatus.status}
windowStart={windowStatus.windowStart}
windowEnd={windowStatus.windowEnd}
inline
/>
) : null;
return (
<Card
// The card surface is a constant neutral surface — dose status is never
@@ -172,40 +241,29 @@ export function MedicationCardBody({
/>
<CardContent className="flex h-full flex-col space-y-3.5">
{/* Top status line. The in-window take-now pill (success) and the
overdue escalation are mutually exclusive — a dose is either still
in its take-window or past it. The overdue line wins when present
so a heavily-overdue dose reads loud at the top of the card.
The slot keeps a constant one-line height even when empty: the
overdue tier arrives with the compliance query, and a line
popping in late used to shift every row below it (CLS). */}
<div className="min-h-5">
{windowStatus?.takenEarlyDaysAgo != null ? (
// v1.16.9 — last-dose context outranks the overdue line: a
// day-scale dose already taken earlier in its period must never
// escalate into a take prompt (double-dose risk).
<MedicationStatusPill
status={windowStatus.status}
windowStart={windowStatus.windowStart}
windowEnd={windowStatus.windowEnd}
takenEarlyDaysAgo={windowStatus.takenEarlyDaysAgo}
/>
) : overdueLabel ? (
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
{overdueLabel}
</p>
) : windowStatus ? (
<MedicationStatusPill
status={windowStatus.status}
windowStart={windowStatus.windowStart}
windowEnd={windowStatus.windowEnd}
/>
) : null}
</div>
{/* Next / last intake — the two decisive lines, each shown once.
The dose state (take-now / overdue / taken-early) IS the
next-intake value when present; the schedule value fills the
row otherwise. */}
<MedicationNextLastSlot
next={nextValueOverride ?? nextLine}
last={lastLine}
/>
{/* Next / last intake — the two decisive lines, each shown once. */}
<MedicationNextLastSlot next={nextLine} last={lastLine} />
{/* v1.16.11 — low-stock context, ONLY below the user's runway
threshold (the prop is pre-gated). One calm warning-toned
line, not a badge: the card stays quiet until the supply
actually needs attention. */}
{active && !asNeeded && lowStockRunwayDays != null && (
<p
className="text-warning text-xs"
data-slot="medication-card-low-stock"
>
{t("medications.cardLowStockRunway", {
days: lowStockRunwayDays,
})}
</p>
)}
{/* Compliance bars — always two rows; constant-height skeleton holds
the slot while the query is in flight so the grid row stays even.
@@ -213,6 +271,7 @@ export function MedicationCardBody({
error fallback (no bars, one notice line + retry) instead of
sitting on the skeleton forever. */}
{active &&
!asNeeded &&
(compliance ? (
<MedicationComplianceBars
rate7={compliance.rate7}
@@ -231,7 +290,7 @@ export function MedicationCardBody({
one xs text line on every active card: the descriptor rides the
compliance query, and a line appearing after the fetch resolved
used to grow the card and shift the grid row (CLS). */}
{active && (
{active && !asNeeded && (
<div className="min-h-4">
{currentCycle && <MedicationCycleStatus cycle={currentCycle} />}
</div>
@@ -26,6 +26,14 @@ interface MedicationStatusPillProps {
* with the card pill — only the suffix is dropped.
*/
compact?: boolean;
/**
* v1.16.11 — inline variant: renders a `<span>` root instead of the
* block `<p>` 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 `<p>` inside the row's `<span>`
* 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 `<p>` contexts keep the legacy truncation.
const suffixClass = inline
? "text-muted-foreground"
: "text-muted-foreground hidden sm:inline";
if (takenEarlyDaysAgo != null) {
return (
<p className="text-sm">
<Root className="text-sm">
<span className="text-muted-foreground inline-flex items-center gap-1 font-medium">
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
{takenEarlyDaysAgo === 1
@@ -55,17 +73,17 @@ export function MedicationStatusPill({
: t("medications.lastDoseDaysAgo", { count: takenEarlyDaysAgo })}
</span>
{!compact && (
<span className="text-muted-foreground hidden sm:inline">
<span className={suffixClass}>
{" "}
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
</span>
)}
</p>
</Root>
);
}
return (
<p className="text-sm">
<Root className="text-sm">
<span
className={
// v1.12.2 — converge the three tiers onto the semantic feedback
@@ -96,11 +114,11 @@ export function MedicationStatusPill({
: t("medications.veryOverdue")}
</span>
{!compact && (
<span className="text-muted-foreground hidden sm:inline">
<span className={suffixClass}>
{" "}
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
</span>
)}
</p>
</Root>
);
}
@@ -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<TabSlug[]>(
() => 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"
/>
<StatusRow
label={t("medications.detail.uebersicht.reminderLabel")}
value={
medication.notificationsEnabled
? t("medications.detail.uebersicht.reminderOn")
: t("medications.detail.uebersicht.reminderOff")
}
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
onJump={() => onTabChange("zeitplan")}
dataSlot="uebersicht-reminder"
/>
{/* v1.16.11 — as-needed never reminds; the row would only
mislead (the cron skips schedule-less medications). */}
{!asNeeded && (
<StatusRow
label={t("medications.detail.uebersicht.reminderLabel")}
value={
medication.notificationsEnabled
? t("medications.detail.uebersicht.reminderOn")
: t("medications.detail.uebersicht.reminderOff")
}
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
onJump={() => onTabChange("zeitplan")}
dataSlot="uebersicht-reminder"
/>
)}
{inventoryItems.length > 0 && (
<StatusRow
label={t("medications.detail.uebersicht.supplyLabel")}
@@ -471,6 +497,7 @@ export function MedicationDetailTabs({
</ul>
</MedicationDetailSection>
{!asNeeded && (
<MedicationDetailSection
titleId="medication-uebersicht-compliance-heading"
title={t("medications.detail.uebersicht.complianceTitle")}
@@ -490,6 +517,7 @@ export function MedicationDetailTabs({
</p>
)}
</MedicationDetailSection>
)}
</TabsContent>
{/* ZEITPLAN — inline edit of the everyday levers: dose times +
@@ -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)}
>
+61 -5
View File
@@ -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 ? (
<span className="text-muted-foreground" data-slot="medication-as-needed-marker">
{t("medications.asNeededMarker")}
</span>
) : 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)}
>
@@ -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 (
<div className="space-y-1.5" data-slot="medication-detail-summary">
+95 -24
View File
@@ -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<T extends TableMedication>(
});
}
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 (
<div className="bg-card border-border rounded-xl border">
// `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.
<div className="bg-card border-border overflow-hidden rounded-xl border">
<Table>
<caption className="sr-only">{t("medications.tableCaption")}</caption>
<TableHeader>
@@ -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}
/>
))}
</TableBody>
@@ -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 = (
<span className="text-muted-foreground">–</span>
);
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 = (
<span
className="text-muted-foreground"
data-slot="medication-table-as-needed-marker"
>
{t("medications.asNeededMarker")}
</span>
);
} else if (medication.active) {
if (medication.nextDueOverdue && nextAt) {
nextCell = (
<span className="font-medium text-amber-600 dark:text-amber-400">
@@ -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 ? (
<span className="text-muted-foreground">–</span>
) : (
<span
className={cn(
"tabular-nums",
stock === 0
? "text-destructive font-medium"
: stock < LOW_STOCK_DOSES
? "text-warning font-medium"
: undefined,
<span className="flex flex-col">
<span
className={cn(
"tabular-nums",
stock === 0
? "text-destructive font-medium"
: lowStock
? "text-warning font-medium"
: undefined,
)}
>
{stock === 1
? t("medications.tableStockDoseOne")
: t("medications.tableStockDoses", { count: stock })}
</span>
{runwayDays !== null && (
<span
className="text-muted-foreground text-xs"
data-slot="medication-table-runway"
>
{t("medications.tableStockRunway", { days: runwayDays })}
</span>
)}
>
{stock === 1
? t("medications.tableStockDoseOne")
: t("medications.tableStockDoses", { count: stock })}
</span>
);
@@ -583,7 +651,7 @@ function MedicationTableRowItem({
</TableCell>
<TableCell>{statusCell}</TableCell>
<TableCell className="text-sm">{nextCell}</TableCell>
<TableCell>{medication.active ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
<TableCell>{medication.active && !medication.asNeeded ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active ? (
@@ -636,7 +704,10 @@ function MedicationTableRowItem({
*/
export function MedicationTableSkeleton({ rows = 4 }: { rows?: number }) {
return (
<div className="bg-card border-border rounded-xl border" aria-hidden="true">
<div
className="bg-card border-border overflow-hidden rounded-xl border"
aria-hidden="true"
>
<Table>
<TableHeader>
<TableRow>
@@ -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<InventoryItem | null>(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 (
<div
@@ -176,6 +222,14 @@ export function InventorySection({
onClose={() => setAdjustItem(null)}
/>
)}
{packagingOpen && (
<PackagingDialog
medicationId={medicationId}
unitsPerDose={perDose}
dosesPerUnit={dosesPerUnit ?? null}
onClose={() => setPackagingOpen(false)}
/>
)}
</>
);
@@ -238,6 +292,55 @@ export function InventorySection({
{t("medications.detail.bestand.addButton")}
</Button>
</div>
{/* 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. */}
<div className="py-2">
<Link
href="/settings/notifications#low-stock"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
data-slot="inventory-low-stock-link"
>
{lowStockDays !== null
? t("medications.detail.bestand.lowStockLinkOn", {
days: lowStockDays,
})
: t("medications.detail.bestand.lowStockLinkOff")}
</Link>
</div>
</SettingsGroup>
{/* 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. */}
<SettingsGroup
label={t("medications.detail.bestand.packagingTitle")}
dataSlot="inventory-packaging-group"
>
<div className="flex items-center justify-between gap-3 py-3">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.bestand.packagingUnitsPerDose", {
units: perDose,
})}
<span className="text-muted-foreground block text-xs font-normal">
{dosesPerUnit != null
? t("medications.detail.bestand.packagingDefaultPack", {
units: dosesPerUnit,
})
: t("medications.detail.bestand.packagingNoDefaultPack")}
</span>
</p>
<Button
variant="outline"
size="sm"
onClick={() => setPackagingOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="inventory-packaging-edit"
>
{t("medications.detail.bestand.packagingEdit")}
</Button>
</div>
</SettingsGroup>
<SettingsGroup
@@ -256,28 +359,36 @@ export function InventorySection({
`medications.detail.bestand.containerType.${item.containerType}`,
)}
</span>
<span className="text-muted-foreground block text-xs">
{t("medications.detail.bestand.doses", {
remaining: Math.floor(item.unitsRemaining / perDose),
total: Math.floor(item.unitsTotal / perDose),
})}
{perDose > 1 && (
<>
{" · "}
{t("medications.detail.bestand.unitsDetail", {
remaining: item.unitsRemaining,
total: item.unitsTotal,
})}
</>
)}
{/* Meta line: per-container figures with the state badge
inline at meta-text size — read-only, never a control. */}
<span className="text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
<span>
{t("medications.detail.bestand.doses", {
remaining: Math.floor(item.unitsRemaining / perDose),
total: Math.floor(item.unitsTotal / perDose),
})}
{perDose > 1 && (
<>
{" · "}
{t("medications.detail.bestand.unitsDetail", {
remaining: item.unitsRemaining,
total: item.unitsTotal,
})}
</>
)}
</span>
<Badge
variant={STATE_BADGE[item.state]}
className="px-1.5 py-0 text-xs font-normal"
data-slot="inventory-state-badge"
>
{t(`medications.detail.bestand.state.${item.state}`)}
</Badge>
</span>
</span>
<span className="flex shrink-0 items-center gap-1">
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
{t(`medications.detail.bestand.state.${item.state}`)}
</Badge>
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() => setAdjustItem(item)}
className="min-h-11 sm:min-h-9"
@@ -285,6 +396,11 @@ export function InventorySection({
>
{t("medications.detail.bestand.adjustButton")}
</Button>
<DeleteButton
onConfirm={() => void deleteItem(item)}
title={t("medications.detail.bestand.deleteTitle")}
description={t("medications.detail.bestand.deleteDescription")}
/>
</span>
</div>
))}
@@ -620,3 +736,156 @@ function AdjustInventoryDialog({
</Dialog>
);
}
/**
* Edit the medication-level packaging economics from the supply tab:
* units one dose consumes, and the default container size the register
* flow prefills. Both PUT sparsely onto the medication — the wizard's
* dose step stays the source on create; this is the correction surface
* for a manufacturer switch (same medication, different blister size).
*/
function PackagingDialog({
medicationId,
unitsPerDose,
dosesPerUnit,
onClose,
}: {
medicationId: string;
unitsPerDose: number;
dosesPerUnit: number | null;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [perDoseValue, setPerDoseValue] = useState(String(unitsPerDose));
const [packValue, setPackValue] = useState(
dosesPerUnit === null ? "" : String(dosesPerUnit),
);
const [busy, setBusy] = useState(false);
const parsedPerDose = Number(perDoseValue);
const perDoseValid =
Number.isInteger(parsedPerDose) && parsedPerDose >= 1 && parsedPerDose <= 100;
const parsedPack = packValue.trim() === "" ? null : Number(packValue);
const packValid =
parsedPack === null ||
(Number.isInteger(parsedPack) && parsedPack >= 1 && parsedPack <= 1000);
async function submit(e: FormEvent) {
e.preventDefault();
if (!perDoseValid || !packValid || busy) return;
setBusy(true);
try {
await apiPut(`/api/medications/${medicationId}`, {
unitsPerDose: parsedPerDose,
dosesPerUnit: parsedPack,
});
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.medicationDetail(medicationId),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.medications(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
}),
]);
toast.success(t("medications.detail.bestand.packagingSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.packagingFailed"));
} finally {
setBusy(false);
}
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("medications.detail.bestand.packagingTitle")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.bestand.packagingDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="packaging-units-per-dose"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
</label>
<Input
id="packaging-units-per-dose"
type="number"
inputMode="numeric"
min={1}
max={100}
step={1}
required
autoComplete="off"
value={perDoseValue}
onChange={(e) => setPerDoseValue(e.target.value)}
aria-describedby="packaging-units-per-dose-helper"
/>
<p
id="packaging-units-per-dose-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="packaging-default-pack"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingDefaultPackLabel")}
</label>
<Input
id="packaging-default-pack"
type="number"
inputMode="numeric"
min={1}
max={1000}
step={1}
autoComplete="off"
value={packValue}
onChange={(e) => setPackValue(e.target.value)}
aria-describedby="packaging-default-pack-helper"
/>
<p
id="packaging-default-pack-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingDefaultPackHelper")}
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={!perDoseValid || !packValid || busy}>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("common.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,139 @@
"use client";
/**
* v1.16.11 (#316) — confirm dialog for the medications-page "Alle
* fälligen einnehmen" header action. Lists every currently-due
* medication (name + dose + matched dose window) and records all of them
* in one confirmed action through `runTakeAllDue` (the per-medication
* intake loop — see `take-all-due.ts` for the loop-vs-bulk decision).
*
* The component owns only the dialog shell + the submitting guard; the
* derivation and the recording orchestration live in the pure,
* injection-tested module. While a run is in flight the dialog refuses
* to close (mirroring `LogInjectionSiteDialog`'s controlled-submit
* contract) so the summary toast is never orphaned mid-request.
*/
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CheckCheck, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useTranslations } from "@/lib/i18n/context";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import {
runTakeAllDue,
type DueMedication,
} from "@/components/medications/take-all-due";
export interface TakeAllDueDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The currently-due set from `deriveDueMedications`, in page order. */
dueMedications: DueMedication[];
}
export function TakeAllDueDialog({
open,
onOpenChange,
dueMedications,
}: TakeAllDueDialogProps) {
const { t, locale } = useTranslations();
const queryClient = useQueryClient();
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
if (submitting) return;
setSubmitting(true);
try {
await runTakeAllDue({ medications: dueMedications, t, queryClient });
// Close regardless of partial failures — the summary toast carries
// the counts and failed medications stay due on the page behind it.
onOpenChange(false);
} finally {
setSubmitting(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
// Refuse to close while the take loop is in flight.
if (submitting && !next) return;
onOpenChange(next);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.takeAllDue.dialogTitle")}</DialogTitle>
<DialogDescription>
{t("medications.takeAllDue.dialogDescription")}
</DialogDescription>
</DialogHeader>
<ul className="space-y-2" data-testid="take-all-due-list">
{dueMedications.map((med) => (
<li
key={med.id}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="min-w-0 truncate">
<span className="font-medium">{med.name}</span>
{med.dose ? (
<span className="text-muted-foreground"> — {med.dose}</span>
) : null}
</span>
{med.window ? (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatTimeWindowRange(
med.window.start,
med.window.end,
locale,
)}
</span>
) : null}
</li>
))}
</ul>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
className="min-h-11 sm:min-h-9"
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={() => void handleConfirm()}
disabled={submitting || dueMedications.length === 0}
className="min-h-11 sm:min-h-9"
data-testid="take-all-due-confirm"
>
{submitting ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<CheckCheck className="h-4 w-4" aria-hidden="true" />
)}
{t("medications.takeAllDue.confirm", {
count: dueMedications.length,
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+252
View File
@@ -0,0 +1,252 @@
/**
* v1.16.11 (#316) — "take all due at once": the due-set derivation plus
* the confirm-time orchestration behind the medications-page header
* button. A user with several medications due in the current window
* confirms them in ONE action instead of per-card taps.
*
* Due-set derivation reuses the EXACT pipeline a single card runs —
* `reduceCurrentWindowStatus` (band model + server display-due gate +
* taken-early downgrade) and `resolveDisplayedSlotInstant` (the canonical
* slot the card's own "Genommen" button would record) — so the set the
* dialog lists is, by construction, the set of cards currently showing a
* take-now / overdue pill. No new endpoint: every input rides on the
* `GET /api/medications` list payload the page already holds.
*
* Recording loops the per-medication `POST /api/medications/{id}/intake`
* (the same call `runRecordIntake` makes for one card) rather than the
* `POST /api/medications/intake/bulk` endpoint, deliberately:
*
* - The bulk route is the iOS SyncMode surface: its taken path requires
* an explicit `takenAt` and attributes by band membership
* (`resolveSlotForWriteByBand`) or a `forceSlotInstant` user pin. The
* interactive card semantics instead send the DISPLAYED slot as
* `scheduledFor` and let the server stamp `takenAt = now` — for an
* overdue slot whose catch-up tail extends past the band, the two
* attributions can diverge. Looping the single route keeps slot
* attribution, inventory consumption (`consumedTransition` gate) and
* the canonical-slot upsert idempotency byte-identical to N
* individual taps.
* - Per-medication failure isolation falls out naturally: each POST
* succeeds or fails alone, the summary toast reports both counts, and
* the failed medications simply stay due (the invalidation refetch
* re-derives them into the set).
*
* The due set is small (a handful of medications, never the 500-entry
* sync scale), so N sequential round trips are negligible.
*/
import type { QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import {
reduceCurrentWindowStatus,
toZonedDate,
type ScheduleWindowInput,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import type { DoseStatus } from "@/lib/analytics/compliance";
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
/** Schedule shape the derivation needs — the list payload is a superset. */
export interface DueDerivationSchedule extends ScheduleWindowInput {
dose: string | null;
}
/** Medication shape the derivation needs — the list payload is a superset. */
export interface DueDerivationMedication {
id: string;
name: string;
dose: string;
active: boolean;
pausedAt: string | null;
lastTakenAt: string | null;
todayEventCount?: number | null;
/** Server display-due verdict from `GET /api/medications`. */
nextDueAt?: string | null;
nextDueOverdue?: boolean;
/** v1.16.11 (#316) — as-needed (PRN): never due, never in the set. */
asNeeded?: boolean;
schedules: DueDerivationSchedule[];
}
/** One medication the confirm dialog lists and the take loop records. */
export interface DueMedication {
id: string;
name: string;
/** Matched schedule dose, falling back to the medication-level dose. */
dose: string;
/** HH:mm bounds of the matched dose band, for the dialog's window line. */
window: { start: string; end: string } | null;
/**
* Canonical slot instant the take records (threaded as `scheduledFor`,
* exactly like the card's own button). Null preserves the PRN /
* unscheduled now-snap path.
*/
scheduledFor: Date | null;
}
const DEFAULT_THRESHOLDS = { lateMinutes: 120, missedMinutes: 240 };
/**
* Derive the medications that are due RIGHT NOW — active, unpaused, with
* a non-null current-window status (in_window / late / very_late) that is
* not the day-scale taken-early downgrade. Input order is preserved so
* the dialog lists medications in the same order the page renders them.
*
* The due set ADDITIONALLY includes escalated medications: an active,
* unpaused, non-as-needed medication whose batched compliance
* `currentDose.status` (`doseStatusById`, the same
* `useMedicationComplianceSummaryAll` rows the page and the table hold)
* is `overdue` / `missed`. A dose whose band + catch-up tail have closed
* renders the red escalation value with an ACTIVE take button on its
* card, so it must be takeable from the batch too — the window-only
* derivation silently dropped it. The recorded slot for such an entry is
* exactly what the card's own take button records: the SAME
* `resolveDisplayedSlotInstant` call over the same (closed) window
* status, which resolves to the server's `nextDueAt` instant (or null,
* preserving the unscheduled now-snap path). Entries dedupe by
* construction — the window branch and the escalation branch are
* mutually exclusive per medication.
*/
export function deriveDueMedications(
medications: DueDerivationMedication[],
options: {
now?: Date;
/** IANA timezone (profile timezone; Berlin is the legacy fallback). */
tz?: string;
thresholds?: { lateMinutes: number; missedMinutes: number };
/**
* Batched compliance dose status per medication id (the page's
* `useMedicationComplianceSummaryAll` rows). Absent / unknown ids
* keep the window-only derivation — the legacy behaviour.
*/
doseStatusById?: ReadonlyMap<string, DoseStatus>;
} = {},
): DueMedication[] {
const now = options.now ?? new Date();
const tz = options.tz ?? "Europe/Berlin";
const { lateMinutes, missedMinutes } =
options.thresholds ?? DEFAULT_THRESHOLDS;
const nowLocal = toZonedDate(now, tz);
const due: DueMedication[] = [];
for (const m of medications) {
// Paused courses keep their schedules but must never surface here —
// the cards suppress their pills the same way. As-needed (PRN)
// medications are never due, structurally — even a (bogus)
// compliance row for one must not pull it into the set.
if (!m.active || m.pausedAt || m.asNeeded) continue;
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
nowBerlin: nowLocal,
lateMinutes,
missedMinutes,
active: m.active,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
tz,
// The same server display-due gate the cards apply: a future
// (non-overdue) next-due suppresses the overdue tiers; `undefined`
// (older mocks) keeps the legacy band-only behaviour.
nextDue:
m.nextDueAt === undefined
? undefined
: Number.isFinite(nextDueMs)
? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true }
: null,
});
// `takenEarlyDaysAgo` non-null = a day-scale dose already on board
// earlier in its period; including it would build a double-dose
// confirm list. The card suppresses every prompt tier (incl. the red
// escalation value) the same way.
if (status.takenEarlyDaysAgo !== null) continue;
// Escalation alignment with the card: a compliance-derived
// overdue / missed dose keeps an active take button on its card even
// after the band's catch-up tail closed (window status null).
const complianceStatus = options.doseStatusById?.get(m.id);
const escalated =
complianceStatus === "overdue" || complianceStatus === "missed";
if (!status.status && !escalated) continue;
due.push({
id: m.id,
name: m.name,
dose: status.schedule?.dose ?? m.dose,
window: status.window
? { start: status.window.start, end: status.window.end }
: null,
scheduledFor: resolveDisplayedSlotInstant({
currentWindowStatus: status,
nextDueAt: m.nextDueAt ?? null,
now,
timeZone: tz,
}),
});
}
return due;
}
/**
* Record every due medication through the per-medication intake route,
* sequentially, with per-medication failure isolation (see the module doc
* for the loop-vs-bulk decision). Dependencies are injected so this is
* unit-testable without a React render, mirroring `runRecordIntake`.
*
* Toast contract: all-success → one success summary; partial → an error
* summary carrying both counts ("3 erfasst, 1 fehlgeschlagen"); all-fail
* → a plain failure prompt. Failed medications stay due — the dependent-
* key invalidation refetches the list and the derivation re-surfaces
* them. The optional per-card injection-site follow-up prompt is
* intentionally NOT part of the batch confirm (it is skippable on the
* single path too); the site stays editable from the intake history.
*/
export async function runTakeAllDue(deps: {
medications: DueMedication[];
t: Translator;
queryClient: QueryClient;
}): Promise<{ taken: number; failed: number }> {
const { medications, t, queryClient } = deps;
let taken = 0;
let failed = 0;
for (const med of medications) {
try {
await apiPost(
`/api/medications/${med.id}/intake`,
med.scheduledFor
? { skipped: false, scheduledFor: med.scheduledFor.toISOString() }
: { skipped: false },
);
taken += 1;
} catch {
failed += 1;
}
}
// One invalidation for the whole batch (the bundle includes the
// dashboard snapshot key, so the hero dose tally refreshes too). A
// zero-success run changed nothing server-side — skip the refetch and
// keep the due set as-is for the retry.
if (taken > 0) {
await invalidateKeys(queryClient, medicationDependentKeys);
}
if (failed === 0) {
toast.success(t("medications.takeAllDue.successToast", { count: taken }));
} else if (taken > 0) {
toast.error(t("medications.takeAllDue.partialToast", { taken, failed }));
} else {
toast.error(t("medications.takeAllDue.failedToast"));
}
return { taken, failed };
}
@@ -1036,3 +1036,93 @@ describe("v1.15.18 per-dose windows — encode + hydrate round-trip", () => {
]);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.16.11 (#316) — as-needed (PRN) mode
// ────────────────────────────────────────────────────────────────────
describe("as-needed mode (v1.16.11, #316)", () => {
function asNeededPayload(): WizardPayload {
return {
...emptyWizardPayload(),
mode: "asNeeded",
treatmentRow: "painRelief",
name: "Ibuprofen",
doseAmount: "400",
doseUnit: "mg",
unitsPerDose: "2",
};
}
it("progressIndices walks the one-shot-shaped path (no cadence detail, no times)", () => {
expect(progressIndices("asNeeded", "daily")).toEqual([1, 2, 3, 4, 8]);
});
it("buildCreateBody emits asNeeded:true with an EMPTY schedules array", () => {
const body = buildCreateBody(asNeededPayload());
expect(body.asNeeded).toBe(true);
expect(body.oneShot).toBe(false);
expect(body.schedules).toEqual([]);
// Dose fields stay first-class — they feed inventory consumption.
expect(body.dose).toBe("400 mg");
expect(body.unitsPerDose).toBe(2);
expect(body.category).toBe("PAIN_RELIEF");
});
it("a scheduled payload emits asNeeded:false (no accidental flag bleed)", () => {
const body = buildCreateBody(withCadence("daily"));
expect(body.asNeeded).toBe(false);
expect(body.schedules.length).toBeGreaterThan(0);
});
it("hydrateWizardPayload round-trips an as-needed snapshot back onto the mode", () => {
const hydrated = hydrateWizardPayload({
id: "m1",
name: "Ibuprofen",
dose: "400 mg",
category: "PAIN_RELIEF",
notificationsEnabled: true,
startsOn: null,
endsOn: null,
oneShot: false,
asNeeded: true,
schedules: [],
});
expect(hydrated.mode).toBe("asNeeded");
// Step 5 pre-selects "Bei Bedarf" via the mode; the placeholder
// draft carries it too so the flat mirror stays coherent.
expect(hydrated.schedules).toHaveLength(1);
expect(hydrated.schedules[0].mode).toBe("asNeeded");
// Full round-trip: hydrate → encode emits the same wire shape.
const body = buildCreateBody(hydrated, "edit");
expect(body.asNeeded).toBe(true);
expect(body.schedules).toEqual([]);
// Edit bodies never round-trip the reminders default (invariant 16).
expect(body.notificationsEnabled).toBeUndefined();
});
it("summariseCadence reads the asNeeded key with no times phrase", () => {
const t = makeStubT();
const line = summariseCadence(asNeededPayload(), t);
expect(line).toContain("medications.wizard.summary.cadence.asNeeded");
expect(line).not.toContain("medications.wizard.summary.times");
});
it("summariseScheduleDraft reads the asNeeded key with no times phrase", () => {
const t = makeStubT();
const line = summariseScheduleDraft(
{ ...emptyWizardPayload().schedules[0], mode: "asNeeded" },
t,
);
expect(line).toContain("medications.wizard.summary.cadence.asNeeded");
expect(line).not.toContain("medications.wizard.summary.times");
});
it("validateStep gates: Step 5 passes once the mode is picked; review mirrors Step 4", () => {
const p = asNeededPayload();
expect(validateStep(p, 5)).toBe(true);
expect(validateStep(p, 8)).toBe(true);
expect(validateStep({ ...p, mode: null }, 5)).toBe(false);
});
});
@@ -8,9 +8,10 @@ import type { WizardPayload } from "../wizard-payload";
import type { StepProps } from "./Step1Name";
/**
* The six cadence rows surfaced in Step 5. Each maps to a (mode,
* The seven cadence rows surfaced in Step 5. Each maps to a (mode,
* cadenceKind) decision. "Einmalig" flips the wizard onto the
* one-shot path; every other row stays recurring.
* one-shot path; "Bei Bedarf" (v1.16.11, #316) onto the as-needed
* path (no schedule at all); every other row stays recurring.
*/
type Step5Row =
| "daily"
@@ -18,7 +19,8 @@ type Step5Row =
| "everyNWeeks"
| "monthly"
| "rolling"
| "oneShot";
| "oneShot"
| "asNeeded";
const STEP5_ROWS: readonly Step5Row[] = [
"daily",
@@ -27,10 +29,12 @@ const STEP5_ROWS: readonly Step5Row[] = [
"monthly",
"rolling",
"oneShot",
"asNeeded",
];
function rowFromPayload(payload: WizardPayload): Step5Row | null {
if (payload.mode === "oneShot") return "oneShot";
if (payload.mode === "asNeeded") return "asNeeded";
if (payload.mode === "recurring") {
switch (payload.cadence.kind) {
case "daily":
@@ -108,6 +112,13 @@ function applyRowPick(
});
return;
}
if (row === "asNeeded") {
// v1.16.11 — as-needed clears schedule configuration: the wizard
// emits an empty `schedules` array on save (`buildCreateBody`), so
// the cadence value left on the draft is inert.
applyPartial({ mode: "asNeeded" });
return;
}
const cadenceKind: CadenceKind = row;
applyPartial({
mode: "recurring",
@@ -145,7 +145,11 @@ export function Step8Summary({
</p>
)}
{payload.mode !== "oneShot" && (
{/* v1.16.11 — an as-needed medication carries no parallel
schedules; the single "Bei Bedarf" card stays as the route
back into Step 5 (mirroring the one-shot edit path), but the
add affordance is hidden alongside one-shot. */}
{payload.mode !== "oneShot" && payload.mode !== "asNeeded" && (
<button
type="button"
onClick={onAddSchedule}
@@ -163,6 +167,9 @@ export function Step8Summary({
)}
</div>
{/* v1.16.11 — an as-needed medication never reminds (no slots),
so the reminders toggle is hidden alongside the schedules. */}
{payload.mode !== "asNeeded" && (
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Label
@@ -185,8 +192,9 @@ export function Step8Summary({
aria-label={t("medications.wizard.steps.step8.remindersLabel")}
/>
</div>
)}
{showNoChannelHint && (
{payload.mode !== "asNeeded" && showNoChannelHint && (
<p
className="text-muted-foreground text-xs"
data-slot="wizard-no-channel-hint"
@@ -176,7 +176,7 @@ export function rowFromTreatment(
*/
export interface ScheduleDraft {
id?: string;
mode: "oneShot" | "recurring" | null;
mode: "oneShot" | "recurring" | "asNeeded" | null;
cadence: CadenceValue;
subControls: CadenceSubControls;
timesOfDay: string[];
@@ -237,7 +237,7 @@ export interface WizardPayload {
*/
allowedInjectionSites: InjectionSiteKey[];
/** Set in Step 5 — mirrors `schedules[activeScheduleIndex].mode`. */
mode: "oneShot" | "recurring" | null;
mode: "oneShot" | "recurring" | "asNeeded" | null;
/** Mirrors `schedules[activeScheduleIndex].cadence`. */
cadence: CadenceValue;
/** Mirrors `schedules[activeScheduleIndex].subControls`. */
@@ -402,6 +402,14 @@ export function removeSchedule(
// ────────────────────────────────────────────────────────────────────
const ONE_SHOT_PATH: readonly number[] = [1, 2, 3, 4, 8];
/**
* v1.16.11 — as-needed (PRN) walks the one-shot shape: picking
* "Bei Bedarf" in Step 5 skips the sub-cadence + times steps entirely
* (an as-needed medication carries no schedule), landing on the
* summary. The dose step stays — dose text + unitsPerDose still feed
* inventory consumption.
*/
const AS_NEEDED_PATH: readonly number[] = [1, 2, 3, 4, 8];
const DAILY_PATH: readonly number[] = [1, 2, 3, 4, 5, 7, 8];
const RECURRING_PATH: readonly number[] = [1, 2, 3, 4, 5, 6, 7, 8];
@@ -418,6 +426,7 @@ export function progressIndices(
cadenceKind: CadenceKind,
): readonly number[] {
if (mode === "oneShot") return ONE_SHOT_PATH;
if (mode === "asNeeded") return AS_NEEDED_PATH;
if (mode === "recurring" && cadenceKind === "daily") return DAILY_PATH;
return RECURRING_PATH;
}
@@ -552,6 +561,11 @@ export interface CreateMedicationBody {
startsOn?: string;
endsOn?: string;
oneShot: boolean;
/**
* v1.16.11 (#316) — as-needed (PRN) medication. True emits an EMPTY
* `schedules` array (the route 422s on any entry alongside the flag).
*/
asNeeded: boolean;
schedules: Array<{
id?: string;
windowStart: string;
@@ -675,10 +689,15 @@ export function buildCreateBody(
// A one-shot medication carries exactly one schedule with no
// recurrence. We honour the active draft (it carries the times the
// user picked); the route enforces the one-schedule invariant.
// v1.16.11 — an as-needed medication carries NO schedule at all; the
// wizard clears the list client-side (the server 422s otherwise).
const isOneShot = committed.mode === "oneShot";
const draftsToEmit = isOneShot
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
: committed.schedules;
const isAsNeeded = committed.mode === "asNeeded";
const draftsToEmit = isAsNeeded
? []
: isOneShot
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
: committed.schedules;
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
const parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 10);
@@ -716,6 +735,7 @@ export function buildCreateBody(
notificationsEnabled: committed.notificationsEnabled,
}),
oneShot: isOneShot,
asNeeded: isAsNeeded,
schedules: draftsToEmit.map((draft) => encodeScheduleDraft(draft, isOneShot)),
};
if (committed.startsOn) {
@@ -739,6 +759,7 @@ function summaryKeyForDraft(
sub: CadenceSubControls,
): string {
if (mode === "oneShot") return "oneShot";
if (mode === "asNeeded") return "asNeeded";
switch (cadence.kind) {
case "daily":
return "daily";
@@ -856,7 +877,9 @@ export function summariseCadence(
): string {
const cadenceLine = cadenceLineFor(payload, t);
const timesPhrase =
payload.mode !== "oneShot" && payload.timesOfDay.length > 0
payload.mode !== "oneShot" &&
payload.mode !== "asNeeded" &&
payload.timesOfDay.length > 0
? t("medications.wizard.summary.times", {
times: sortTimes(payload.timesOfDay).join(", "),
})
@@ -890,7 +913,9 @@ export function summariseScheduleDraft(
): string {
const cadenceLine = cadenceLineFor(draft, t);
const timesPhrase =
draft.mode !== "oneShot" && draft.timesOfDay.length > 0
draft.mode !== "oneShot" &&
draft.mode !== "asNeeded" &&
draft.timesOfDay.length > 0
? t("medications.wizard.summary.times", {
times: sortTimes(draft.timesOfDay).join(", "),
})
@@ -928,6 +953,8 @@ export interface MedicationPayload {
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
/** v1.16.11 — as-needed (PRN) flag; hydrates the "Bei Bedarf" mode. */
asNeeded?: boolean;
schedules: Array<{
id?: string;
windowStart: string;
@@ -1012,8 +1039,13 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
const base = emptyWizardPayload();
const parsedDose = parseDoseExpression(initial.dose ?? "");
const drafts: ScheduleDraft[] =
initial.schedules.length > 0
// v1.16.11 — an as-needed medication persists ZERO schedules; hydrate
// one placeholder draft carrying the asNeeded mode so the flat mirror
// and Step-5 selection round-trip (the encode path emits no schedule).
const asNeeded = initial.asNeeded === true;
const drafts: ScheduleDraft[] = asNeeded
? [{ ...emptyScheduleDraft(), mode: "asNeeded" }]
: initial.schedules.length > 0
? initial.schedules.map((schedule) =>
hydrateScheduleDraft(schedule, initial.oneShot),
)
@@ -0,0 +1,100 @@
/**
* v1.17 — searchable icon picker over the shared curated catalog.
* The filter is a pure function (name + English keyword aids,
* case-insensitive, undrawable names excluded) so the search contract
* is pinned without DOM events; the SSR render covers the radiogroup
* grid, the selected-tile accent, and the localised search affordances.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
MOOD_TAG_ICON_CATALOG,
type MoodTagIconCatalogEntry,
} from "@/lib/mood/icon-catalog";
import { isMoodTagIconName } from "../mood-tag-icons";
import { MoodTagIconPicker, filterIconCatalog } from "../mood-tag-icon-picker";
describe("filterIconCatalog", () => {
it("returns the full drawable catalog for an empty query", () => {
const all = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "");
expect(all.length).toBeGreaterThan(40);
expect(all.every((entry) => isMoodTagIconName(entry.name))).toBe(true);
});
it("matches on the icon name, case-insensitive", () => {
const hits = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "dumbbell");
expect(hits.map((entry) => entry.name)).toContain("Dumbbell");
});
it("matches on keyword aids", () => {
const byKeyword = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "happy");
expect(byKeyword.length).toBeGreaterThan(0);
expect(
byKeyword.some((entry) =>
entry.keywords.some((k) => k.includes("happy")),
) ||
byKeyword.some((entry) => entry.name.toLowerCase().includes("happy")),
).toBe(true);
});
it("returns nothing for a miss", () => {
expect(filterIconCatalog(MOOD_TAG_ICON_CATALOG, "zzzznope")).toEqual([]);
});
it("excludes catalog names the client bundle cannot draw", () => {
const withGhost: MoodTagIconCatalogEntry[] = [
...MOOD_TAG_ICON_CATALOG,
{ name: "NotARealIcon", keywords: ["ghost"], group: "misc" },
];
const filtered = filterIconCatalog(withGhost, "");
expect(filtered.some((entry) => entry.name === "NotARealIcon")).toBe(false);
});
it("every shipped catalog entry resolves to a real glyph (allowlist ⊆ client map)", () => {
for (const entry of MOOD_TAG_ICON_CATALOG) {
expect(
isMoodTagIconName(entry.name),
`unmapped icon: ${entry.name}`,
).toBe(true);
}
});
});
describe("<MoodTagIconPicker> — SSR", () => {
function render(value: string | null, locale: "en" | "de" = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MoodTagIconPicker value={value} onChange={() => {}} />
</I18nProvider>,
);
}
it("renders the search input + one radio tile per drawable catalog entry", () => {
const html = render(null);
expect(html).toContain('type="search"');
const tiles = html.match(/data-slot="mood-icon-tile"/g);
expect(tiles?.length ?? 0).toBe(
filterIconCatalog(MOOD_TAG_ICON_CATALOG, "").length,
);
});
it("marks the current value with the selected accent + aria-checked", () => {
const html = render("Heart");
expect(html).toMatch(
/data-icon="Heart"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-icon="Heart"/,
);
});
it("resolves the German search placeholder", () => {
const html = render(null, "de");
expect(html).toContain("Symbole durchsuchen…");
});
it("renders localised group sub-headers", () => {
const html = render(null, "de");
expect(html).toContain("Gefühle");
expect(html).toContain("Aktivitäten");
});
});
@@ -0,0 +1,152 @@
/**
* v1.17 — picker-side management-suite additions:
* - a custom tag renders its decrypted `label` (the v1.13 API field
* the web picker previously ignored — the raw `custom:<uuid>` key
* used to paint through the `t()` fallback);
* - every group carries a trailing ghost "+" tile that opens the
* inline create sheet, plus a synthetic Custom bootstrap group when
* the plain read carries no custom node yet;
* - render order is exactly the server-resolved order (no client
* sorting).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
interface FixtureCatalog {
categories: unknown[];
}
const queryState: { catalog: FixtureCatalog | null } = { catalog: null };
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
if (
Array.isArray(queryKey) &&
queryKey[0] === "mood-tag-catalog" &&
queryKey.length === 1
) {
return { data: queryState.catalog, isLoading: false };
}
return { data: null, isLoading: false };
},
useQueryClient: () => ({
setQueryData: vi.fn(),
getQueryData: vi.fn(),
cancelQueries: vi.fn(),
invalidateQueries: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("@/hooks/use-is-mobile", () => ({
useIsMobile: () => false,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, isLoading: false }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MoodTagPicker } from "../mood-tag-picker";
const CATALOG = {
categories: [
{
key: "feelings",
labelKey: "mood.tagCategory.feelings",
label: null,
icon: "Smile",
custom: false,
tags: [
{
key: "happy",
labelKey: "mood.tag.happy",
label: null,
icon: "Smile",
kind: "BINARY",
scaleMin: 0,
scaleMax: 1,
inverse: false,
custom: false,
},
],
},
{
key: "custom",
labelKey: "mood.tagCategory.custom",
label: null,
icon: "SlidersHorizontal",
custom: false,
tags: [
{
key: "custom:11111111-aaaa",
labelKey: "custom:11111111-aaaa",
label: "Gartenarbeit",
icon: "Leaf",
kind: "BINARY",
scaleMin: 0,
scaleMax: 1,
inverse: false,
custom: true,
},
],
},
],
};
function render(locale: "en" | "de" = "de"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MoodTagPicker selected={[]} onToggle={() => {}} />
</I18nProvider>,
);
}
beforeEach(() => {
queryState.catalog = CATALOG;
});
describe("<MoodTagPicker> — custom labels + inline create", () => {
it("renders a custom tag by its decrypted label, never the raw key", () => {
const html = render();
expect(html).toContain("Gartenarbeit");
expect(html).not.toContain("custom:11111111-aaaa</span>");
});
it("keeps resolving catalogue tags through their i18n key", () => {
const html = render("en");
expect(html).toContain("happy");
expect(html).not.toContain("mood.tag.happy");
});
it("renders a trailing + tile per group with the localised aria-label", () => {
const html = render();
const tiles = html.match(/data-slot="mood-tag-add-tile"/g);
expect(tiles?.length ?? 0).toBe(2); // one per group
expect(html).toContain("Tag hinzufügen");
});
it("synthesizes the Custom bootstrap group when the plain read has no custom node", () => {
queryState.catalog = { categories: [CATALOG.categories[0]] };
const html = render();
expect(html).toContain('data-slot="mood-tag-custom-bootstrap"');
expect(html).toContain("Eigene"); // mood.tagCategory.custom (de)
});
it("does NOT synthesize the bootstrap group when the custom node exists", () => {
const html = render();
expect(html).not.toContain('data-slot="mood-tag-custom-bootstrap"');
});
it("renders groups and tags in exactly the server order", () => {
const html = render();
expect(html.indexOf("Feelings") === -1).toBe(true); // de locale
const feelings = html.indexOf("Gefühle");
const custom = html.indexOf("Gartenarbeit");
expect(feelings).toBeGreaterThanOrEqual(0);
expect(custom).toBeGreaterThan(feelings);
});
});
@@ -0,0 +1,212 @@
/**
* v1.17 — pure catalog-tree surgery behind the mood-tag management
* cards. The optimistic-update path applies these to the cached manage
* read before the PATCH/PUT settles, so their contracts (unknown keys
* tolerated, archived rows preserved, placement maps complete) are
* pinned here without any DOM.
*/
import { describe, expect, it } from "vitest";
import {
buildGroupOrder,
buildPlacements,
moveTagToGroup,
removeTag,
reorderGroupTags,
reorderGroups,
setTagArchived,
setTagHidden,
type ManageCatalog,
type ManageTag,
} from "../use-mood-tag-manage";
import {
buildVisiblePlacements,
mergeBack,
visibleCatalog,
} from "../tag-manager-card";
import { archivedTags } from "../archived-tags-card";
import { moveGroupKey } from "../tag-groups-card";
import { insertTagIntoCatalog } from "../tag-editor-sheet";
function tag(key: string, overrides: Partial<ManageTag> = {}): ManageTag {
return {
key,
labelKey: `mood.tag.${key}`,
label: null,
icon: "Tag",
kind: "BINARY",
scaleMin: 0,
scaleMax: 1,
inverse: false,
custom: false,
...overrides,
};
}
function catalog(): ManageCatalog {
return {
categories: [
{
key: "feelings",
labelKey: "mood.tagCategory.feelings",
label: null,
icon: "Smile",
custom: false,
tags: [tag("happy"), tag("stressed", { hidden: true })],
},
{
key: "customcat:g1",
labelKey: "customcat:g1",
label: "Garten",
icon: "Leaf",
custom: true,
tags: [
tag("custom:1", { custom: true, label: "Gartenarbeit" }),
tag("custom:2", {
custom: true,
label: "Altes Tag",
archived: true,
usageCount: 5,
}),
],
},
],
};
}
describe("setTagHidden / setTagArchived", () => {
it("flips only the addressed tag", () => {
const next = setTagHidden(catalog(), "happy", true);
expect(next.categories[0]!.tags[0]!.hidden).toBe(true);
expect(next.categories[0]!.tags[1]!.hidden).toBe(true); // untouched
expect(next.categories[1]!.tags[0]!.hidden).toBeUndefined();
});
it("archive flag round-trips", () => {
const archived = setTagArchived(catalog(), "custom:1", true);
expect(archived.categories[1]!.tags[0]!.archived).toBe(true);
const restored = setTagArchived(archived, "custom:1", false);
expect(restored.categories[1]!.tags[0]!.archived).toBe(false);
});
});
describe("removeTag", () => {
it("drops the row from its group and leaves everything else", () => {
const next = removeTag(catalog(), "custom:2");
expect(next.categories[1]!.tags.map((t) => t.key)).toEqual(["custom:1"]);
expect(next.categories[0]!.tags).toHaveLength(2);
});
});
describe("moveTagToGroup", () => {
it("appends the tag to the target group", () => {
const next = moveTagToGroup(catalog(), "happy", "customcat:g1");
expect(next.categories[0]!.tags.map((t) => t.key)).toEqual(["stressed"]);
expect(next.categories[1]!.tags.map((t) => t.key)).toEqual([
"custom:1",
"custom:2",
"happy",
]);
});
it("is a no-op for an unknown tag key", () => {
const before = catalog();
expect(moveTagToGroup(before, "nope", "customcat:g1")).toEqual(before);
});
});
describe("reorderGroupTags / reorderGroups", () => {
it("applies the given order and keeps unknown keys last", () => {
const next = reorderGroupTags(catalog(), "feelings", [
"stressed",
"ghost",
"happy",
]);
expect(next.categories[0]!.tags.map((t) => t.key)).toEqual([
"stressed",
"happy",
]);
});
it("keeps unmentioned tags after the placed block", () => {
const next = reorderGroupTags(catalog(), "feelings", ["stressed"]);
expect(next.categories[0]!.tags.map((t) => t.key)).toEqual([
"stressed",
"happy",
]);
});
it("reorders groups and keeps unmentioned groups after the placed block", () => {
const next = reorderGroups(catalog(), ["customcat:g1"]);
expect(next.categories.map((c) => c.key)).toEqual([
"customcat:g1",
"feelings",
]);
});
});
describe("buildPlacements / buildGroupOrder", () => {
it("emits the complete map in display order", () => {
expect(buildPlacements(catalog())).toEqual({
feelings: ["happy", "stressed"],
"customcat:g1": ["custom:1", "custom:2"],
});
expect(buildGroupOrder(catalog())).toEqual(["feelings", "customcat:g1"]);
});
});
describe("visibleCatalog / archivedTags / buildVisiblePlacements", () => {
it("splits archived rows out of the visible projection", () => {
const visible = visibleCatalog(catalog());
expect(visible.categories[1]!.tags.map((t) => t.key)).toEqual(["custom:1"]);
expect(archivedTags(catalog()).map((t) => t.key)).toEqual(["custom:2"]);
});
it("placement map of the visible tree excludes archived keys", () => {
expect(buildVisiblePlacements(catalog())).toEqual({
feelings: ["happy", "stressed"],
"customcat:g1": ["custom:1"],
});
});
});
describe("mergeBack", () => {
it("re-attaches archived rows after a visible-tree edit", () => {
const full = catalog();
const moved = moveTagToGroup(visibleCatalog(full), "happy", "customcat:g1");
const merged = mergeBack(full, moved);
expect(merged.categories[1]!.tags.map((t) => t.key)).toEqual([
"custom:1",
"happy",
"custom:2",
]);
});
});
describe("moveGroupKey", () => {
it("swaps within bounds and refuses to cross either end", () => {
expect(moveGroupKey(["a", "b", "c"], "b", -1)).toEqual(["b", "a", "c"]);
expect(moveGroupKey(["a", "b", "c"], "a", -1)).toEqual(["a", "b", "c"]);
expect(moveGroupKey(["a", "b", "c"], "c", 1)).toEqual(["a", "b", "c"]);
});
});
describe("insertTagIntoCatalog", () => {
it("appends the fresh DTO to its group", () => {
const created = tag("custom:3", { custom: true, label: "Neu" });
const next = insertTagIntoCatalog(catalog(), "customcat:g1", created);
expect(next.categories[1]!.tags.map((t) => t.key)).toEqual([
"custom:1",
"custom:2",
"custom:3",
]);
});
it("leaves the tree untouched when the group node is absent (empty group dropped by the plain read)", () => {
const before = catalog();
const created = tag("custom:3", { custom: true });
expect(insertTagIntoCatalog(before, "customcat:none", created)).toEqual(
before,
);
});
});
@@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ArchiveRestore, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ApiError, apiDelete, apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "../mood-tag-icons";
import {
invalidateMoodTagCaches,
removeTag,
setTagArchived,
snapshotManageCache,
tagDisplayName,
updateManageCache,
type ManageCatalog,
type ManageTag,
} from "./use-mood-tag-manage";
/**
* v1.17 — archived custom tags for `/settings/mood`. Archiving keeps
* every historical entry resolving the tag's label (the 0126 retire
* precedent: reads join by id without the active filter) — the card
* copy says so explicitly. Restore flips `isActive` back (the server
* re-checks the 50-tag cap). The hard delete sits behind an explicit
* confirm dialog that names the number of past entries the purge will
* strip the tag from; the FK cascade removes those links for good.
*/
interface ArchivedTagsCardProps {
catalog: ManageCatalog;
}
/** Every archived custom tag across the tree, in tree order. */
export function archivedTags(catalog: ManageCatalog): ManageTag[] {
return catalog.categories.flatMap((category) =>
category.tags.filter((tag) => tag.archived === true),
);
}
export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [purgeTag, setPurgeTag] = useState<ManageTag | null>(null);
const archived = archivedTags(catalog);
async function restore(tag: ManageTag) {
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, (c) => setTagArchived(c, tag.key, false));
try {
await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, {
isActive: true,
});
toast.success(t("common.saved"));
} catch (err) {
rollback();
if (err instanceof ApiError && err.status === 422) {
toast.error(t("mood.manage.tagLimitReached"));
} else {
toast.error(err instanceof ApiError ? err.message : t("common.error"));
}
} finally {
void invalidateMoodTagCaches(queryClient);
}
}
async function purge(tag: ManageTag) {
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, (c) => removeTag(c, tag.key));
try {
await apiDelete(
`/api/mood/tags/custom/${encodeURIComponent(tag.key)}?purge=true`,
);
toast.success(t("mood.manage.purged"));
} catch (err) {
rollback();
toast.error(err instanceof ApiError ? err.message : t("common.error"));
} finally {
void invalidateMoodTagCaches(queryClient);
}
}
return (
<div className="space-y-3" data-slot="mood-archived-tags-card">
<p className="text-muted-foreground text-xs">
{t("mood.manage.archivedDescription")}
</p>
{archived.length === 0 ? (
<p
className="text-muted-foreground text-sm"
data-slot="mood-archived-empty"
>
{t("mood.manage.archivedEmpty")}
</p>
) : (
<div className="space-y-2">
{archived.map((tag) => {
const Icon = moodTagIcon(tag.icon);
const name = tagDisplayName(tag, t);
return (
<div
key={tag.key}
data-slot="mood-archived-row"
data-tag={tag.key}
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
>
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span
className="text-foreground/75 min-w-0 flex-1 truncate text-sm"
title={name}
>
{name}
</span>
{typeof tag.usageCount === "number" && tag.usageCount > 0 && (
<Badge variant="secondary" className="tabular-nums">
{t("mood.manage.usageCount", {
count: String(tag.usageCount),
})}
</Badge>
)}
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => void restore(tag)}
>
<ArchiveRestore className="h-4 w-4" aria-hidden="true" />
{t("mood.manage.restore")}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive size-11 sm:size-9"
onClick={() => setPurgeTag(tag)}
aria-label={`${t("mood.manage.purgeAction")} — ${name}`}
title={t("mood.manage.purgeAction")}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
})}
</div>
)}
<AlertDialog
open={purgeTag !== null}
onOpenChange={(open) => {
if (!open) setPurgeTag(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("mood.manage.purgeTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("mood.manage.purgeBody", {
count: String(purgeTag?.usageCount ?? 0),
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
const tag = purgeTag;
setPurgeTag(null);
if (tag) void purge(tag);
}}
>
{t("mood.manage.purgeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,236 @@
"use client";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { MoodTagIconPicker } from "../mood-tag-icon-picker";
import {
invalidateMoodTagCaches,
type ManageCatalog,
type ManageTag,
} from "./use-mood-tag-manage";
/**
* v1.17 — create / edit sheet for a custom mood tag, shared by the
* settings tag manager and the picker's inline "+" tile. Label (40-char
* counter, mirrors the server Zod bound), curated icon picker, group
* select (any seeded category or one of the caller's own groups — the
* widened `categoryKey` contract).
*
* Create POSTs `/api/mood/tags/custom` and inserts the 201 DTO into the
* picker cache immediately (so an inline create lands in the form
* without a refetch round-trip), then invalidates the
* `["mood-tag-catalog"]` prefix. Edit PATCHes `/custom/[key]`
* (label / icon / categoryKey move).
*/
const LABEL_MAX_LENGTH = 40;
export interface TagEditorGroupOption {
key: string;
name: string;
}
export interface TagEditorSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Group choices: every category of the effective tree, display-named. */
groups: TagEditorGroupOption[];
/** Preselected group for create mode (inline "+" tile passes its group). */
initialGroupKey?: string;
/** Present → edit mode for this custom tag. */
tag?: {
key: string;
label: string;
icon: string | null;
groupKey: string;
};
/** Fires with the server DTO after a successful create. */
onCreated?: (tag: ManageTag) => void;
}
/** Append a fresh tag DTO to its group in a cached catalog tree. */
export function insertTagIntoCatalog(
catalog: ManageCatalog,
groupKey: string,
tag: ManageTag,
): ManageCatalog {
let inserted = false;
const categories = catalog.categories.map((category) => {
if (category.key !== groupKey) return category;
inserted = true;
return { ...category, tags: [...category.tags, tag] };
});
// The plain picker read drops empty categories, so a first tag in a
// fresh group has no node to land in — the prefix invalidation that
// follows fetches the tree with the group present.
return inserted ? { categories } : catalog;
}
export function TagEditorSheet({
open,
onOpenChange,
groups,
initialGroupKey,
tag,
onCreated,
}: TagEditorSheetProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const formId = useId();
const labelInputId = useId();
const groupSelectId = useId();
// Hosts mount this sheet conditionally (`{open && <TagEditorSheet …>}`),
// so the field state seeds fresh from props on every open — no
// re-seeding effect needed.
const isEdit = tag !== undefined;
const [label, setLabel] = useState(tag?.label ?? "");
const [icon, setIcon] = useState<string | null>(tag?.icon ?? "Tag");
const [groupKey, setGroupKey] = useState(
tag?.groupKey ?? initialGroupKey ?? "custom",
);
const [saving, setSaving] = useState(false);
const trimmed = label.trim();
const valid = trimmed.length > 0 && trimmed.length <= LABEL_MAX_LENGTH;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!valid || saving) return;
setSaving(true);
try {
if (isEdit) {
await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, {
...(trimmed !== tag.label ? { label: trimmed } : {}),
...(icon !== tag.icon ? { icon } : {}),
...(groupKey !== tag.groupKey ? { categoryKey: groupKey } : {}),
});
toast.success(t("common.saved"));
} else {
const created = await apiPost<ManageTag>("/api/mood/tags/custom", {
label: trimmed,
icon,
categoryKey: groupKey,
});
// Land the fresh tag in the picker cache immediately so the
// inline-create flow pre-selects it without a refetch beat.
if (created) {
queryClient.setQueryData<ManageCatalog>(
queryKeys.moodTagCatalog(),
(current) =>
current
? insertTagIntoCatalog(current, groupKey, created)
: current,
);
onCreated?.(created);
}
toast.success(t("common.saved"));
}
onOpenChange(false);
void invalidateMoodTagCaches(queryClient);
} catch (err) {
if (err instanceof ApiError && err.status === 422) {
toast.error(t("mood.manage.tagLimitReached"));
} else {
toast.error(err instanceof ApiError ? err.message : t("common.error"));
}
} finally {
setSaving(false);
}
}
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={isEdit ? t("common.edit") : t("mood.manage.newTag")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || !valid}>
{saving && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</>
}
>
<form
id={formId}
onSubmit={handleSubmit}
className="space-y-4"
data-slot="mood-tag-editor"
>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={labelInputId}>{t("mood.manage.tagLabel")}</Label>
<span
className={`text-xs tabular-nums ${
label.length >= LABEL_MAX_LENGTH
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(label.length),
max: String(LABEL_MAX_LENGTH),
})}
</span>
</div>
<Input
id={labelInputId}
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={LABEL_MAX_LENGTH}
autoFocus
autoComplete="off"
autoCapitalize="sentences"
enterKeyHint="done"
required
aria-required="true"
/>
</div>
<div className="space-y-2">
<Label htmlFor={groupSelectId}>{t("mood.manage.tagGroup")}</Label>
<NativeSelect
id={groupSelectId}
value={groupKey}
onChange={(e) => setGroupKey(e.target.value)}
>
{groups.map((group) => (
<option key={group.key} value={group.key}>
{group.name}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-2">
<Label>{t("mood.manage.tagIcon")}</Label>
<MoodTagIconPicker value={icon} onChange={setIcon} />
</div>
</form>
</ResponsiveSheet>
);
}
@@ -0,0 +1,603 @@
"use client";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
ArrowUp,
FolderPlus,
GripVertical,
Loader2,
MoreHorizontal,
Pencil,
Trash2,
type LucideIcon,
} from "lucide-react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
ApiError,
apiDelete,
apiPatch,
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "../mood-tag-icons";
import { MoodTagIconPicker } from "../mood-tag-icon-picker";
import {
buildGroupOrder,
categoryDisplayName,
invalidateMoodTagCaches,
reorderGroups,
snapshotManageCache,
updateManageCache,
type ManageCatalog,
} from "./use-mood-tag-manage";
/**
* v1.17 — group management card for `/settings/mood`. Lists every
* group of the effective tree (seeded + own) in picker order and reuses
* the medication-order-editor interaction grammar: a drag handle
* (@dnd-kit, pointer + keyboard sensors) buffers a draft flushed by an
* explicit Save, while the per-row arrow buttons write through
* immediately (one optimistic layout PUT per press). Own groups carry a
* kebab with rename (label + icon) and delete; seeded groups reorder
* only — they are shared reference data.
*
* Group delete is non-destructive by contract: the server re-homes the
* group's custom tags back to the seeded Custom group and catalogue
* placements evaporate to their original category; the confirm dialog
* says exactly that.
*/
const GROUP_LABEL_MAX = 40;
interface TagGroupsCardProps {
catalog: ManageCatalog;
}
/** Arrow move bounded to the list ends (same contract as medications). */
export function moveGroupKey(
keys: readonly string[],
key: string,
delta: -1 | 1,
): string[] {
const idx = keys.indexOf(key);
const target = idx + delta;
if (idx < 0 || target < 0 || target >= keys.length) return [...keys];
return reorderById(
keys.map((id, order) => ({ id, order })),
key,
keys[target],
).map((row) => row.id);
}
export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const dragHintId = useId();
const [draftOrder, setDraftOrder] = useState<string[] | null>(null);
const [saving, setSaving] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [deleteKey, setDeleteKey] = useState<string | null>(null);
const serverOrder = buildGroupOrder(catalog);
const order = draftOrder ?? serverOrder;
const byKey = new Map(catalog.categories.map((c) => [c.key, c]));
const dirty = draftOrder !== null;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
async function putGroupOrder(nextOrder: string[]): Promise<boolean> {
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, (current) =>
reorderGroups(current, nextOrder),
);
try {
await apiPut("/api/mood/tags/layout", { groupOrder: nextOrder });
toast.success(t("mood.manage.orderSaved"));
return true;
} catch (err) {
rollback();
toast.error(err instanceof ApiError ? err.message : t("common.error"));
return false;
} finally {
void invalidateMoodTagCaches(queryClient);
}
}
/** Arrow press: immediate optimistic PUT unless a drag draft is open. */
function moveByArrow(key: string, delta: -1 | 1) {
const next = moveGroupKey(order, key, delta);
if (next.every((v, i) => v === order[i])) return;
if (dirty) {
setDraftOrder(next);
} else {
void putGroupOrder(next);
}
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
setDraftOrder(
reorderById(
order.map((id, idx) => ({ id, order: idx })),
String(active.id),
String(over.id),
).map((row) => row.id),
);
}
async function saveDraft() {
if (!draftOrder) return;
setSaving(true);
const ok = await putGroupOrder(draftOrder);
setSaving(false);
if (ok) setDraftOrder(null);
}
const editing = editingKey ? byKey.get(editingKey) : undefined;
const deleting = deleteKey ? byKey.get(deleteKey) : undefined;
const customGroupCount = catalog.categories.filter((c) => c.custom).length;
return (
<div className="space-y-3" data-slot="mood-tag-groups-card">
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground min-w-0 text-xs">
{t("mood.manage.groupsDescription")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => {
setEditingKey(null);
setEditorOpen(true);
}}
>
<FolderPlus className="h-4 w-4" aria-hidden="true" />
{t("mood.manage.newGroup")}
</Button>
</div>
{customGroupCount === 0 && (
<p
className="text-muted-foreground text-sm"
data-slot="mood-groups-empty"
>
{t("mood.manage.groupEmpty")}
</p>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={order} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{order.map((key, index) => {
const group = byKey.get(key);
if (!group) return null;
return (
<SortableGroupRow
key={key}
groupKey={key}
name={categoryDisplayName(group, t)}
Icon={moodTagIcon(group.icon)}
custom={group.custom === true}
tagCount={group.tags.length}
index={index}
total={order.length}
dragHintId={dragHintId}
disabled={saving}
onMove={moveByArrow}
onEdit={() => {
setEditingKey(key);
setEditorOpen(true);
}}
onDelete={() => setDeleteKey(key)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
{dirty && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDraftOrder(null)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={() => void saveDraft()} disabled={saving}>
{saving && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("mood.manage.orderSave")}
</Button>
</div>
)}
{editorOpen && (
<GroupEditorSheet
open
onOpenChange={(next) => {
if (!next) setEditorOpen(false);
}}
group={
editing && editing.custom
? {
key: editing.key,
label: categoryDisplayName(editing, t),
icon: editing.icon,
}
: undefined
}
/>
)}
<AlertDialog
open={deleteKey !== null}
onOpenChange={(open) => {
if (!open) setDeleteKey(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("mood.manage.groupDeleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("mood.manage.groupDeleteBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
const key = deleting?.key;
setDeleteKey(null);
if (!key) return;
void (async () => {
try {
await apiDelete(
`/api/mood/tags/groups/${encodeURIComponent(key)}`,
);
toast.success(t("mood.manage.groupDeleted"));
} catch (err) {
toast.error(
err instanceof ApiError ? err.message : t("common.error"),
);
} finally {
void invalidateMoodTagCaches(queryClient);
}
})();
}}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
interface SortableGroupRowProps {
groupKey: string;
name: string;
/** Resolved by the host's map callback (react-hooks/static-components). */
Icon: LucideIcon;
custom: boolean;
tagCount: number;
index: number;
total: number;
dragHintId: string;
disabled: boolean;
onMove: (key: string, delta: -1 | 1) => void;
onEdit: () => void;
onDelete: () => void;
}
function SortableGroupRow({
groupKey,
name,
Icon,
custom,
tagCount,
index,
total,
dragHintId,
disabled,
onMove,
onEdit,
onDelete,
}: SortableGroupRowProps) {
const { t } = useTranslations();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: groupKey });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="mood-group-row"
data-group={groupKey}
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${t("dashboard.dragHandle")} — ${name}`}
aria-describedby={dragHintId}
title={t("dashboard.dragHandle")}
disabled={disabled}
data-slot="mood-group-drag-handle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
>
<GripVertical className="h-4 w-4" />
</button>
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span
className="min-w-0 flex-1 truncate text-sm font-medium"
title={name}
>
{name}
</span>
<Badge variant="outline" className="tabular-nums">
{tagCount}
</Badge>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, -1)}
disabled={index === 0 || disabled}
aria-label={`${t("mood.manage.moveUp")} — ${name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${t("mood.manage.moveDown")} — ${name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
{custom && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={`${t("common.moreOptions")} — ${name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
interface GroupEditorSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Present → rename mode for this custom group. */
group?: { key: string; label: string; icon: string | null };
}
function GroupEditorSheet({
open,
onOpenChange,
group,
}: GroupEditorSheetProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const formId = useId();
const labelInputId = useId();
// The host mounts this sheet conditionally, so the field state seeds
// fresh from props on every open — no re-seeding effect needed.
const isEdit = group !== undefined;
const [label, setLabel] = useState(group?.label ?? "");
const [icon, setIcon] = useState<string | null>(group?.icon ?? "Tag");
const [saving, setSaving] = useState(false);
const trimmed = label.trim();
const valid = trimmed.length > 0 && trimmed.length <= GROUP_LABEL_MAX;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!valid || saving) return;
setSaving(true);
try {
if (isEdit) {
await apiPatch(
`/api/mood/tags/groups/${encodeURIComponent(group.key)}`,
{
...(trimmed !== group.label ? { label: trimmed } : {}),
...(icon !== group.icon ? { icon } : {}),
},
);
toast.success(t("mood.manage.groupRenamed"));
} else {
await apiPost("/api/mood/tags/groups", { label: trimmed, icon });
toast.success(t("mood.manage.groupCreated"));
}
onOpenChange(false);
void invalidateMoodTagCaches(queryClient);
} catch (err) {
if (err instanceof ApiError && err.status === 422) {
toast.error(t("mood.manage.groupLimitReached"));
} else {
toast.error(err instanceof ApiError ? err.message : t("common.error"));
}
} finally {
setSaving(false);
}
}
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={isEdit ? t("common.edit") : t("mood.manage.newGroup")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || !valid}>
{saving && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</>
}
>
<form
id={formId}
onSubmit={handleSubmit}
className="space-y-4"
data-slot="mood-group-editor"
>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={labelInputId}>{t("mood.manage.groupLabel")}</Label>
<span
className={`text-xs tabular-nums ${
label.length >= GROUP_LABEL_MAX
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(label.length),
max: String(GROUP_LABEL_MAX),
})}
</span>
</div>
<Input
id={labelInputId}
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={GROUP_LABEL_MAX}
autoFocus
autoComplete="off"
autoCapitalize="sentences"
enterKeyHint="done"
required
aria-required="true"
/>
</div>
<div className="space-y-2">
<Label>{t("mood.manage.groupIcon")}</Label>
<MoodTagIconPicker value={icon} onChange={setIcon} />
</div>
</form>
</ResponsiveSheet>
);
}
@@ -0,0 +1,641 @@
"use client";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Archive,
ArrowDown,
ArrowUp,
Eye,
EyeOff,
FolderInput,
GripVertical,
Loader2,
MoreHorizontal,
Pencil,
Plus,
type LucideIcon,
} from "lucide-react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ApiError, apiPatch, apiPut } from "@/lib/api/api-fetch";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "../mood-tag-icons";
import { TagEditorSheet } from "./tag-editor-sheet";
import {
categoryDisplayName,
invalidateMoodTagCaches,
moveTagToGroup,
reorderGroupTags,
setTagArchived,
setTagHidden,
snapshotManageCache,
tagDisplayName,
updateManageCache,
type ManageCatalog,
type ManageCategory,
type ManageTag,
} from "./use-mood-tag-manage";
/**
* v1.17 — per-group tag management for `/settings/mood`. Mirrors the
* picker's grouping; every row carries: icon, label (custom label first,
* i18n key second), a scale badge for RATED factors, the historical
* usage count, an eye toggle (catalogue tag → per-user hide via
* `PUT /[key]/hidden`; custom tag → archive via `PATCH … isActive`),
* and a kebab with edit (custom only), move-to-group, and archive.
*
* Ordering follows the medication-order-editor grammar: drag buffers a
* draft (explicit Save, one layout PUT), arrows write through
* immediately (optimistic, rollback on error). Moving a catalogue tag
* between groups is a per-user PLACEMENT (layout blob) — the shared
* catalogue row itself never changes group; a custom tag moves for real
* (`categoryKey` PATCH) with the placement map updated alongside so the
* resolved order stays deterministic.
*/
interface TagManagerCardProps {
catalog: ManageCatalog;
}
/** The card manages visible (non-archived) tags; archived rows live in the archived card. */
export function visibleCatalog(catalog: ManageCatalog): ManageCatalog {
return {
categories: catalog.categories.map((category) => ({
...category,
tags: category.tags.filter((tag) => tag.archived !== true),
})),
};
}
/** Placement map of the visible tree: every group → its visible tag keys in order. */
export function buildVisiblePlacements(
catalog: ManageCatalog,
): Record<string, string[]> {
const placements: Record<string, string[]> = {};
for (const category of visibleCatalog(catalog).categories) {
placements[category.key] = category.tags.map((tag) => tag.key);
}
return placements;
}
export function TagManagerCard({ catalog }: TagManagerCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const dragHintId = useId();
const [drafts, setDrafts] = useState<Record<string, string[]>>({});
const [saving, setSaving] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editorTag, setEditorTag] = useState<
| { key: string; label: string; icon: string | null; groupKey: string }
| undefined
>(undefined);
const visible = visibleCatalog(catalog);
const dirty = Object.keys(drafts).length > 0;
const groupOptions = visible.categories.map((category) => ({
key: category.key,
name: categoryDisplayName(category, t),
}));
function orderedKeys(category: ManageCategory): string[] {
return drafts[category.key] ?? category.tags.map((tag) => tag.key);
}
/** Apply current drafts onto the visible tree, then PUT the placement map. */
async function putPlacements(nextCatalog: ManageCatalog): Promise<boolean> {
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, () => mergeBack(catalog, nextCatalog));
try {
await apiPut("/api/mood/tags/layout", {
placements: buildVisiblePlacements(nextCatalog),
});
toast.success(t("mood.manage.orderSaved"));
return true;
} catch (err) {
rollback();
toast.error(err instanceof ApiError ? err.message : t("common.error"));
return false;
} finally {
void invalidateMoodTagCaches(queryClient);
}
}
/** Arrow press: immediate optimistic PUT unless a drag session is open. */
function moveByArrow(groupKey: string, tagKey: string, delta: -1 | 1) {
const category = visible.categories.find((c) => c.key === groupKey);
if (!category) return;
const keys = orderedKeys(category);
const idx = keys.indexOf(tagKey);
const target = idx + delta;
if (idx < 0 || target < 0 || target >= keys.length) return;
const next = reorderById(
keys.map((id, order) => ({ id, order })),
tagKey,
keys[target],
).map((row) => row.id);
if (dirty) {
setDrafts((prev) => ({ ...prev, [groupKey]: next }));
} else {
void putPlacements(reorderGroupTags(visible, groupKey, next));
}
}
function handleDragEnd(groupKey: string, event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const category = visible.categories.find((c) => c.key === groupKey);
if (!category) return;
const keys = orderedKeys(category);
const next = reorderById(
keys.map((id, order) => ({ id, order })),
String(active.id),
String(over.id),
).map((row) => row.id);
setDrafts((prev) => ({ ...prev, [groupKey]: next }));
}
async function saveDrafts() {
setSaving(true);
let next = visible;
for (const [groupKey, keys] of Object.entries(drafts)) {
next = reorderGroupTags(next, groupKey, keys);
}
const ok = await putPlacements(next);
setSaving(false);
if (ok) setDrafts({});
}
/** Eye toggle — catalogue hide/show or custom archive. */
async function toggleVisibility(tag: ManageTag) {
const rollback = await snapshotManageCache(queryClient);
try {
if (tag.custom) {
updateManageCache(queryClient, (c) => setTagArchived(c, tag.key, true));
await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, {
isActive: false,
});
} else {
const nextHidden = tag.hidden !== true;
updateManageCache(queryClient, (c) =>
setTagHidden(c, tag.key, nextHidden),
);
await apiPut(`/api/mood/tags/${encodeURIComponent(tag.key)}/hidden`, {
hidden: nextHidden,
});
}
} catch (err) {
rollback();
toast.error(err instanceof ApiError ? err.message : t("common.error"));
} finally {
void invalidateMoodTagCaches(queryClient);
}
}
/** Move a tag to another group (custom: relational; catalogue: placement). */
async function moveToGroup(tag: ManageTag, targetGroupKey: string) {
const moved = moveTagToGroup(visible, tag.key, targetGroupKey);
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, () => mergeBack(catalog, moved));
try {
if (tag.custom) {
await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, {
categoryKey: targetGroupKey,
});
}
// Both kinds pin the resulting per-group order in the layout blob
// so the resolved tree matches what the user just saw happen.
await apiPut("/api/mood/tags/layout", {
placements: buildVisiblePlacements(moved),
});
toast.success(t("common.saved"));
} catch (err) {
rollback();
toast.error(err instanceof ApiError ? err.message : t("common.error"));
} finally {
void invalidateMoodTagCaches(queryClient);
}
}
return (
<div className="space-y-4" data-slot="mood-tag-manager-card">
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground min-w-0 text-xs">
{t("mood.manage.tagsDescription")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => {
setEditorTag(undefined);
setEditorOpen(true);
}}
>
<Plus className="h-4 w-4" aria-hidden="true" />
{t("mood.manage.newTag")}
</Button>
</div>
{visible.categories.length === 0 && (
<p className="text-muted-foreground text-sm">{t("common.noData")}</p>
)}
{visible.categories.map((category) => {
const keys = orderedKeys(category);
const byKey = new Map(category.tags.map((tag) => [tag.key, tag]));
return (
<TagGroupSection
key={category.key}
heading={categoryDisplayName(category, t)}
HeadingIcon={moodTagIcon(category.icon)}
groupKey={category.key}
keys={keys}
byKey={byKey}
dragHintId={dragHintId}
disabled={saving}
groupOptions={groupOptions}
onDragEnd={handleDragEnd}
onMove={moveByArrow}
onToggleVisibility={(tag) => void toggleVisibility(tag)}
onMoveToGroup={(tag, target) => void moveToGroup(tag, target)}
onEdit={(tag) => {
setEditorTag({
key: tag.key,
label: tagDisplayName(tag, t),
icon: tag.icon,
groupKey: category.key,
});
setEditorOpen(true);
}}
/>
);
})}
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
{dirty && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDrafts({})}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={() => void saveDrafts()} disabled={saving}>
{saving && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("mood.manage.orderSave")}
</Button>
</div>
)}
{editorOpen && (
<TagEditorSheet
open
onOpenChange={(next) => {
if (!next) setEditorOpen(false);
}}
groups={groupOptions}
tag={editorTag}
/>
)}
</div>
);
}
/**
* Write an updated VISIBLE tree back over the full manage tree without
* losing the archived rows the visible projection filtered out.
*/
export function mergeBack(
full: ManageCatalog,
nextVisible: ManageCatalog,
): ManageCatalog {
const archivedByGroup = new Map<string, ManageTag[]>();
for (const category of full.categories) {
archivedByGroup.set(
category.key,
category.tags.filter((tag) => tag.archived === true),
);
}
return {
categories: nextVisible.categories.map((category) => ({
...category,
tags: [...category.tags, ...(archivedByGroup.get(category.key) ?? [])],
})),
};
}
interface TagGroupSectionProps {
heading: string;
/** Resolved by the host's map callback (react-hooks/static-components). */
HeadingIcon: LucideIcon;
groupKey: string;
keys: string[];
byKey: ReadonlyMap<string, ManageTag>;
dragHintId: string;
disabled: boolean;
groupOptions: { key: string; name: string }[];
onDragEnd: (groupKey: string, event: DragEndEvent) => void;
onMove: (groupKey: string, tagKey: string, delta: -1 | 1) => void;
onToggleVisibility: (tag: ManageTag) => void;
onMoveToGroup: (tag: ManageTag, targetGroupKey: string) => void;
onEdit: (tag: ManageTag) => void;
}
function TagGroupSection({
heading,
HeadingIcon,
groupKey,
keys,
byKey,
dragHintId,
disabled,
groupOptions,
onDragEnd,
onMove,
onToggleVisibility,
onMoveToGroup,
onEdit,
}: TagGroupSectionProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const rows = keys
.map((key) => byKey.get(key))
.filter((tag): tag is ManageTag => tag !== undefined);
return (
<div
className="space-y-2"
data-slot="mood-tag-manage-group"
data-group={groupKey}
>
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<HeadingIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{heading}</span>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => onDragEnd(groupKey, event)}
>
<SortableContext items={keys} strategy={verticalListSortingStrategy}>
{rows.map((tag, index) => (
<SortableTagRow
key={tag.key}
tag={tag}
Icon={moodTagIcon(tag.icon)}
groupKey={groupKey}
index={index}
total={rows.length}
dragHintId={dragHintId}
disabled={disabled}
groupOptions={groupOptions}
onMove={onMove}
onToggleVisibility={onToggleVisibility}
onMoveToGroup={onMoveToGroup}
onEdit={onEdit}
/>
))}
</SortableContext>
</DndContext>
</div>
);
}
interface SortableTagRowProps {
tag: ManageTag;
/** Resolved by the host's map callback (react-hooks/static-components). */
Icon: LucideIcon;
groupKey: string;
index: number;
total: number;
dragHintId: string;
disabled: boolean;
groupOptions: { key: string; name: string }[];
onMove: (groupKey: string, tagKey: string, delta: -1 | 1) => void;
onToggleVisibility: (tag: ManageTag) => void;
onMoveToGroup: (tag: ManageTag, targetGroupKey: string) => void;
onEdit: (tag: ManageTag) => void;
}
function SortableTagRow({
tag,
Icon,
groupKey,
index,
total,
dragHintId,
disabled,
groupOptions,
onMove,
onToggleVisibility,
onMoveToGroup,
onEdit,
}: SortableTagRowProps) {
const { t } = useTranslations();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tag.key });
const name = tagDisplayName(tag, t);
const isHidden = tag.hidden === true;
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="mood-tag-manage-row"
data-tag={tag.key}
data-hidden={isHidden ? "true" : undefined}
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
} ${isHidden ? "opacity-60" : ""}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${t("dashboard.dragHandle")} — ${name}`}
aria-describedby={dragHintId}
title={t("dashboard.dragHandle")}
disabled={disabled}
data-slot="mood-tag-drag-handle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
>
<GripVertical className="h-4 w-4" />
</button>
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-sm" title={name}>
{name}
</span>
{tag.kind === "RATED" && (
<Badge variant="outline" className="tabular-nums">
{tag.scaleMin}–{tag.scaleMax}
</Badge>
)}
{typeof tag.usageCount === "number" && tag.usageCount > 0 && (
<Badge variant="secondary" className="tabular-nums">
{t("mood.manage.usageCount", { count: String(tag.usageCount) })}
</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onToggleVisibility(tag)}
disabled={disabled}
aria-pressed={tag.custom ? undefined : isHidden}
aria-label={`${
tag.custom
? t("mood.manage.archive")
: isHidden
? t("mood.manage.showTag")
: t("mood.manage.hideTag")
} — ${name}`}
title={
tag.custom
? t("mood.manage.archive")
: isHidden
? t("mood.manage.showTag")
: t("mood.manage.hideTag")
}
>
{tag.custom ? (
<Archive className="h-4 w-4" aria-hidden="true" />
) : isHidden ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, -1)}
disabled={index === 0 || disabled}
aria-label={`${t("mood.manage.moveUp")} — ${name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(groupKey, tag.key, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${t("mood.manage.moveDown")} — ${name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={`${t("common.moreOptions")} — ${name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{tag.custom && (
<DropdownMenuItem onClick={() => onEdit(tag)}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput className="mr-2 h-4 w-4" />
{t("mood.manage.moveToGroup")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{groupOptions
.filter((group) => group.key !== groupKey)
.map((group) => (
<DropdownMenuItem
key={group.key}
onClick={() => onMoveToGroup(tag, group.key)}
>
{group.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{tag.custom && (
<DropdownMenuItem onClick={() => onToggleVisibility(tag)}>
<Archive className="mr-2 h-4 w-4" />
{t("mood.manage.archive")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,262 @@
"use client";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.17 — shared data layer for the mood-tag management surface
* (`/settings/mood`). One catalog read + one layout read feed every
* card; both fire in parallel at section mount so the section never
* waterfalls.
*
* Wire contract (frozen for the v1.17 management suite):
* - `GET /api/mood/tags?include=hidden,archived,usage` — the fully
* resolved per-user Category → Tag tree, including hidden catalogue
* tags (`hidden: true`), the caller's archived custom tags
* (`archived: true`), per-tag historical link counts
* (`usageCount`), the caller's own custom groups (`custom: true`
* on the category) and empty own groups (kept when an `include`
* flag is set, dropped on the plain picker read).
* - `GET /api/mood/tags/layout` — the per-user presentation blob
* (`groupOrder` + `placements`), merged over the seeded defaults
* server-side. Display-only; a PUT is preserve-when-absent.
*
* Cache discipline: the manage read rides under the
* `["mood-tag-catalog"]` prefix (see `queryKeys.moodTagManage`), so a
* single prefix invalidation after any management mutation refreshes
* both this read and the picker's plain read.
*/
export interface ManageTag {
key: string;
labelKey: string;
/** Decrypted custom label; null/absent for catalogue tags. */
label?: string | null;
icon: string | null;
kind: "BINARY" | "RATED";
scaleMin: number;
scaleMax: number;
inverse: boolean;
custom?: boolean;
/** Catalogue tag hidden for this user (`mood_tag_hidden`). */
hidden?: boolean;
/** Custom tag soft-deactivated (history intact). */
archived?: boolean;
/** Historical entry-link count; present on the `usage` include. */
usageCount?: number;
}
export interface ManageCategory {
key: string;
labelKey: string;
/** Decrypted custom group label; null/absent for seeded categories. */
label?: string | null;
icon: string | null;
custom?: boolean;
tags: ManageTag[];
}
export interface ManageCatalog {
categories: ManageCategory[];
}
export function useMoodTagManage(enabled: boolean) {
return useQuery({
queryKey: queryKeys.moodTagManage(),
queryFn: async () =>
apiGet<ManageCatalog>("/api/mood/tags?include=hidden,archived,usage"),
enabled,
});
}
/**
* Invalidate every mood-tag read after a management mutation: the
* `["mood-tag-catalog"]` prefix catches both the picker read and the
* manage read; the layout key rides along because group/order writes
* change the resolved tree AND the blob.
*/
export function invalidateMoodTagCaches(
queryClient: QueryClient,
): Promise<unknown> {
return Promise.allSettled([
queryClient.invalidateQueries({ queryKey: queryKeys.moodTagCatalog() }),
queryClient.invalidateQueries({ queryKey: queryKeys.moodTagLayout() }),
]);
}
/**
* Cancel in-flight catalog fetches and snapshot the manage cache so an
* optimistic update can roll back on error. Returns the restore thunk.
*/
export async function snapshotManageCache(
queryClient: QueryClient,
): Promise<() => void> {
await queryClient.cancelQueries({ queryKey: queryKeys.moodTagCatalog() });
const manage = queryClient.getQueryData<ManageCatalog>(
queryKeys.moodTagManage(),
);
return () => {
queryClient.setQueryData(queryKeys.moodTagManage(), manage);
};
}
/** Apply a pure updater to the manage cache (no-op when not yet loaded). */
export function updateManageCache(
queryClient: QueryClient,
updater: (catalog: ManageCatalog) => ManageCatalog,
): void {
queryClient.setQueryData<ManageCatalog>(
queryKeys.moodTagManage(),
(current) => (current ? updater(current) : current),
);
}
/* ------------------------------------------------------------------ */
/* Pure catalog-tree surgery — exported for unit tests. */
/* ------------------------------------------------------------------ */
/** Display name for a category: custom label first, i18n key second. */
export function categoryDisplayName(
category: Pick<ManageCategory, "label" | "labelKey">,
t: (key: string) => string,
): string {
return category.label ?? t(category.labelKey);
}
/** Display name for a tag: custom label first, i18n key second. */
export function tagDisplayName(
tag: Pick<ManageTag, "label" | "labelKey">,
t: (key: string) => string,
): string {
return tag.label ?? t(tag.labelKey);
}
/** Flip a tag's `hidden` flag in place (catalogue eye-toggle). */
export function setTagHidden(
catalog: ManageCatalog,
tagKey: string,
hidden: boolean,
): ManageCatalog {
return mapTag(catalog, tagKey, (tag) => ({ ...tag, hidden }));
}
/** Flip a custom tag's `archived` flag in place (archive / restore). */
export function setTagArchived(
catalog: ManageCatalog,
tagKey: string,
archived: boolean,
): ManageCatalog {
return mapTag(catalog, tagKey, (tag) => ({ ...tag, archived }));
}
/** Remove a tag from the tree entirely (purge). */
export function removeTag(
catalog: ManageCatalog,
tagKey: string,
): ManageCatalog {
return {
categories: catalog.categories.map((category) => ({
...category,
tags: category.tags.filter((tag) => tag.key !== tagKey),
})),
};
}
/** Move a tag to another group, appended after that group's tags. */
export function moveTagToGroup(
catalog: ManageCatalog,
tagKey: string,
groupKey: string,
): ManageCatalog {
let moved: ManageTag | undefined;
const stripped = catalog.categories.map((category) => {
const hit = category.tags.find((tag) => tag.key === tagKey);
if (hit) moved = hit;
return {
...category,
tags: category.tags.filter((tag) => tag.key !== tagKey),
};
});
if (!moved) return catalog;
return {
categories: stripped.map((category) =>
category.key === groupKey
? { ...category, tags: [...category.tags, moved!] }
: category,
),
};
}
/** Re-order the tags of one group to `orderedKeys` (unknown keys kept last). */
export function reorderGroupTags(
catalog: ManageCatalog,
groupKey: string,
orderedKeys: readonly string[],
): ManageCatalog {
return {
categories: catalog.categories.map((category) => {
if (category.key !== groupKey) return category;
const byKey = new Map(category.tags.map((tag) => [tag.key, tag]));
const placed = orderedKeys
.map((key) => byKey.get(key))
.filter((tag): tag is ManageTag => tag !== undefined);
const placedKeys = new Set(orderedKeys);
const rest = category.tags.filter((tag) => !placedKeys.has(tag.key));
return { ...category, tags: [...placed, ...rest] };
}),
};
}
/** Re-order the groups to `orderedKeys` (unknown keys kept last). */
export function reorderGroups(
catalog: ManageCatalog,
orderedKeys: readonly string[],
): ManageCatalog {
const byKey = new Map(
catalog.categories.map((category) => [category.key, category]),
);
const placed = orderedKeys
.map((key) => byKey.get(key))
.filter((category): category is ManageCategory => category !== undefined);
const placedKeys = new Set(orderedKeys);
const rest = catalog.categories.filter(
(category) => !placedKeys.has(category.key),
);
return { categories: [...placed, ...rest] };
}
/**
* The complete placement map the layout PUT carries: every category →
* its tag keys in current display order. Sending the full map (instead
* of a delta) keeps the stored blob deterministic — the server resolves
* unknown / stale keys by dropping them at read time.
*/
export function buildPlacements(
catalog: ManageCatalog,
): Record<string, string[]> {
const placements: Record<string, string[]> = {};
for (const category of catalog.categories) {
placements[category.key] = category.tags.map((tag) => tag.key);
}
return placements;
}
/** Current group-key order, for the layout PUT's `groupOrder`. */
export function buildGroupOrder(catalog: ManageCatalog): string[] {
return catalog.categories.map((category) => category.key);
}
function mapTag(
catalog: ManageCatalog,
tagKey: string,
fn: (tag: ManageTag) => ManageTag,
): ManageCatalog {
return {
categories: catalog.categories.map((category) => ({
...category,
tags: category.tags.map((tag) => (tag.key === tagKey ? fn(tag) : tag)),
})),
};
}
@@ -0,0 +1,190 @@
"use client";
import { useId, useMemo, useState } from "react";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import {
MOOD_TAG_ICON_CATALOG,
type MoodTagIconCatalogEntry,
} from "@/lib/mood/icon-catalog";
import { isMoodTagIconName, moodTagIcon } from "./mood-tag-icons";
/**
* v1.17 — searchable icon picker over the shared curated catalog
* (`src/lib/mood/icon-catalog.ts`: the server half feeds the custom-tag
* icon allowlist, this client half renders the searchable grid). Search
* filters on the icon name plus its English keyword aids; the grid is
* sub-headed by catalog group and behaves as one ARIA radiogroup with a
* roving tab stop, so Arrow keys walk the filtered set and Enter/Space
* picks the focused tile. Tile selection language matches the mood-tag
* tiles (`border-primary bg-primary/15 text-primary`).
*/
/** i18n keys for the catalog's fixed group headers. */
const ICON_GROUP_LABEL_KEYS: Record<string, string> = {
emotions: "mood.manage.iconGroupEmotions",
activities: "mood.manage.iconGroupActivities",
health: "mood.manage.iconGroupHealth",
food: "mood.manage.iconGroupFood",
weather: "mood.manage.iconGroupWeather",
places: "mood.manage.iconGroupPlaces",
misc: "mood.manage.iconGroupMisc",
};
/**
* Pure search filter — name + keyword substring match, case-insensitive.
* Exported for unit tests. Entries the client bundle cannot draw are
* excluded up front so the picker never offers a fallback-glyph tile.
*/
export function filterIconCatalog(
catalog: readonly MoodTagIconCatalogEntry[],
query: string,
): MoodTagIconCatalogEntry[] {
const drawable = catalog.filter((entry) => isMoodTagIconName(entry.name));
const needle = query.trim().toLowerCase();
if (needle.length === 0) return drawable;
return drawable.filter(
(entry) =>
entry.name.toLowerCase().includes(needle) ||
entry.keywords.some((keyword) => keyword.toLowerCase().includes(needle)),
);
}
export interface MoodTagIconPickerProps {
/** Currently selected icon name (null → nothing selected yet). */
value: string | null;
onChange: (iconName: string) => void;
}
export function MoodTagIconPicker({ value, onChange }: MoodTagIconPickerProps) {
const { t } = useTranslations();
const [query, setQuery] = useState("");
const groupLabelId = useId();
const filtered = useMemo(
() => filterIconCatalog(MOOD_TAG_ICON_CATALOG, query),
[query],
);
// One flat roving radiogroup across the (filtered) grid; the group
// sub-headers are presentation only.
const selectedIndex = filtered.findIndex((entry) => entry.name === value);
const { getRadioProps } = useRovingRadioGroup({
count: filtered.length,
selectedIndex,
onSelect: (index) => {
const entry = filtered[index];
if (entry) onChange(entry.name);
},
});
// Group the filtered list while preserving catalog order; remember
// each entry's flat index for the roving wiring.
const groups: {
group: string;
entries: { entry: MoodTagIconCatalogEntry; index: number }[];
}[] = [];
filtered.forEach((entry, index) => {
const last = groups[groups.length - 1];
if (last && last.group === entry.group) {
last.entries.push({ entry, index });
} else {
groups.push({ group: entry.group, entries: [{ entry, index }] });
}
});
return (
<div className="space-y-3" data-slot="mood-tag-icon-picker">
<span id={groupLabelId} className="sr-only">
{t("mood.manage.iconPickerTitle")}
</span>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("mood.manage.iconSearchPlaceholder")}
aria-label={t("mood.manage.iconSearchPlaceholder")}
className="px-9"
autoComplete="off"
autoCapitalize="none"
/>
{query.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setQuery("")}
aria-label={t("mood.manage.iconSearchClear")}
className="absolute top-1/2 right-1 h-8 w-8 -translate-y-1/2"
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
{filtered.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-start gap-2 py-2 text-sm">
<span>{t("mood.manage.iconSearchEmpty")}</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setQuery("")}
>
{t("mood.manage.iconSearchClear")}
</Button>
</div>
) : (
<div
role="radiogroup"
aria-labelledby={groupLabelId}
className="max-h-64 space-y-3 overflow-y-auto pr-1"
>
{groups.map(({ group, entries }) => (
<div key={group} className="space-y-1.5">
<p className="text-muted-foreground text-xs font-medium">
{t(ICON_GROUP_LABEL_KEYS[group] ?? "mood.manage.iconGroupMisc")}
</p>
<div className="flex flex-wrap gap-2">
{entries.map(({ entry, index }) => {
const Icon = moodTagIcon(entry.name);
const isActive = entry.name === value;
return (
<button
key={entry.name}
type="button"
role="radio"
aria-checked={isActive}
aria-label={entry.name}
title={entry.name}
data-slot="mood-icon-tile"
data-icon={entry.name}
onClick={() => onChange(entry.name)}
{...getRadioProps(index)}
className={`focus-visible:ring-ring focus-visible:ring-offset-background flex h-11 w-11 items-center justify-center rounded-xl border transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
<Icon className="h-5 w-5" aria-hidden="true" />
</button>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}
+73
View File
@@ -1,42 +1,70 @@
import {
Activity,
AlertTriangle,
Angry,
Apple,
Baby,
Banknote,
Bath,
Bed,
BedDouble,
Bike,
Book,
BookOpen,
Brain,
Briefcase,
Camera,
CandyOff,
Car,
Cat,
CheckCircle,
Cigarette,
CigaretteOff,
Clock,
Cloud,
CloudMoon,
CloudRain,
CloudSun,
Coffee,
Dog,
Dumbbell,
Film,
Flame,
Footprints,
Frown,
Gamepad2,
Gift,
GlassWater,
GraduationCap,
HandHeart,
Headphones,
Heart,
HeartPulse,
HelpCircle,
Home,
House,
Laugh,
Leaf,
LogOut,
Meh,
Moon,
MoonStar,
Mountain,
Music,
Palette,
PartyPopper,
Phone,
Pill,
Pizza,
Plane,
ShoppingCart,
SlidersHorizontal,
Smile,
Star,
Stethoscope,
Sun,
Swords,
Syringe,
Tag,
Thermometer,
ThumbsUp,
@@ -55,44 +83,80 @@ import {
* server stays icon-library-agnostic); this map resolves them on the
* client. Mirrors the static-map convention used by the workout / sport
* icon lookups. `Tag` is the fallback for an unmapped name.
*
* v1.17 — extended to a superset of the server's custom-tag icon
* allowlist (`src/lib/mood/icon-catalog.ts`): the allowlist is the
* contract, this map must always cover at least every allowed name so a
* user-picked icon never falls back to the generic glyph on web.
*/
const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
Activity,
AlertTriangle,
Angry,
Apple,
Baby,
Banknote,
Bath,
Bed,
BedDouble,
Bike,
Book,
BookOpen,
Brain,
Briefcase,
Camera,
CandyOff,
Car,
Cat,
CheckCircle,
Cigarette,
CigaretteOff,
Clock,
Cloud,
CloudMoon,
CloudRain,
CloudSun,
Coffee,
Dog,
Dumbbell,
Film,
Flame,
Footprints,
Frown,
Gamepad2,
Gift,
GlassWater,
GraduationCap,
HandHeart,
Headphones,
Heart,
HeartPulse,
HelpCircle,
Home,
House,
Laugh,
Leaf,
LogOut,
Meh,
Moon,
MoonStar,
Mountain,
Music,
Palette,
PartyPopper,
Phone,
Pill,
Pizza,
Plane,
ShoppingCart,
SlidersHorizontal,
Smile,
Star,
Stethoscope,
Sun,
Swords,
Syringe,
Tag,
Thermometer,
ThumbsUp,
Trees,
@@ -108,6 +172,15 @@ export function moodTagIcon(name: string | null | undefined): LucideIcon {
return MOOD_TAG_ICONS[name] ?? Tag;
}
/**
* v1.17 — every icon name this client can resolve to a real glyph.
* The searchable icon picker filters the shared catalog against this
* set so a catalog entry the bundle can't draw is never offered.
*/
export function isMoodTagIconName(name: string): boolean {
return name in MOOD_TAG_ICONS;
}
/**
* v1.12.0 — face icon per mood enum for the "How are you?" 5-face hero.
*
+104 -5
View File
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Plus, Tag } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "./mood-tag-icons";
import { TagEditorSheet } from "./manage/tag-editor-sheet";
import { apiGet } from "@/lib/api/api-fetch";
/**
@@ -27,22 +30,36 @@ import { apiGet } from "@/lib/api/api-fetch";
* Additive next to the legacy free-text tag input: a user can use
* either or both. Renders nothing if the catalog is empty so a
* deployment that cleared the taxonomy degrades to the free-text field.
*
* v1.17 — three management-suite additions: (1) a custom tag renders
* its decrypted `label` (the v1.13 API field the web picker previously
* ignored — a custom tag used to paint its raw key through the `t()`
* fallback); (2) a trailing ghost "+" tile per group opens the shared
* create sheet with that group preselected, and the fresh tag is
* selected in the form right away; (3) the render order is exactly the
* server-resolved per-user order — no client-side sorting.
*/
interface CatalogTag {
key: string;
labelKey: string;
/** Decrypted custom label (v1.13+); null/absent for catalogue tags. */
label?: string | null;
icon: string | null;
kind: "BINARY" | "RATED";
scaleMin: number;
scaleMax: number;
inverse: boolean;
custom?: boolean;
}
interface CatalogCategory {
key: string;
labelKey: string;
/** Decrypted custom group label; null/absent for seeded categories. */
label?: string | null;
icon: string | null;
custom?: boolean;
tags: CatalogTag[];
}
@@ -75,6 +92,9 @@ export function MoodTagPicker({
}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
// v1.17 — inline-create sheet state: the group key the tapped "+"
// tile belongs to, or null when closed.
const [createGroupKey, setCreateGroupKey] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: queryKeys.moodTagCatalog(),
@@ -97,6 +117,16 @@ export function MoodTagPicker({
const selectedSet = new Set(selected);
const ratingByKey = new Map(ratedFactors.map((f) => [f.key, f.rating]));
const groupOptions = data.categories.map((category) => ({
key: category.key,
name: category.label ?? t(category.labelKey),
}));
// The plain read drops empty categories, so before the first custom
// tag exists there is no Custom node to hang the "+" tile on —
// synthesize the bootstrap group so inline creation is always
// reachable.
const hasCustomGroup = data.categories.some((c) => c.key === "custom");
return (
<div className="space-y-4" data-slot="mood-tag-picker">
{data.categories.map((category) => {
@@ -114,15 +144,17 @@ export function MoodTagPicker({
<div key={category.key} className="space-y-2">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<CategoryIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t(category.labelKey)}</span>
<span>{category.label ?? t(category.labelKey)}</span>
</div>
{binaryTags.length > 0 && (
{
<div className="flex flex-wrap gap-2">
{binaryTags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
const isActive = selectedSet.has(tag.key);
const label = t(tag.labelKey);
// v1.17 — a custom tag carries its decrypted label;
// catalogue tags keep resolving their i18n key.
const label = tag.label ?? t(tag.labelKey);
return (
<button
key={tag.key}
@@ -141,14 +173,18 @@ export function MoodTagPicker({
</button>
);
})}
<AddTagTile
label={t("mood.addTagInline")}
onClick={() => setCreateGroupKey(category.key)}
/>
</div>
)}
}
{ratedTags.length > 0 && onRateFactor && (
<div className="space-y-2" data-slot="mood-factor-ratings">
{ratedTags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
const label = t(tag.labelKey);
const label = tag.label ?? t(tag.labelKey);
const current = ratingByKey.get(tag.key);
const isBinaryScale = tag.scaleMax - tag.scaleMin === 1;
const steps: number[] = [];
@@ -223,6 +259,69 @@ export function MoodTagPicker({
</div>
);
})}
{/* Bootstrap node for the seeded Custom group: before the first
custom tag exists the plain read carries no `custom` category,
so the inline-create entry point renders synthetically. */}
{!hasCustomGroup && (
<div className="space-y-2" data-slot="mood-tag-custom-bootstrap">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<Tag className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("mood.tagCategory.custom")}</span>
</div>
<div className="flex flex-wrap gap-2">
<AddTagTile
label={t("mood.addTagInline")}
onClick={() => setCreateGroupKey("custom")}
/>
</div>
</div>
)}
{createGroupKey !== null && (
<TagEditorSheet
open
onOpenChange={(open) => {
if (!open) setCreateGroupKey(null);
}}
groups={
hasCustomGroup
? groupOptions
: [
...groupOptions,
{ key: "custom", name: t("mood.tagCategory.custom") },
]
}
initialGroupKey={createGroupKey}
onCreated={(created) => onToggle(created.key)}
/>
)}
</div>
);
}
/**
* v1.17 — trailing ghost "+" tile: same footprint as a tag tile,
* dashed border, opens the inline create sheet for its group.
*/
function AddTagTile({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
data-slot="mood-tag-add-tile"
className="border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background flex min-h-16 w-[4.5rem] flex-col items-center justify-center gap-1 rounded-xl border border-dashed p-2 text-center transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Plus className="h-5 w-5" aria-hidden="true" />
<span className="text-[11px] leading-tight">{label}</span>
</button>
);
}
@@ -0,0 +1,252 @@
/**
* v1.17 — the "Stimmungs-Tags" settings section (`/settings/mood`).
*
* SSR smoke assertions matching the rest of the settings suite: the
* three management cards (groups / tags / archived) render from one
* mocked manage read, custom labels paint decrypted (never the raw
* key), hidden catalogue rows dim, archived custom tags land in the
* archived card with restore + purge affordances, and both locales
* resolve end-to-end. The interactive contracts (optimistic flips,
* reorder PUTs) ride the pure helpers pinned in
* `src/components/mood/manage/__tests__/catalog-helpers.test.ts`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ManageCatalog } from "@/components/mood/manage/use-mood-tag-manage";
const FIXTURE: ManageCatalog = {
categories: [
{
key: "feelings",
labelKey: "mood.tagCategory.feelings",
label: null,
icon: "Smile",
custom: false,
tags: [
{
key: "happy",
labelKey: "mood.tag.happy",
label: null,
icon: "Smile",
kind: "BINARY",
scaleMin: 0,
scaleMax: 1,
inverse: false,
custom: false,
hidden: false,
usageCount: 12,
},
{
key: "stressed",
labelKey: "mood.tag.stressed",
label: null,
icon: "AlertTriangle",
kind: "BINARY",
scaleMin: 0,
scaleMax: 1,
inverse: true,
custom: false,
hidden: true,
usageCount: 0,
},
],
},
{
key: "customcat:g1",
labelKey: "customcat:g1",
label: "Garten",
icon: "Leaf",
custom: true,
tags: [
{
key: "custom:1",
labelKey: "custom:1",
label: "Gartenarbeit",
icon: "Leaf",
kind: "BINARY",
scaleMin: 0,
scaleMax: 1,
inverse: false,
custom: true,
archived: false,
usageCount: 3,
},
{
key: "custom:2",
labelKey: "custom:2",
label: "Altes Tag",
icon: "Tag",
kind: "BINARY",
scaleMin: 0,
scaleMax: 1,
inverse: false,
custom: true,
archived: true,
usageCount: 5,
},
],
},
],
};
const queryState: { catalog: ManageCatalog | null; loading: boolean } = {
catalog: FIXTURE,
loading: false,
};
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
if (
Array.isArray(queryKey) &&
queryKey[0] === "mood-tag-catalog" &&
queryKey[1] === "manage"
) {
return { data: queryState.catalog, isLoading: queryState.loading };
}
return { data: null, isLoading: false };
},
useQueryClient: () => ({
setQueryData: vi.fn(),
getQueryData: vi.fn(),
cancelQueries: vi.fn(),
invalidateQueries: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("@/lib/charts/reduced-motion", () => ({
prefersReducedMotion: () => true,
}));
vi.mock("@/hooks/use-is-mobile", () => ({
useIsMobile: () => false,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, isLoading: false }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MoodSection } from "../mood-section";
function render(locale: "en" | "de" = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MoodSection />
</I18nProvider>,
);
}
beforeEach(() => {
queryState.catalog = FIXTURE;
queryState.loading = false;
});
describe("<MoodSection> — SSR smoke", () => {
it("renders the section heading + description via i18n (no raw keys)", () => {
const html = render();
expect(html).toContain("settings-section-mood-title");
expect(html).toContain("Groups, tags, and order of the mood picker.");
expect(html).not.toContain("settings.sections.");
expect(html).not.toContain("mood.manage.");
});
it("renders the German copy end-to-end", () => {
const html = render("de");
expect(html).toContain("Gruppen, Tags und Reihenfolge der Tag-Auswahl.");
expect(html).toContain("Neue Gruppe");
expect(html).toContain("Neuer Tag");
expect(html).toContain("Archivierte Tags");
});
it("hosts the three management cards from ONE manage read", () => {
const html = render();
expect(html).toContain('data-slot="mood-tag-groups-card"');
expect(html).toContain('data-slot="mood-tag-manager-card"');
expect(html).toContain('data-slot="mood-archived-tags-card"');
});
it("lists every group — seeded by i18n key, custom by decrypted label", () => {
const html = render();
expect(html).toContain("Feelings");
expect(html).toContain("Garten");
// The custom group's raw key must never paint as text (it remains
// present as a `data-group` hook for the reorder wiring).
expect(html).not.toContain("&gt;customcat:g1&lt;");
expect(html).not.toContain(">customcat:g1<");
});
it("seeded groups reorder only — the kebab is reserved for own groups", () => {
const html = render();
const groupRows = html.match(/data-slot="mood-group-row"/g);
expect(groupRows?.length ?? 0).toBe(2);
// One kebab per CUSTOM group row; the seeded row carries none.
const kebabs = html.match(/More options — Garten/g);
expect(kebabs?.length ?? 0).toBeGreaterThanOrEqual(1);
expect(html).not.toContain("More options — Feelings");
});
it("renders custom tags by decrypted label, never the raw key", () => {
const html = render();
expect(html).toContain("Gartenarbeit");
expect(html).not.toContain("custom:1</");
});
it("dims a hidden catalogue row and labels its show-toggle", () => {
const html = render();
expect(html).toMatch(
/data-slot="mood-tag-manage-row"[^>]*data-hidden="true"/,
);
expect(html).toContain("Show tag — stressed");
});
it("surfaces the usage count badge", () => {
const html = render();
expect(html).toContain("12 entries");
});
it("archived card lists the archived custom tag with restore + purge", () => {
const html = render();
expect(html).toContain('data-slot="mood-archived-row"');
expect(html).toContain("Altes Tag");
expect(html).toContain("Restore");
expect(html).toContain("Delete permanently — Altes Tag");
// The active custom tag must NOT appear in the archived card.
const archivedCard = html.slice(
html.indexOf('data-slot="mood-archived-tags-card"'),
);
expect(archivedCard).not.toContain("Gartenarbeit");
});
it("archived wording makes clear history stays (German)", () => {
const html = render("de");
expect(html).toContain(
"Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten",
);
});
it("shows the groups empty-state when the user owns no groups", () => {
queryState.catalog = {
categories: FIXTURE.categories.filter((c) => !c.custom),
};
const html = render();
expect(html).toContain('data-slot="mood-groups-empty"');
expect(html).toContain("No own groups yet.");
});
it("shows the archived empty-state when nothing is archived", () => {
queryState.catalog = {
categories: FIXTURE.categories.map((c) => ({
...c,
tags: c.tags.filter((t) => t.archived !== true),
})),
};
const html = render();
expect(html).toContain('data-slot="mood-archived-empty"');
expect(html).toContain("Nothing archived.");
});
});
@@ -53,6 +53,9 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.16.10 — `medications` sits between `insights` and `thresholds`:
// the view-preference + manual-order customise surface for
// `/medications`, reached from the page header's Settings2 glyph.
// v1.17 — `mood` sits between `medications` and `thresholds`: the
// mood-tag management surface (groups, custom tags, hide/archive,
// picker order), reached from the /mood page header's wrench glyph.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
@@ -60,6 +63,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"dashboard",
"insights",
"medications",
"mood",
"thresholds",
"sources",
"ai",
@@ -157,6 +161,10 @@ describe("<SettingsShell>", () => {
// between Insights and Targets.
expect(html).toContain('href="/settings/medications"');
expect(html).toContain("Medications");
// v1.17 — the mood-tag management surface sits between Medications
// and Targets.
expect(html).toContain('href="/settings/mood"');
expect(html).toContain("Mood tags");
// The ampersand is HTML-escaped by React SSR — assert on the encoded
// form so we don't accidentally match a parser that double-escapes.
expect(html).toContain("API &amp; Tokens");
@@ -192,6 +200,9 @@ describe("<SettingsShell>", () => {
// v1.16.10 — the /medications customise surface ("Medikamente").
expect(html).toContain('href="/settings/medications"');
expect(html).toContain("Medikamente");
// v1.17 — the mood-tag management surface ("Stimmungs-Tags").
expect(html).toContain('href="/settings/mood"');
expect(html).toContain("Stimmungs-Tags");
// v1.8.7.1 — Targets and Sources are two separate German nav
// entries again: "Zielwerte" and "Quellen".
expect(html).toContain("Zielwerte");
+227
View File
@@ -0,0 +1,227 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { PackageOpen } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
/**
* v1.16.11 — medication low-stock alert threshold (Settings →
* Notifications). The threshold is REMAINING RUNWAY: notify when a
* tracked medication's supply covers fewer than N days (1–60, default
* 7); the switch off persists `null` (alert off). Reads the same
* per-user prefs blob the mood / coach cards use and writes through
* `PATCH /api/auth/me/notification-prefs`; the value also surfaces on
* `GET /api/settings/reminder-thresholds` for API consumers, so the
* save invalidates both keys.
*/
interface NotificationPrefsShape {
medication: { lowStockRunwayDays: number | null };
}
const DEFAULT_DAYS = 7;
const MIN_DAYS = 1;
const MAX_DAYS = 60;
export function LowStockCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [optimistic, setOptimistic] = useState<number | null | undefined>(
undefined,
);
const [draft, setDraft] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// Auto-clear the inline status line 3 s after the mutation settles —
// the MoodReminderCard pattern, including the unmount / re-toggle
// timer hygiene.
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const { data: prefs } = useQuery({
queryKey: queryKeys.authNotificationPrefs(),
queryFn: async () => {
return apiGet<NotificationPrefsShape>("/api/auth/me/notification-prefs");
},
enabled: isAuthenticated,
});
const persisted =
prefs?.medication?.lowStockRunwayDays === undefined
? DEFAULT_DAYS
: prefs.medication.lowStockRunwayDays;
const current = optimistic === undefined ? persisted : optimistic;
const enabled = current !== null;
const days = current ?? DEFAULT_DAYS;
async function persist(next: number | null, successMsg: string) {
setOptimistic(next);
setSaving(true);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ medication: { lowStockRunwayDays: next } }),
});
if (res.ok) {
setMsg(successMsg);
setMsgType("success");
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.settingsReminderThresholds(),
}),
]);
setOptimistic(undefined);
} else {
setOptimistic(undefined);
setMsg(t("notifications.lowStock.saveError"));
setMsgType("error");
}
scheduleClear();
setSaving(false);
}
async function handleToggle(next: boolean) {
await persist(
next ? days : null,
next
? t("notifications.lowStock.enabledToast")
: t("notifications.lowStock.disabledToast"),
);
}
function commitDraft() {
if (draft === null) return;
const parsed = Number(draft);
setDraft(null);
if (!Number.isInteger(parsed)) return;
const clamped = Math.min(MAX_DAYS, Math.max(MIN_DAYS, parsed));
if (clamped === days) return;
void persist(clamped, t("notifications.lowStock.savedToast"));
}
return (
<section
// `id` is the anchor target of the supply tab's cross-link
// (`/settings/notifications#low-stock`) — keep it stable.
id="low-stock"
aria-labelledby="settings-low-stock-title"
className="bg-card border-border scroll-mt-20 rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={PackageOpen}
title={t("notifications.lowStock.title")}
titleId="settings-low-stock-title"
description={t("notifications.lowStock.description")}
status={
<label className="flex min-h-11 items-center gap-3">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.lowStock.toggleAria")}
/>
<span className="text-muted-foreground text-xs">
{enabled
? t("notifications.lowStock.statusOn")
: t("notifications.lowStock.statusOff")}
</span>
</label>
}
/>
{enabled && (
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
<label htmlFor="low-stock-days" className="text-sm font-medium">
{t("notifications.lowStock.daysLabel")}
</label>
<Input
id="low-stock-days"
type="number"
inputMode="numeric"
min={MIN_DAYS}
max={MAX_DAYS}
className="w-24"
value={draft ?? String(days)}
disabled={!isAuthenticated || saving}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === "Enter") commitDraft();
}}
aria-label={t("notifications.lowStock.daysAria")}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
)}
{msg && (
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
}
>
{msg}
</p>
)}
{/* v1.16.11 — back-link to the medications list, the surface the
alert is about (the supply tab links here the same way). */}
<p className="mt-3 pl-7">
<Link
href="/medications"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
data-slot="low-stock-medications-link"
>
{t("notifications.lowStock.medicationsLink")}
</Link>
</p>
</section>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { Archive, Folders, Loader2, Tags } from "lucide-react";
import { ArchivedTagsCard } from "@/components/mood/manage/archived-tags-card";
import { TagGroupsCard } from "@/components/mood/manage/tag-groups-card";
import { TagManagerCard } from "@/components/mood/manage/tag-manager-card";
import { useMoodTagManage } from "@/components/mood/manage/use-mood-tag-manage";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.17 — the "Stimmungs-Tags" settings section (`/settings/mood`),
* following the medications-section card pattern. Hosts the whole
* mood-tag management surface in three blocks:
* 1. Groups — create / rename / delete own groups, drag + arrow
* reorder of the picker's group order.
* 2. Tags — per-group tag list mirroring the picker: hide/show
* catalogue tags, edit / move / archive custom tags, drag + arrow
* reorder, create.
* 3. Archived — archived custom tags with restore and the explicit
* hard-delete path.
*
* One management read (`useMoodTagManage`) feeds all three cards — the
* section never waterfalls; every mutation invalidates the
* `["mood-tag-catalog"]` prefix so the mood form's picker repaints in
* the same tick.
*/
export function MoodSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data: catalog, isLoading } = useMoodTagManage(isAuthenticated);
const loading = (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("common.loading")}
</div>
);
return (
<section
aria-labelledby="settings-section-mood-title"
className="space-y-6"
>
<header className="space-y-1">
<h1 id="settings-section-mood-title" className="sr-only">
{t("settings.sections.mood.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.mood.description")}
</p>
</header>
{/* Groups — picker group order + own groups. */}
<div
id="mood-groups"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<Folders className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("mood.manage.groupsTitle")}
</h2>
</div>
{isLoading || !catalog ? loading : <TagGroupsCard catalog={catalog} />}
</div>
{/* Tags — visibility, order, edit, move, create. */}
<div
id="mood-tags"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<Tags className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("mood.manage.tagsTitle")}
</h2>
</div>
{isLoading || !catalog ? loading : <TagManagerCard catalog={catalog} />}
</div>
{/* Archived custom tags — restore / hard delete. */}
<div
id="mood-archived"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<Archive className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("mood.manage.archivedTitle")}
</h2>
</div>
{isLoading || !catalog ? (
loading
) : (
<ArchivedTagsCard catalog={catalog} />
)}
</div>
</section>
);
}
@@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
import { NtfyCard } from "@/components/settings/ntfy-card";
@@ -107,6 +108,10 @@ export function NotificationsSection() {
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
{/* v1.16.11 — medication low-stock runway threshold. */}
<div id="low-stock" className="scroll-mt-28">
<LowStockCard isAuthenticated={isAuthenticated} />
</div>
{/* v1.15.20 — proactive Coach nudge opt-out. */}
<div id="coach-nudge" className="scroll-mt-28">
<CoachNudgeCard isAuthenticated={isAuthenticated} />
@@ -22,6 +22,7 @@ import {
Pill,
Settings2,
SlidersHorizontal,
Smile,
Sparkles,
TrendingUp,
User,
@@ -39,6 +40,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
dashboard: LayoutDashboard,
insights: TrendingUp,
medications: Pill,
mood: Smile,
thresholds: SlidersHorizontal,
sources: Layers,
ai: Sparkles,
+4
View File
@@ -17,6 +17,9 @@
// for the /medications list (view preference + manual order), following
// the dashboard / insights pattern of a dedicated settings section
// reached from the page header's Settings2 glyph.
// v1.17 — `mood` sits after `medications`: the mood-tag management
// surface (groups, custom tags, hide/archive, picker order), reached
// from the /mood page header's wrench glyph.
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
@@ -24,6 +27,7 @@ export const SETTINGS_SECTION_SLUGS = [
"dashboard",
"insights",
"medications",
"mood",
"thresholds",
"sources",
"ai",
@@ -28,6 +28,7 @@ import {
Pill,
Settings2,
SlidersHorizontal,
Smile,
Sparkles,
TrendingUp,
User,
@@ -106,6 +107,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.medications.title",
icon: Pill,
},
{
slug: "mood",
titleKey: "settings.sections.mood.title",
icon: Smile,
},
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
+8
View File
@@ -3,6 +3,14 @@ import type { Instrumentation } from "next";
export async function register() {
// Only start the worker on the Node.js server runtime (not Edge, not build)
if (process.env.NEXT_RUNTIME === "nodejs") {
// Every Node process watches its own event loop — web-only
// containers included; a stall report from the process serving
// requests is the whole point.
const { startEventLoopLagMonitor } = await import(
"@/lib/observability/event-loop-lag"
);
startEventLoopLagMonitor();
const { shouldRunWorker } = await import("@/lib/process-type");
// Web-only container — the dedicated worker service runs the queues.
if (!shouldRunWorker()) return;
+9
View File
@@ -250,6 +250,15 @@ describe("dependent-key bundles", () => {
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["compliance-chart-inline"]));
});
// v1.16.11 — the dashboard snapshot feeds the hero band, dose tally,
// verdict and checklist from ONE query with refetch-on-mount/focus
// off and a 120 s poll. Without this key in the bundle a dose taken
// from the dashboard stayed visibly due until the next poll (#316).
it("medicationDependentKeys bundles the dashboard snapshot (v1.16.11)", () => {
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"]));
});
});
/**
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more