mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
153 files changed
+13852
-628
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 179 KiB |
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+118
-113
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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(">customcat:g1<");
|
||||
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 & 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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in new issue
Block a user