Merge pull request #307 from MBombeck/release/v1.16.1

Release v1.16.1 — a chat worth using, honest sleep stages, stock you can record
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-10 21:39:08 +02:00
commit 8b745015db
145 files changed
+5296 -1921

No files matched your search

+22
View File
@@ -2,6 +2,28 @@
## [Unreleased]
## [1.16.1] — 2026-06-10 — a chat worth using, honest sleep stages, stock you can record
### Added
- **The inventory tab records stock.** Packs and pens with optional expiry, plus corrections and withdrawals — no API detour. The medication detail page also gains a dedicated API tab, an editor entry under lifecycle replacing the misleading header button, and a danger zone on the shared card surface with only its buttons in red.
- **A shared filter bar.** Measurements and mood lists filter through one pill-rail language — active filters read as removable chips, date ranges live in a popover, every page feels the same.
- **Six care achievements.** Miss-free dose streaks (7/30/90 days), a four-week measurement routine, a complete self-context and a week of sleep logging.
### Changed
- **The coach chat behaves like one.** Single-line composer that grows with the text, a typing indicator, no inline disclaimer, a context rail that hides until asked, conversations in the full view, and coach preferences in Settings. Clearly stated allergies and conditions are remembered immediately.
- **Settings and admin share one alignment language.** Icon column left, content flush on its own column, neutral header icons, no duplicated titles, and every select on the account-style idiom with a visible chevron.
- **First paint shows silhouettes, not bare cards**, and charts reveal within 1.2 seconds even when one widget lags.
### Fixed
- **Sleep stages show again next to coarse sources.** A night carrying granular watch stages alongside coarse phone samples picked the coarse source and showed only awake/asleep; the granular source now wins its session.
- **The next-dose line tells the truth after a schedule change.** It reads from the dose calendar instead of a stale schedule window; the repair script reconciles drifted windows and clears pending rows on abandoned anchors.
- **Nightly insight texts are actually there in the morning.** The warm pass no longer skips the status cards when the briefing step is budget-blocked or fails, and one hanging provider cannot stall the whole run.
- The welcome tour stays inside the viewport — no more sideways scrolling to reach its buttons, with a bottom-sheet fallback on narrow screens.
- The dashboard customize icon matches insights, the target-settings link leaves the home charts, and the mood heatmap adopts the damped fills of its sibling charts.
## [1.16.0] — 2026-06-10 — a coach that knows you, invites, and a faster, calmer app
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.16.0
version: 1.16.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+30 -5
View File
@@ -1,7 +1,8 @@
# Medication intake repair
`scripts/repair-intake-anomalies.ts` repairs two known historic defects in
the medication intake ledger (`medication_intake_events`):
`scripts/repair-intake-anomalies.ts` repairs the known historic defects in
the medication intake ledger (`medication_intake_events`) and its schedule
metadata (`medication_schedules`):
1. **Duplicate dose-slot rows.** More than one live row on the same exact
`(user, medication, scheduled_for)` tuple — cross-source duplicates the
@@ -12,6 +13,20 @@ the medication intake ledger (`medication_intake_events`):
7 days before `scheduled_for` or more than 1 day in the future —
typically the residue of a mis-attributed edit before the v1.15.19
`taken_at` validation landed.
3. **Window / times drift (v1.16.1).** Schedule rows whose legacy
`window_start` / `window_end` no longer contains the canonical
`times_of_day` — typically a degenerate `07:00 / 07:00` point window
left behind by an old write path while the dose times moved on (e.g.
to `09:00 / 21:00`). The dose-band model never reads the window once
`times_of_day` exists, but the stale pair kept feeding every legacy
read (the pre-v1.16.1 cards painted "next intake 07:00" from it).
4. **Stale-anchor pending rows (v1.16.1).** Live pending rows
(`taken_at IS NULL`, not skipped, not auto-missed) whose
`scheduled_for` wall-clock HH:mm — evaluated in the user's timezone —
matches no current dose anchor of the medication (any schedule's
`times_of_day` entry; `window_start` for legacy rows without times).
These are reminder-minted slots on retired schedule times that linger
as phantom open doses in the ledger.
## When to use it
@@ -51,8 +66,10 @@ Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips
`tsx`.
The dry-run prints every duplicate group (which row would be kept, which
would be tombstoned) and a table of implausible rows (id, medication,
`scheduledFor`, `takenAt`, source, timestamps).
would be tombstoned), a table of implausible rows (id, medication,
`scheduledFor`, `takenAt`, source, timestamps), every schedule whose
window has drifted off its `times_of_day` (with the reconciled bounds it
would write), and a table of stale-anchor pending rows.
## Applying the fix
@@ -60,7 +77,7 @@ would be tombstoned) and a table of implausible rows (id, medication,
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
```
`--fix` does three things:
`--fix` does five things:
- **Duplicate groups** are collapsed to one winner per slot. Precedence:
an actioned row (taken or skipped) beats a pending one; between two
@@ -79,6 +96,14 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
```
`--tombstone-implausible` is refused without `--fix`.
- **Drifted schedule windows are reconciled** to
`min/max(times_of_day)` so the legacy pair stops contradicting the
canonical dose times. Overnight windows (`window_end < window_start`)
encode a deliberate wrap and are never touched.
- **Stale-anchor pending rows are tombstoned** (same soft-delete shape
as the duplicate losers). A medication without any derivable anchor
(PRN-only / unscheduled) is skipped — there is nothing to compare
against.
- **Affected compliance rollups are recomputed** for every touched
`(user, medication, day)`. The script executes the same DISTINCT-slot
aggregation SQL the shared helper runs (a verbatim twin of
+46 -2
View File
@@ -198,7 +198,51 @@ admin views. Default to `<Card>` + list rows for everything else;
tables on mobile collapse poorly. When you must use a table on mobile,
swap to a vertical list at `sm:` breakpoint.
### 2.6 Status indicators
### 2.6 Filter bars over data lists
Every list surface with filters (measurements, mood, future admin
lists) uses the unified `<FilterBar>` rail from
`src/components/ui/filter-bar.tsx` — never a per-page row of large
labelled `<Select>`s and date inputs.
- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
type, source; surface-specific dimensions append after.
- **Pill = trigger + chip**: inactive pills show only the dimension
name; active pills show `Name: Wert`, pick up the primary accent and
grow a removable ✕. No separate chip row.
- **Reset** renders only while at least one dimension is active.
- **Mobile** (< `sm`): the rail is a horizontal scroll row (hidden
scrollbar, edge-bleed); the result count wraps to its own line.
- The component is presentation-only — filter state, query keys and
fetching stay in the owning page. Use `FilterBarSelect` for enum
dimensions and `FilterBarDateRange` for the from/to pair.
### 2.7 Section-card headers — icon column + content column
Every Settings/Admin section card header follows one shape, implemented
by `<SettingsCardHeader>` (`src/components/settings/_card-header.tsx`)
or an equivalent inline structure:
- **Icon column** (left): one Lucide icon, `text-muted-foreground h-5 w-5`.
Icons in card headers are always neutral — never `text-primary` or any
`dracula-*` accent. Purple/primary is reserved for actions and
highlight states (§2.8), not for iconography.
- **Content column** (right of the icon, `gap-2` → 28 px offset): the
title, its description, and **everything textual that follows in the
card** — descriptions, follow-up headings after separators, form
stacks, helper/feedback lines. Apply `pl-7` to body blocks so nothing
textual ever starts further left than the title above it, and
follow-up headings align with the title column, not the icon.
- **Exception — full-bleed structures**: data tables, tile/card grids,
charts, ladder lists, and `<hr>` separators may span the full card
width. The rule governs text alignment, not structural surfaces.
- The page-level section `<h1>` is `sr-only`: the shell header already
carries "Einstellungen · <Sektion>", so a visible in-section repeat
is a double title. Keep the `<h1>` in the DOM for the document
outline and `aria-labelledby`.
### 2.8 Status indicators
Use the same color taxonomy across the app:
@@ -277,7 +321,7 @@ Same shape as Settings, with the right-hand sidebar listing:
- Maintenance
- Audit log
Status badges use the §2.6 color taxonomy. Each section opens to a card
Status badges use the §2.8 color taxonomy. Each section opens to a card
grid, never a long fluid list.
---
+101 -50
View File
@@ -3,36 +3,40 @@ import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 R3d MB4 — Coach reachability on `/insights/{slug}` sub-pages
* plus the Coach drawer bottom-sheet branch on `<sm`.
* Coach launch surfaces on `/insights/{slug}` sub-pages.
*
* Two contracts under test:
* Contracts under test (v1.16.1):
*
* 1. Every routed Insights sub-page mounts a Coach launch surface.
* The layout renders a sticky-bottom FAB
* (`data-slot="coach-launch-fab"`, visible on `<lg`); v1.8.6 moved
* the per-page action into the sub-page header as an icon-only
* button (`data-slot="coach-launch-icon"`, mounted by the shell at
* heading height across breakpoints). The spec asserts the FAB
* renders on Pixel-5 and the header icon renders on Desktop Chrome.
* 1. The floating bubble (`data-slot="coach-nudge-bubble"`) renders
* ONLY while an unread Coach-initiated nudge exists
* (`GET /api/insights/coach/nudge-status`). By default no bubble
* mounts on any viewport; the everyday entry point is the inline
* launch the sub-page shell mounts in the header
* (`data-slot="coach-launch-icon"`, visible across breakpoints).
*
* 2. Opening the Coach drawer on `<sm` mounts it as a bottom-sheet
* (`data-variant="bottom-sheet"`). On `>=sm` it mounts as a
* right-side sheet (`data-variant="side-sheet"`).
* 2. Clicking the inline launch opens the Coach drawer — as a
* 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 bubble mounts and clicking it
* navigates to the full-page chat at `/insights/coach`.
*
* 4. The drawer's "Conversations" button no longer opens an in-panel
* tray; it hands off to the full-page route `/insights/coach`.
*
* The three sub-pages covered (blutdruck, gewicht, schlaf) span the
* empty-state branch (no data → CTA + Coach launch) and the populated
* branch (with data → full chart + Coach launch beneath).
* branch (with data → full chart + Coach launch in the header).
*/
test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
test.describe("Coach launch surfaces on insights sub-pages", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Populated analytics so each sub-page paints the data branch
// (`hasMetricData` true) — the Coach button is mounted at the
// bottom of the data tree.
// v1.4.39.3 — regex form matches the slim slice the v1.4.39.2
// dashboard split fires alongside the thick request.
// (`hasMetricData` true) — the inline Coach launch is mounted in
// the sub-page header by the shell.
// Regex form matches the slim slice the dashboard split fires
// alongside the thick request.
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
@@ -103,35 +107,50 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
}),
}),
);
// Default nudge state: nothing unread. Registered AFTER the
// generic `**-status*` mock so it wins for the nudge endpoint
// (Playwright matches routes in reverse registration order).
await page.route("**/api/insights/coach/nudge-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { nudgedAt: null, unread: false },
error: null,
}),
}),
);
});
for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) {
test(`/insights/${slug} mounts the Coach launch button on Pixel 5 (FAB branch)`, async ({
test(`/insights/${slug} mounts the inline Coach launch and no bubble by default on Pixel 5`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-mobile",
"Pixel 5 FAB branch",
"mobile inline-launch branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
// The FAB slot must be present and CSS-visible at Pixel 5.
const fab = page
.locator('[data-slot="coach-launch-fab"]')
.first();
await expect(fab).toBeVisible({ timeout: 10_000 });
// The header launch icon is the everyday entry point — visible
// on mobile too since the layout bubble is nudge-gated.
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
// No unread nudge → no floating bubble in the DOM.
await expect(
page.locator('[data-slot="coach-nudge-bubble"]'),
).toHaveCount(0);
});
test(`/insights/${slug} mounts the header Coach icon on Desktop Chrome`, async ({
test(`/insights/${slug} mounts the inline Coach launch and no bubble by default on Desktop Chrome`, async ({
page,
}, testInfo) => {
// Desktop Chrome viewport is 1280 — `lg` (1024) is matched so the
// layout FAB is hidden by `lg:hidden`; the header icon the shell
// mounts is the Coach entry on the page itself.
test.skip(
testInfo.project.name !== "chromium-desktop",
"desktop header-icon branch",
"desktop inline-launch branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
@@ -139,12 +158,13 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
const fab = page.locator('[data-slot="coach-launch-fab"]').first();
await expect(fab).toBeHidden();
await expect(
page.locator('[data-slot="coach-nudge-bubble"]'),
).toHaveCount(0);
});
}
test("clicking the FAB opens the Coach drawer as a bottom-sheet on Pixel 5", async ({
test("clicking the inline Coach launch opens the drawer as a bottom-sheet on Pixel 5", async ({
page,
}, testInfo) => {
test.skip(
@@ -154,16 +174,16 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
const fab = page.locator('[data-slot="coach-launch-fab"]').first();
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
await icon.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet");
});
test("clicking the header Coach icon opens the Coach drawer as a side-sheet on Desktop Chrome", async ({
test("clicking the inline Coach launch opens the drawer as a side-sheet on Desktop Chrome", async ({
page,
}, testInfo) => {
test.skip(
@@ -182,12 +202,41 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
await expect(drawer).toHaveAttribute("data-variant", "side-sheet");
});
test("conversation history is collapsed behind a toggle that opens the tray on Desktop Chrome", async ({
test("an unread Coach nudge mounts the floating bubble and clicking it opens the full-page chat", async ({
page,
}) => {
// The bubble contract is viewport-independent — the test runs on
// both projects. Override the default nudge mock with an unread
// one (later registration wins).
await page.route("**/api/insights/coach/nudge-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { nudgedAt: "2026-06-09T06:00:00.000Z", unread: true },
error: null,
}),
}),
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
const bubble = page.locator('[data-slot="coach-nudge-bubble"]');
await expect(bubble).toBeVisible({ timeout: 10_000 });
await bubble.click();
await page.waitForURL("**/insights/coach", { timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-page"]'),
).toBeVisible({ timeout: 10_000 });
});
test("the drawer's Conversations button hands off to the full-page chat on Desktop Chrome", async ({
page,
}, testInfo) => {
// v1.12.0 — the history rail is no longer an always-on left column;
// it lives behind the "Conversations" toggle on every viewport and
// opens as a tray on demand so the thread keeps the full width.
// The in-panel history tray is gone from the drawer; the button
// navigates to `/insights/coach` where the conversation list
// renders inline on lg+.
test.skip(
testInfo.project.name !== "chromium-desktop",
"exercises the desktop drawer layout",
@@ -202,22 +251,24 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
// No inline history column — only the toggle.
// The drawer renders no inline history column.
await expect(
page.locator('[data-slot="coach-drawer-history"]'),
).toHaveCount(0);
const historyToggle = page.locator(
const historyButton = page.locator(
'[data-slot="coach-drawer-history-tray-trigger"]',
);
await expect(historyToggle).toBeVisible({ timeout: 10_000 });
await expect(historyButton).toBeVisible({ timeout: 10_000 });
await historyButton.click();
// The history rail surfaces only after the toggle is pressed.
// No in-panel tray opens — the click navigates to the full view.
await page.waitForURL("**/insights/coach", { timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-page"]'),
).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-drawer-history-tray"]'),
).toHaveCount(0);
await historyToggle.click();
await expect(
page.locator('[data-slot="coach-drawer-history-tray"]'),
).toBeVisible({ timeout: 10_000 });
});
});
+59 -6
View File
@@ -1509,10 +1509,10 @@
"bestand": "Bestand",
"verlauf": "Verlauf",
"injektion": "Injektion",
"api": "API",
"erweitert": "Erweitert"
},
"shell": {
"fullEdit": "Vollständig bearbeiten",
"loadFailed": "Dieses Medikament konnte nicht geladen werden.",
"tablistLabel": "Medikamenten-Detailbereiche"
},
@@ -1543,7 +1543,25 @@
"USED_UP": "Aufgebraucht"
},
"empty": "Kein Bestand erfasst.",
"emptyHelper": "Erfasse einen Pen oder eine Packung, um verbleibende Dosen und Reichweite zu verfolgen."
"emptyHelper": "Erfasse einen Pen oder eine Packung, um verbleibende Dosen und Reichweite zu verfolgen.",
"emptyCta": "Packung erfassen",
"addButton": "Erfassen",
"addTitle": "Pen oder Packung erfassen",
"addDescription": "Lege einen neuen Vorrat mit seiner Dosenanzahl an; das Ablaufdatum ist optional.",
"addQuantityLabel": "Menge (Dosen / Einheiten)",
"addQuantityHelper": "1–100 Dosen pro Pen oder Packung.",
"addExpiryLabel": "Ablaufdatum (optional)",
"addSubmit": "Erfassen",
"addSuccess": "Bestand erfasst.",
"addFailed": "Bestand konnte nicht erfasst werden.",
"adjustButton": "Anpassen",
"adjustTitle": "Bestand korrigieren",
"adjustDescription": "Setze die verbleibenden Dosen neu — für Korrekturen oder eine Entnahme. 0 markiert den Vorrat als aufgebraucht.",
"adjustQuantityLabel": "Verbleibende Dosen",
"adjustHelper": "Kapazität: {total} Dosen.",
"adjustSubmit": "Speichern",
"adjustSuccess": "Bestand aktualisiert.",
"adjustFailed": "Bestand konnte nicht aktualisiert werden."
},
"erweitert": {
"group": {
@@ -1551,16 +1569,22 @@
"externalApi": "Externe API",
"data": "Daten",
"danger": "Gefahrenzone"
},
"editor": {
"title": "Medikament im Editor bearbeiten",
"helper": "Name, Wirkstoffklasse, Häufigkeit und Zeitpläne im Schritt-für-Schritt-Editor ändern.",
"button": "Editor öffnen"
}
},
"zeitplan": {
"title": "Zeitplan",
"timesTitle": "Einnahmezeiten",
"timesNote": "Passe die Uhrzeiten und das Zeitfenster jeder Dosis an. Für die Häufigkeit (täglich, wöchentlich …) nutze „Vollständig bearbeiten“.",
"timesNote": "Passe die Uhrzeiten und das Zeitfenster jeder Dosis an. Die Häufigkeit (täglich, wöchentlich …) änderst du im Editor unter Erweitert.",
"scheduleLabel": "Zeitplan {index}",
"fullEditNote": "Häufigkeit, Wirkstoffklasse und Kursfenster bearbeitest du über „Vollständig bearbeiten“.",
"fullEditNote": "Häufigkeit, Wirkstoffklasse und Kursfenster bearbeitest du im Editor unter Erweitert → Lebenszyklus.",
"graceEcho": "Erinnerungsfenster: {minutes} Minuten",
"reminderTitle": "Erinnerung",
"manageChannels": "Benachrichtigungskanäle verwalten",
"save": "Zeitplan speichern",
"saved": "Zeitplan gespeichert",
"failed": "Zeitplan konnte nicht gespeichert werden",
@@ -1955,8 +1979,7 @@
"stop": "Stopp",
"thinking": "Denke nach…",
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
"composerHint": "Enter zum Senden, Umschalt+Enter für eine neue Zeile.",
"composerDisclaimer": "Coach-Antworten sind generiert. Klinische Entscheidungen gehören in die Hand deines Arztes.",
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
"selfContextChipsLabel": "Der Coach würde gern wissen:",
"selfContextChipDismiss": "Frage verwerfen",
"voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.",
@@ -3735,6 +3758,7 @@
"resetConfirmTitle": "Kette auf Standard zurücksetzen?",
"resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten."
},
"providerCardTitle": "KI-Anbieter",
"activeProviderHeading": "Aktiver Provider",
"activeProviderBody": "Wähle den Provider, der zuerst genutzt wird. Das Formular darunter konfiguriert ausschließlich den ausgewählten Provider; die Fallback-Kette unten regelt, was passiert, wenn er ausfällt.",
"activeProviderLabel": "Primärer Provider",
@@ -4943,6 +4967,30 @@
"hiddenBugBuddy": {
"title": "Bug-Kumpel",
"description": "Mindestens 5 Bugreports eingereicht — das Projekt liebt dich."
},
"missFree7": {
"title": "Lückenlos · 7T",
"description": "7 Tage in Folge ohne eine verpasste Dosis."
},
"missFree30": {
"title": "Lückenlos · 30T",
"description": "30 Tage in Folge ohne eine verpasste Dosis."
},
"missFree90": {
"title": "Lückenlos · 90T",
"description": "90 Tage in Folge ohne eine verpasste Dosis."
},
"measurementWeeks4": {
"title": "Mess-Routine",
"description": "4 Wochen in Folge an mindestens 5 Tagen pro Woche gemessen."
},
"selfContextComplete": {
"title": "Selbstauskunft gepflegt",
"description": "Alle vier Felder der Selbstauskunft ausgefüllt — der Coach kennt deinen Kontext."
},
"sleepLog7": {
"title": "Schlafprotokoll · 7T",
"description": "An 7 Tagen in Folge Schlafdaten erfasst."
}
},
"nextProgressLabel": "Fortschritt bis Freischaltung",
@@ -5604,6 +5652,11 @@
"allSources": "Alle Quellen",
"dateFrom": "Von",
"dateTo": "Bis",
"dateRange": "Zeitraum",
"typeLabel": "Typ",
"sourceLabel": "Quelle",
"resetFilters": "Zurücksetzen",
"clearFilter": "Filter entfernen: {label}",
"selectAll": "Alle auf dieser Seite auswählen",
"selectRow": "Zeile auswählen",
"selected": "{count} ausgewählt",
+59 -6
View File
@@ -1509,10 +1509,10 @@
"bestand": "Supply",
"verlauf": "History",
"injektion": "Injection",
"api": "API",
"erweitert": "Advanced"
},
"shell": {
"fullEdit": "Edit fully",
"loadFailed": "This medication could not be loaded.",
"tablistLabel": "Medication detail sections"
},
@@ -1543,7 +1543,25 @@
"USED_UP": "Used up"
},
"empty": "No supply recorded.",
"emptyHelper": "Register a pen or pack to track doses remaining and weeks of supply."
"emptyHelper": "Register a pen or pack to track doses remaining and weeks of supply.",
"emptyCta": "Register a pack",
"addButton": "Register",
"addTitle": "Register a pen or pack",
"addDescription": "Record a new supply with its dose count; the expiry date is optional.",
"addQuantityLabel": "Quantity (doses / units)",
"addQuantityHelper": "1–100 doses per pen or pack.",
"addExpiryLabel": "Expiry date (optional)",
"addSubmit": "Register",
"addSuccess": "Supply registered.",
"addFailed": "The supply could not be registered.",
"adjustButton": "Adjust",
"adjustTitle": "Correct stock",
"adjustDescription": "Set the remaining doses — for corrections or a withdrawal. 0 marks the item as used up.",
"adjustQuantityLabel": "Remaining doses",
"adjustHelper": "Capacity: {total} doses.",
"adjustSubmit": "Save",
"adjustSuccess": "Stock updated.",
"adjustFailed": "The stock could not be updated."
},
"erweitert": {
"group": {
@@ -1551,16 +1569,22 @@
"externalApi": "External API",
"data": "Data",
"danger": "Danger zone"
},
"editor": {
"title": "Edit medication in the editor",
"helper": "Change name, treatment class, frequency and schedules in the step-by-step editor.",
"button": "Open editor"
}
},
"zeitplan": {
"title": "Schedule",
"timesTitle": "Dose times",
"timesNote": "Adjust each dose's time and on-time window. For the frequency (daily, weekly …) use “Full edit”.",
"timesNote": "Adjust each dose's time and on-time window. The frequency (daily, weekly …) is edited in the editor under Advanced.",
"scheduleLabel": "Schedule {index}",
"fullEditNote": "Frequency, treatment class and course window are edited through “Full edit”.",
"fullEditNote": "Frequency, treatment class and course window are edited in the editor under Advanced → Lifecycle.",
"graceEcho": "Reminder window: {minutes} minutes",
"reminderTitle": "Reminder",
"manageChannels": "Manage notification channels",
"save": "Save schedule",
"saved": "Schedule saved",
"failed": "Could not save the schedule",
@@ -1955,8 +1979,7 @@
"stop": "Stop",
"thinking": "Thinking…",
"composerPlaceholder": "Ask anything about your data…",
"composerHint": "Press Enter to send, Shift+Enter for a new line.",
"composerDisclaimer": "Coach replies are generated. Clinical decisions belong with your doctor.",
"nudgeBubbleLabel": "New message from the Coach — open chat",
"selfContextChipsLabel": "The Coach would like to know:",
"selfContextChipDismiss": "Dismiss question",
"voiceComingSoon": "Voice input arrives with the iOS app in v1.5.",
@@ -3735,6 +3758,7 @@
"resetConfirmTitle": "Reset chain to defaults?",
"resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched."
},
"providerCardTitle": "AI provider",
"activeProviderHeading": "Active provider",
"activeProviderBody": "Pick the provider you want to use first. The form below configures only the selected provider; the fallback chain at the bottom decides what happens if it fails.",
"activeProviderLabel": "Primary provider",
@@ -4943,6 +4967,30 @@
"hiddenBugBuddy": {
"title": "Bug buddy",
"description": "Submitted at least 5 bug reports — the project loves you."
},
"missFree7": {
"title": "No dose missed · 7d",
"description": "7 consecutive days without a missed dose."
},
"missFree30": {
"title": "No dose missed · 30d",
"description": "30 consecutive days without a missed dose."
},
"missFree90": {
"title": "No dose missed · 90d",
"description": "90 consecutive days without a missed dose."
},
"measurementWeeks4": {
"title": "Measuring routine",
"description": "4 consecutive weeks with measurements on at least 5 days each."
},
"selfContextComplete": {
"title": "Self-report maintained",
"description": "All four self-report fields filled in — the Coach knows your context."
},
"sleepLog7": {
"title": "Sleep log · 7d",
"description": "Sleep data recorded on 7 consecutive days."
}
},
"nextProgressLabel": "Progress to unlock",
@@ -5604,6 +5652,11 @@
"allSources": "All sources",
"dateFrom": "From",
"dateTo": "To",
"dateRange": "Date range",
"typeLabel": "Type",
"sourceLabel": "Source",
"resetFilters": "Reset",
"clearFilter": "Clear filter: {label}",
"selectAll": "Select all on this page",
"selectRow": "Select row",
"selected": "{count} selected",
+59 -6
View File
@@ -1509,10 +1509,10 @@
"bestand": "Existencias",
"verlauf": "Historial",
"injektion": "Inyección",
"api": "API",
"erweitert": "Avanzado"
},
"shell": {
"fullEdit": "Editar por completo",
"loadFailed": "No se pudo cargar este medicamento.",
"tablistLabel": "Secciones de detalle del medicamento"
},
@@ -1543,7 +1543,25 @@
"USED_UP": "Agotado"
},
"empty": "No hay existencias registradas.",
"emptyHelper": "Registra una pluma o un paquete para hacer seguimiento de las dosis restantes y las semanas de suministro."
"emptyHelper": "Registra una pluma o un paquete para hacer seguimiento de las dosis restantes y las semanas de suministro.",
"emptyCta": "Registrar un envase",
"addButton": "Registrar",
"addTitle": "Registrar una pluma o un envase",
"addDescription": "Registra una nueva existencia con su número de dosis; la fecha de caducidad es opcional.",
"addQuantityLabel": "Cantidad (dosis / unidades)",
"addQuantityHelper": "1–100 dosis por pluma o envase.",
"addExpiryLabel": "Fecha de caducidad (opcional)",
"addSubmit": "Registrar",
"addSuccess": "Existencias registradas.",
"addFailed": "No se pudieron registrar las existencias.",
"adjustButton": "Ajustar",
"adjustTitle": "Corregir existencias",
"adjustDescription": "Establece las dosis restantes — para correcciones o una retirada. 0 marca el envase como agotado.",
"adjustQuantityLabel": "Dosis restantes",
"adjustHelper": "Capacidad: {total} dosis.",
"adjustSubmit": "Guardar",
"adjustSuccess": "Existencias actualizadas.",
"adjustFailed": "No se pudieron actualizar las existencias."
},
"erweitert": {
"group": {
@@ -1551,16 +1569,22 @@
"externalApi": "API externa",
"data": "Datos",
"danger": "Zona de peligro"
},
"editor": {
"title": "Editar el medicamento en el editor",
"helper": "Cambia el nombre, la clase de tratamiento, la frecuencia y los horarios en el editor paso a paso.",
"button": "Abrir editor"
}
},
"zeitplan": {
"title": "Horario",
"timesTitle": "Horas de toma",
"timesNote": "Ajusta la hora y la ventana puntual de cada dosis. Para la frecuencia (diaria, semanal…) usa «Edición completa».",
"timesNote": "Ajusta la hora y la ventana puntual de cada dosis. La frecuencia (diaria, semanal…) se cambia en el editor, en Avanzado.",
"scheduleLabel": "Horario {index}",
"fullEditNote": "La frecuencia, la clase de tratamiento y la ventana del curso se editan en «Edición completa».",
"fullEditNote": "La frecuencia, la clase de tratamiento y la ventana del curso se editan en el editor, en Avanzado → Ciclo de vida.",
"graceEcho": "Ventana de recordatorio: {minutes} minutos",
"reminderTitle": "Recordatorio",
"manageChannels": "Gestionar canales de notificación",
"save": "Guardar horario",
"saved": "Horario guardado",
"failed": "No se pudo guardar el horario",
@@ -1955,8 +1979,7 @@
"stop": "Detener",
"thinking": "Pensando…",
"composerPlaceholder": "Pregúntame sobre tus datos…",
"composerHint": "Intro para enviar, Mayús+Intro para una nueva línea.",
"composerDisclaimer": "Las respuestas son generadas. Las decisiones clínicas las toma tu médico.",
"nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat",
"selfContextChipsLabel": "Al Coach le gustaría saber:",
"selfContextChipDismiss": "Descartar pregunta",
"voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.",
@@ -3735,6 +3758,7 @@
"resetConfirmTitle": "¿Restablecer la cadena a los valores predeterminados?",
"resetConfirmBody": "La cadena vuelve a Codex → OpenAI → Anthropic → Local → OpenAI de administrador. Tus credenciales guardadas no se modifican."
},
"providerCardTitle": "Proveedor de IA",
"activeProviderHeading": "Proveedor activo",
"activeProviderBody": "Elige el proveedor que quieres usar primero. El formulario de abajo configura únicamente el proveedor seleccionado; la cadena de respaldo del final decide qué ocurre si falla.",
"activeProviderLabel": "Proveedor principal",
@@ -4943,6 +4967,30 @@
"hiddenBugBuddy": {
"title": "Colega de errores",
"description": "Enviaste al menos 5 informes de error — el proyecto te quiere."
},
"missFree7": {
"title": "Sin dosis perdidas · 7d",
"description": "7 días consecutivos sin una dosis perdida."
},
"missFree30": {
"title": "Sin dosis perdidas · 30d",
"description": "30 días consecutivos sin una dosis perdida."
},
"missFree90": {
"title": "Sin dosis perdidas · 90d",
"description": "90 días consecutivos sin una dosis perdida."
},
"measurementWeeks4": {
"title": "Rutina de medición",
"description": "4 semanas consecutivas con mediciones en al menos 5 días cada una."
},
"selfContextComplete": {
"title": "Autoinforme al día",
"description": "Los cuatro campos del autoinforme completados — el Coach conoce tu contexto."
},
"sleepLog7": {
"title": "Registro de sueño · 7d",
"description": "Datos de sueño registrados durante 7 días consecutivos."
}
},
"nextProgressLabel": "Progreso para desbloquear",
@@ -5604,6 +5652,11 @@
"allSources": "Todas las fuentes",
"dateFrom": "Desde",
"dateTo": "Hasta",
"dateRange": "Periodo",
"typeLabel": "Tipo",
"sourceLabel": "Fuente",
"resetFilters": "Restablecer",
"clearFilter": "Quitar filtro: {label}",
"selectAll": "Seleccionar todo en esta página",
"selectRow": "Seleccionar fila",
"selected": "{count} seleccionados",
+59 -6
View File
@@ -1509,10 +1509,10 @@
"bestand": "Stock",
"verlauf": "Historique",
"injektion": "Injection",
"api": "API",
"erweitert": "Avancé"
},
"shell": {
"fullEdit": "Modifier entièrement",
"loadFailed": "Ce médicament n'a pas pu être chargé.",
"tablistLabel": "Sections de détail du médicament"
},
@@ -1543,7 +1543,25 @@
"USED_UP": "Épuisé"
},
"empty": "Aucun stock enregistré.",
"emptyHelper": "Enregistrez un stylo ou une boîte pour suivre les doses restantes et l'autonomie."
"emptyHelper": "Enregistrez un stylo ou une boîte pour suivre les doses restantes et l'autonomie.",
"emptyCta": "Enregistrer une boîte",
"addButton": "Enregistrer",
"addTitle": "Enregistrer un stylo ou une boîte",
"addDescription": "Enregistrez un nouveau stock avec son nombre de doses ; la date de péremption est facultative.",
"addQuantityLabel": "Quantité (doses / unités)",
"addQuantityHelper": "1–100 doses par stylo ou boîte.",
"addExpiryLabel": "Date de péremption (facultatif)",
"addSubmit": "Enregistrer",
"addSuccess": "Stock enregistré.",
"addFailed": "Le stock n'a pas pu être enregistré.",
"adjustButton": "Ajuster",
"adjustTitle": "Corriger le stock",
"adjustDescription": "Définissez les doses restantes — pour une correction ou un prélèvement. 0 marque l'article comme épuisé.",
"adjustQuantityLabel": "Doses restantes",
"adjustHelper": "Capacité : {total} doses.",
"adjustSubmit": "Enregistrer",
"adjustSuccess": "Stock mis à jour.",
"adjustFailed": "Le stock n'a pas pu être mis à jour."
},
"erweitert": {
"group": {
@@ -1551,16 +1569,22 @@
"externalApi": "API externe",
"data": "Données",
"danger": "Zone de danger"
},
"editor": {
"title": "Modifier le médicament dans l'éditeur",
"helper": "Modifiez le nom, la classe de traitement, la fréquence et les horaires dans l'éditeur pas à pas.",
"button": "Ouvrir l'éditeur"
}
},
"zeitplan": {
"title": "Horaire",
"timesTitle": "Heures de prise",
"timesNote": "Ajustez l'heure et la fenêtre à l'heure de chaque dose. Pour la fréquence (quotidienne, hebdomadaire…), utilisez « Édition complète ».",
"timesNote": "Ajustez l'heure et la fenêtre à l'heure de chaque dose. La fréquence (quotidienne, hebdomadaire…) se modifie dans l'éditeur, sous Avancé.",
"scheduleLabel": "Horaire {index}",
"fullEditNote": "La fréquence, la classe de traitement et la fenêtre de cure se modifient via « Édition complète ».",
"fullEditNote": "La fréquence, la classe de traitement et la fenêtre de cure se modifient dans l'éditeur, sous Avancé → Cycle de vie.",
"graceEcho": "Fenêtre de rappel : {minutes} minutes",
"reminderTitle": "Rappel",
"manageChannels": "Gérer les canaux de notification",
"save": "Enregistrer l'horaire",
"saved": "Horaire enregistré",
"failed": "Impossible d'enregistrer l'horaire",
@@ -1955,8 +1979,7 @@
"stop": "Arrêter",
"thinking": "Réflexion en cours…",
"composerPlaceholder": "Pose-moi une question sur tes données…",
"composerHint": "Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne.",
"composerDisclaimer": "Les réponses sont générées. Les décisions cliniques relèvent de ton médecin.",
"nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat",
"selfContextChipsLabel": "Le Coach aimerait savoir :",
"selfContextChipDismiss": "Ignorer la question",
"voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.",
@@ -3735,6 +3758,7 @@
"resetConfirmTitle": "Réinitialiser la chaîne aux valeurs par défaut ?",
"resetConfirmBody": "La chaîne revient à Codex → OpenAI → Anthropic → Local → OpenAI administrateur. Vos identifiants enregistrés ne sont pas affectés."
},
"providerCardTitle": "Fournisseur d'IA",
"activeProviderHeading": "Fournisseur actif",
"activeProviderBody": "Choisissez le fournisseur à utiliser en premier. Le formulaire ci-dessous configure uniquement le fournisseur sélectionné ; la chaîne de secours en bas détermine ce qui se passe en cas d’échec.",
"activeProviderLabel": "Fournisseur principal",
@@ -4943,6 +4967,30 @@
"hiddenBugBuddy": {
"title": "Compagnon des bugs",
"description": "Vous avez soumis au moins 5 rapports de bug — le projet vous adore."
},
"missFree7": {
"title": "Aucune dose oubliée · 7j",
"description": "7 jours consécutifs sans dose oubliée."
},
"missFree30": {
"title": "Aucune dose oubliée · 30j",
"description": "30 jours consécutifs sans dose oubliée."
},
"missFree90": {
"title": "Aucune dose oubliée · 90j",
"description": "90 jours consécutifs sans dose oubliée."
},
"measurementWeeks4": {
"title": "Routine de mesure",
"description": "4 semaines consécutives avec des mesures au moins 5 jours par semaine."
},
"selfContextComplete": {
"title": "Auto-déclaration à jour",
"description": "Les quatre champs de l’auto-déclaration sont remplis — le Coach connaît votre contexte."
},
"sleepLog7": {
"title": "Journal de sommeil · 7j",
"description": "Données de sommeil enregistrées pendant 7 jours consécutifs."
}
},
"nextProgressLabel": "Progression avant déblocage",
@@ -5604,6 +5652,11 @@
"allSources": "Toutes les sources",
"dateFrom": "Du",
"dateTo": "Au",
"dateRange": "Période",
"typeLabel": "Type",
"sourceLabel": "Source",
"resetFilters": "Réinitialiser",
"clearFilter": "Supprimer le filtre : {label}",
"selectAll": "Tout sélectionner sur cette page",
"selectRow": "Sélectionner la ligne",
"selected": "{count} sélectionné(s)",
+59 -6
View File
@@ -1509,10 +1509,10 @@
"bestand": "Scorta",
"verlauf": "Cronologia",
"injektion": "Iniezione",
"api": "API",
"erweitert": "Avanzate"
},
"shell": {
"fullEdit": "Modifica completa",
"loadFailed": "Impossibile caricare questo farmaco.",
"tablistLabel": "Sezioni di dettaglio del farmaco"
},
@@ -1543,7 +1543,25 @@
"USED_UP": "Esaurito"
},
"empty": "Nessuna scorta registrata.",
"emptyHelper": "Registra una penna o una confezione per monitorare le dosi rimanenti e l'autonomia."
"emptyHelper": "Registra una penna o una confezione per monitorare le dosi rimanenti e l'autonomia.",
"emptyCta": "Registra una confezione",
"addButton": "Registra",
"addTitle": "Registra una penna o una confezione",
"addDescription": "Registra una nuova scorta con il numero di dosi; la data di scadenza è facoltativa.",
"addQuantityLabel": "Quantità (dosi / unità)",
"addQuantityHelper": "1–100 dosi per penna o confezione.",
"addExpiryLabel": "Data di scadenza (facoltativa)",
"addSubmit": "Registra",
"addSuccess": "Scorta registrata.",
"addFailed": "Impossibile registrare la scorta.",
"adjustButton": "Correggi",
"adjustTitle": "Correggi la scorta",
"adjustDescription": "Imposta le dosi rimanenti — per correzioni o un prelievo. 0 segna la scorta come esaurita.",
"adjustQuantityLabel": "Dosi rimanenti",
"adjustHelper": "Capacità: {total} dosi.",
"adjustSubmit": "Salva",
"adjustSuccess": "Scorta aggiornata.",
"adjustFailed": "Impossibile aggiornare la scorta."
},
"erweitert": {
"group": {
@@ -1551,16 +1569,22 @@
"externalApi": "API esterna",
"data": "Dati",
"danger": "Zona pericolosa"
},
"editor": {
"title": "Modifica il farmaco nell'editor",
"helper": "Cambia nome, classe di trattamento, frequenza e programmi nell'editor passo dopo passo.",
"button": "Apri editor"
}
},
"zeitplan": {
"title": "Programma",
"timesTitle": "Orari di assunzione",
"timesNote": "Regola l'orario e la finestra puntuale di ogni dose. Per la frequenza (giornaliera, settimanale…) usa «Modifica completa».",
"timesNote": "Regola l'orario e la finestra puntuale di ogni dose. La frequenza (giornaliera, settimanale…) si modifica nell'editor, in Avanzate.",
"scheduleLabel": "Programma {index}",
"fullEditNote": "Frequenza, classe di trattamento e finestra del ciclo si modificano da «Modifica completa».",
"fullEditNote": "Frequenza, classe di trattamento e finestra del ciclo si modificano nell'editor, in Avanzate → Ciclo di vita.",
"graceEcho": "Finestra di promemoria: {minutes} minuti",
"reminderTitle": "Promemoria",
"manageChannels": "Gestisci i canali di notifica",
"save": "Salva programma",
"saved": "Programma salvato",
"failed": "Impossibile salvare il programma",
@@ -1955,8 +1979,7 @@
"stop": "Interrompi",
"thinking": "Sto pensando…",
"composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…",
"composerHint": "Invio per inviare, Shift+Invio per una nuova riga.",
"composerDisclaimer": "Le risposte sono generate. Le decisioni cliniche restano al tuo medico.",
"nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat",
"selfContextChipsLabel": "Il Coach vorrebbe sapere:",
"selfContextChipDismiss": "Ignora la domanda",
"voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.",
@@ -3735,6 +3758,7 @@
"resetConfirmTitle": "Ripristinare la catena ai valori predefiniti?",
"resetConfirmBody": "La catena torna a Codex → OpenAI → Anthropic → Locale → OpenAI amministratore. Le credenziali salvate non vengono toccate."
},
"providerCardTitle": "Provider IA",
"activeProviderHeading": "Provider attivo",
"activeProviderBody": "Scegli il provider da usare per primo. Il modulo qui sotto configura solo il provider selezionato; la catena di fallback in fondo decide cosa accade se fallisce.",
"activeProviderLabel": "Provider principale",
@@ -4943,6 +4967,30 @@
"hiddenBugBuddy": {
"title": "Amico dei bug",
"description": "Hai inviato almeno 5 segnalazioni di bug — il progetto ti ama."
},
"missFree7": {
"title": "Nessuna dose persa · 7g",
"description": "7 giorni consecutivi senza una dose persa."
},
"missFree30": {
"title": "Nessuna dose persa · 30g",
"description": "30 giorni consecutivi senza una dose persa."
},
"missFree90": {
"title": "Nessuna dose persa · 90g",
"description": "90 giorni consecutivi senza una dose persa."
},
"measurementWeeks4": {
"title": "Routine di misurazione",
"description": "4 settimane consecutive con misurazioni in almeno 5 giorni ciascuna."
},
"selfContextComplete": {
"title": "Autodichiarazione aggiornata",
"description": "Tutti e quattro i campi dell’autodichiarazione compilati — il Coach conosce il tuo contesto."
},
"sleepLog7": {
"title": "Diario del sonno · 7g",
"description": "Dati del sonno registrati per 7 giorni consecutivi."
}
},
"nextProgressLabel": "Progressi verso lo sblocco",
@@ -5604,6 +5652,11 @@
"allSources": "Tutte le fonti",
"dateFrom": "Dal",
"dateTo": "Al",
"dateRange": "Periodo",
"typeLabel": "Tipo",
"sourceLabel": "Fonte",
"resetFilters": "Reimposta",
"clearFilter": "Rimuovi filtro: {label}",
"selectAll": "Seleziona tutto in questa pagina",
"selectRow": "Seleziona riga",
"selected": "{count} selezionati",
+59 -6
View File
@@ -1509,10 +1509,10 @@
"bestand": "Zapas",
"verlauf": "Historia",
"injektion": "Iniekcja",
"api": "API",
"erweitert": "Zaawansowane"
},
"shell": {
"fullEdit": "Edytuj w pełni",
"loadFailed": "Nie udało się załadować tego leku.",
"tablistLabel": "Sekcje szczegółów leku"
},
@@ -1543,7 +1543,25 @@
"USED_UP": "Zużyte"
},
"empty": "Nie zarejestrowano zapasu.",
"emptyHelper": "Zarejestruj wstrzykiwacz lub opakowanie, aby śledzić pozostałe dawki i zapas na tygodnie."
"emptyHelper": "Zarejestruj wstrzykiwacz lub opakowanie, aby śledzić pozostałe dawki i zapas na tygodnie.",
"emptyCta": "Zarejestruj opakowanie",
"addButton": "Zarejestruj",
"addTitle": "Zarejestruj wstrzykiwacz lub opakowanie",
"addDescription": "Zapisz nowy zapas z liczbą dawek; data ważności jest opcjonalna.",
"addQuantityLabel": "Ilość (dawki / jednostki)",
"addQuantityHelper": "1–100 dawek na wstrzykiwacz lub opakowanie.",
"addExpiryLabel": "Data ważności (opcjonalnie)",
"addSubmit": "Zarejestruj",
"addSuccess": "Zapas zarejestrowany.",
"addFailed": "Nie udało się zarejestrować zapasu.",
"adjustButton": "Skoryguj",
"adjustTitle": "Skoryguj zapas",
"adjustDescription": "Ustaw pozostałe dawki — przy korekcie lub pobraniu. 0 oznacza zapas jako zużyty.",
"adjustQuantityLabel": "Pozostałe dawki",
"adjustHelper": "Pojemność: {total} dawek.",
"adjustSubmit": "Zapisz",
"adjustSuccess": "Zapas zaktualizowany.",
"adjustFailed": "Nie udało się zaktualizować zapasu."
},
"erweitert": {
"group": {
@@ -1551,16 +1569,22 @@
"externalApi": "Zewnętrzne API",
"data": "Dane",
"danger": "Strefa zagrożenia"
},
"editor": {
"title": "Edytuj lek w edytorze",
"helper": "Zmień nazwę, klasę leczenia, częstotliwość i harmonogramy w edytorze krok po kroku.",
"button": "Otwórz edytor"
}
},
"zeitplan": {
"title": "Harmonogram",
"timesTitle": "Godziny przyjęcia",
"timesNote": "Dostosuj godzinę i okno punktualności każdej dawki. Częstotliwość (codziennie, co tydzień…) zmieniasz w „Pełna edycja”.",
"timesNote": "Dostosuj godzinę i okno punktualności każdej dawki. Częstotliwość (codziennie, co tydzień…) zmieniasz w edytorze w sekcji Zaawansowane.",
"scheduleLabel": "Harmonogram {index}",
"fullEditNote": "Częstotliwość, klasę leczenia i okno kuracji edytujesz przez „Pełna edycja”.",
"fullEditNote": "Częstotliwość, klasę leczenia i okno kuracji edytujesz w edytorze w sekcji Zaawansowane → Cykl życia.",
"graceEcho": "Okno przypomnienia: {minutes} minut",
"reminderTitle": "Przypomnienie",
"manageChannels": "Zarządzaj kanałami powiadomień",
"save": "Zapisz harmonogram",
"saved": "Zapisano harmonogram",
"failed": "Nie udało się zapisać harmonogramu",
@@ -1955,8 +1979,7 @@
"stop": "Zatrzymaj",
"thinking": "Myślę…",
"composerPlaceholder": "Zapytaj o swoje dane…",
"composerHint": "Enter wysyła, Shift+Enter przechodzi do nowej linii.",
"composerDisclaimer": "Odpowiedzi są generowane. Decyzje kliniczne należą do lekarza.",
"nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat",
"selfContextChipsLabel": "Coach chciałby wiedzieć:",
"selfContextChipDismiss": "Odrzuć pytanie",
"voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.",
@@ -3735,6 +3758,7 @@
"resetConfirmTitle": "Przywrócić domyślny łańcuch?",
"resetConfirmBody": "Łańcuch wraca do Codex → OpenAI → Anthropic → Lokalny → OpenAI administratora. Zapisane dane logowania pozostają nienaruszone."
},
"providerCardTitle": "Dostawca AI",
"activeProviderHeading": "Aktywny dostawca",
"activeProviderBody": "Wybierz dostawcę, którego chcesz użyć jako pierwszego. Formularz poniżej konfiguruje tylko wybranego dostawcę; łańcuch zapasowy na dole decyduje, co się stanie w razie awarii.",
"activeProviderLabel": "Główny dostawca",
@@ -4943,6 +4967,30 @@
"hiddenBugBuddy": {
"title": "Towarzysz błędów",
"description": "Wysłałeś co najmniej 5 zgłoszeń błędów — projekt Cię kocha."
},
"missFree7": {
"title": "Bez pominiętej dawki · 7 dni",
"description": "7 kolejnych dni bez pominiętej dawki."
},
"missFree30": {
"title": "Bez pominiętej dawki · 30 dni",
"description": "30 kolejnych dni bez pominiętej dawki."
},
"missFree90": {
"title": "Bez pominiętej dawki · 90 dni",
"description": "90 kolejnych dni bez pominiętej dawki."
},
"measurementWeeks4": {
"title": "Rutyna pomiarów",
"description": "4 kolejne tygodnie z pomiarami przez co najmniej 5 dni w każdym."
},
"selfContextComplete": {
"title": "Wywiad uzupełniony",
"description": "Wszystkie cztery pola wywiadu wypełnione — Coach zna Twój kontekst."
},
"sleepLog7": {
"title": "Dziennik snu · 7 dni",
"description": "Dane o śnie zapisane przez 7 kolejnych dni."
}
},
"nextProgressLabel": "Postęp do odblokowania",
@@ -5604,6 +5652,11 @@
"allSources": "Wszystkie źródła",
"dateFrom": "Od",
"dateTo": "Do",
"dateRange": "Zakres dat",
"typeLabel": "Typ",
"sourceLabel": "Źródło",
"resetFilters": "Resetuj",
"clearFilter": "Usuń filtr: {label}",
"selectAll": "Zaznacz wszystko na tej stronie",
"selectRow": "Zaznacz wiersz",
"selected": "Zaznaczono: {count}",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.16.0",
"version": "1.16.1",
"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",
+223 -3
View File
@@ -1,5 +1,5 @@
/**
* scripts/repair-intake-anomalies.ts — operator repair for the two known
* scripts/repair-intake-anomalies.ts — operator repair for the known
* historic medication-ledger defects:
*
* 1. SLOT DUPLICATES — more than one live `medication_intake_events` row
@@ -18,6 +18,26 @@
* `--fix --tombstone-implausible` the operator can explicitly opt
* into soft-deleting them.
*
* 3. WINDOW / TIMES DRIFT (v1.16.1) — `medication_schedules` rows whose
* legacy `window_start` / `window_end` no longer contains the
* canonical `times_of_day` (e.g. a degenerate 07:00 / 07:00 window
* left behind while the dose times moved to 09:00 / 21:00). The
* band model never reads the window when `times_of_day` exists, but
* the stale pair keeps confusing every legacy read. Repair: clamp
* `window_start` / `window_end` to `min/max(times_of_day)`. Report
* always; mutate with `--fix`. Overnight windows
* (`window_end < window_start`) are skipped — they encode a
* deliberate wrap and cannot drift in this shape.
*
* 4. STALE-ANCHOR PENDING ROWS (v1.16.1) — live pending rows
* (`taken_at IS NULL`, not skipped, not auto-missed) whose
* `scheduled_for` wall-clock HH:mm (user timezone) matches NO
* current dose anchor of the medication (any schedule's
* `times_of_day` entry; `window_start` for legacy rows without
* times). These are reminder-minted slots on retired schedule
* anchors — they linger as phantom open doses. Repair: tombstone
* with `--fix` (same soft-delete shape as defect 1).
*
* After any mutation the affected `(user, medication, day)` compliance
* rollups are recomputed with the SAME slot-level DISTINCT aggregation the
* shared helper runs — see `recomputeComplianceDay` below, a verbatim
@@ -350,9 +370,30 @@ interface Summary {
rowsTombstoned: number;
implausibleRows: number;
implausibleTombstoned: number;
windowDriftSchedules: number;
windowsReconciled: number;
staleAnchorRows: number;
staleAnchorTombstoned: number;
rollupsRecomputed: number;
}
const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
/** Wall-clock `HH:mm` of `instant` in the user's timezone. */
function hhmmInTz(instant: Date, tz: string | null | undefined): string {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: safeTimezone(tz),
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(instant);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "00";
// Some engines render midnight as 24:xx; normalise to 00.
const hour = String(Number(get("hour")) % 24).padStart(2, "0");
return `${hour}:${get("minute")}`;
}
async function main(): Promise<void> {
if (!process.env.DATABASE_URL) {
console.error(
@@ -384,6 +425,10 @@ async function main(): Promise<void> {
rowsTombstoned: 0,
implausibleRows: 0,
implausibleTombstoned: 0,
windowDriftSchedules: 0,
windowsReconciled: 0,
staleAnchorRows: 0,
staleAnchorTombstoned: 0,
rollupsRecomputed: 0,
};
@@ -591,13 +636,184 @@ async function main(): Promise<void> {
}
// ─────────────────────────────────────────────────────────────────
// 3. Recompute the affected compliance rollups so scheduled/taken
// 3. Window / times drift — schedules whose legacy window no longer
// contains the canonical times_of_day. Reconcile the window to
// min/max(times_of_day) with --fix. Overnight windows
// (window_end < window_start) are skipped by the predicate.
// ─────────────────────────────────────────────────────────────────
const schedUserFilter = opts.userId ? `AND m."user_id" = $1` : "";
const driftRes = await client.query<{
id: string;
medication_id: string;
user_id: string;
medication_name: string;
window_start: string;
window_end: string;
times_min: string;
times_max: string;
}>(
`
SELECT s."id", s."medication_id", m."user_id",
m."name" AS medication_name,
s."window_start", s."window_end",
(SELECT MIN(t) FROM unnest(s."times_of_day") AS t) AS times_min,
(SELECT MAX(t) FROM unnest(s."times_of_day") AS t) AS times_max
FROM "medication_schedules" s
JOIN "medications" m ON m."id" = s."medication_id"
WHERE COALESCE(array_length(s."times_of_day", 1), 0) > 0
AND s."window_start" <= s."window_end"
AND (
(SELECT MIN(t) FROM unnest(s."times_of_day") AS t) < s."window_start"
OR (SELECT MAX(t) FROM unnest(s."times_of_day") AS t) > s."window_end"
)
${schedUserFilter}
ORDER BY m."user_id", s."medication_id", s."id"
`,
userParams,
);
summary.windowDriftSchedules = driftRes.rows.length;
console.log(
`\n${TAG} 3) schedules whose window does not contain times_of_day: ${driftRes.rows.length}`,
);
for (const row of driftRes.rows) {
console.log(
` - schedule=${row.id} medication=${row.medication_id} (${row.medication_name}) ` +
`window=${row.window_start}..${row.window_end} → ` +
`${row.times_min}..${row.times_max}`,
);
if (!HHMM_RE.test(row.times_min) || !HHMM_RE.test(row.times_max)) {
console.log(
`${TAG} SKIP: malformed times_of_day bounds on schedule ${row.id}`,
);
continue;
}
if (opts.fix) {
const res = await client.query(
`
UPDATE "medication_schedules"
SET "window_start" = $2,
"window_end" = $3
WHERE "id" = $1
`,
[row.id, row.times_min, row.times_max],
);
summary.windowsReconciled += res.rowCount ?? 0;
}
}
// ─────────────────────────────────────────────────────────────────
// 4. Stale-anchor pending rows — live pending rows whose
// scheduled_for HH:mm (user tz) matches no current dose anchor of
// the medication. Tombstone with --fix.
// ─────────────────────────────────────────────────────────────────
const pendingRes = await client.query<{
id: string;
user_id: string;
medication_id: string;
medication_name: string;
scheduled_for: Date;
source: string;
}>(
`
SELECT e."id", e."user_id", e."medication_id",
m."name" AS medication_name,
e."scheduled_for", e."source"::text AS source
FROM "medication_intake_events" e
JOIN "medications" m ON m."id" = e."medication_id"
WHERE e."deleted_at" IS NULL
AND e."taken_at" IS NULL
AND e."skipped" = false
AND e."auto_missed" = false
${userFilter}
ORDER BY e."user_id", e."scheduled_for"
`,
userParams,
);
/** `medicationId -> Set<HH:mm>` of current dose anchors. */
const anchorCache = new Map<string, Set<string>>();
async function medicationAnchors(
medicationId: string,
): Promise<Set<string>> {
const cached = anchorCache.get(medicationId);
if (cached) return cached;
const res = await client.query<{
window_start: string;
times_of_day: string[];
}>(
`SELECT "window_start", "times_of_day"
FROM "medication_schedules" WHERE "medication_id" = $1`,
[medicationId],
);
const anchors = new Set<string>();
for (const s of res.rows) {
const times = (s.times_of_day ?? []).filter((t) => HHMM_RE.test(t));
if (times.length > 0) {
for (const t of times) anchors.add(t);
} else if (HHMM_RE.test(s.window_start)) {
// Legacy row without times_of_day — its anchor is window_start.
anchors.add(s.window_start);
}
}
anchorCache.set(medicationId, anchors);
return anchors;
}
const staleRows: typeof pendingRes.rows = [];
for (const row of pendingRes.rows) {
const anchors = await medicationAnchors(row.medication_id);
// No derivable anchors (PRN-only / unscheduled) — leave the row.
if (anchors.size === 0) continue;
const tz = await userTimezone(row.user_id);
if (!anchors.has(hhmmInTz(row.scheduled_for, tz))) {
staleRows.push(row);
}
}
summary.staleAnchorRows = staleRows.length;
console.log(
`\n${TAG} 4) live pending rows on stale schedule anchors: ${staleRows.length}`,
);
if (staleRows.length > 0) {
console.table(
staleRows.map((r) => ({
id: r.id,
medication: r.medication_name,
scheduledFor: r.scheduled_for.toISOString(),
source: r.source,
})),
);
}
if (opts.fix && staleRows.length > 0) {
const res = await client.query(
`
UPDATE "medication_intake_events"
SET "deleted_at" = NOW(),
"sync_version" = "sync_version" + 1,
"updated_at" = NOW()
WHERE "id" = ANY($1) AND "deleted_at" IS NULL
`,
[staleRows.map((r) => r.id)],
);
summary.staleAnchorTombstoned = res.rowCount ?? 0;
for (const row of staleRows) {
const tz = await userTimezone(row.user_id);
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
}
}
// ─────────────────────────────────────────────────────────────────
// 5. Recompute the affected compliance rollups so scheduled/taken
// counts self-correct. Same DISTINCT-slot SQL the shared helper
// runs (see `recomputeComplianceDay` above).
// ─────────────────────────────────────────────────────────────────
if (opts.fix && daysToRecompute.size > 0) {
console.log(
`\n${TAG} 3) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
`\n${TAG} 5) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
);
for (const key of daysToRecompute) {
const [userId, medicationId, dayKey] = key.split("|");
@@ -630,6 +846,10 @@ async function main(): Promise<void> {
console.log(` duplicate rows tombstoned: ${summary.rowsTombstoned}`);
console.log(` implausible taken_at rows: ${summary.implausibleRows}`);
console.log(` implausible rows tombstoned: ${summary.implausibleTombstoned}`);
console.log(` window/times drift schedules: ${summary.windowDriftSchedules}`);
console.log(` windows reconciled: ${summary.windowsReconciled}`);
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
}
@@ -85,8 +85,13 @@ describe("resolveChartRevealState", () => {
).toBe(true);
});
it("pins the 2 s fallback budget", () => {
expect(CHART_REVEAL_TIMEOUT_MS).toBe(2_000);
it("pins the 1.2 s fallback budget", () => {
// v1.16.1 — lowered from 2 s: the timeout is only the worst-case cap
// on how long fast charts wait for the slowest sibling (there is no
// minimum delay — the row reveals the moment every gated query
// settles). 1.2 s keeps the synchronized reveal without making one
// cold widget hold the whole row on skeletons for 2 s.
expect(CHART_REVEAL_TIMEOUT_MS).toBe(1_200);
});
});
@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import RootLoading from "../loading";
import {
DASHBOARD_WIDGET_IDS,
DEFAULT_DASHBOARD_LAYOUT,
} from "@/lib/dashboard-layout";
/**
* v1.16.1 — root loading fallback renders the structured dashboard
* silhouette, not bare cards.
*
* The first frame a user sees on a cold dashboard load is this fallback
* (it streams before the page mounts and before any query resolves).
* Pre-fix it painted six EMPTY `bg-card` rectangles in a generic
* 3-column grid; the page then swapped to its structured
* `<TrendCardSkeleton>` strip + `<ChartSkeleton>` band — a visible
* "empty dark cards → skeletons" two-step with a layout jump in
* between. These tests pin the fallback to the SAME silhouette
* components and the SAME grid track the page's own loading phase
* uses, so every phase of the first paint shows one consistent
* structure.
*/
const GRID_TRACK =
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]";
// Same expression the fallback (and, per-user, the page) uses to size
// the silhouette strip: web-known widget ids, tileVisible falling back
// to the legacy visible flag.
const webWidgetIds = new Set<string>(DASHBOARD_WIDGET_IDS);
const expectedTileCount = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
(w) => webWidgetIds.has(w.id) && (w.tileVisible ?? w.visible),
).length;
function renderMarkup(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RootLoading />
</I18nProvider>,
);
}
describe("RootLoading — structured dashboard silhouette (SSR markup)", () => {
it("renders one structured trend-card silhouette per default-visible web tile", () => {
const markup = renderMarkup();
const tiles = markup.match(/data-slot="trend-card-skeleton"/g) ?? [];
expect(expectedTileCount).toBeGreaterThan(0);
expect(tiles).toHaveLength(expectedTileCount);
});
it("renders the chart-band silhouette", () => {
const markup = renderMarkup();
expect(markup).toContain('data-slot="chart-skeleton"');
});
it("uses the same auto-fit grid track as the in-page tile strip", () => {
const markup = renderMarkup();
expect(markup).toContain(
"grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))",
);
});
it("marks the silhouette strip with the shared tile-strip-skeleton slot", () => {
const markup = renderMarkup();
expect(markup).toContain('data-slot="dashboard-tile-strip-skeleton"');
});
it("contains no bare empty-card silhouette (every card carries inner skeleton rows)", () => {
const markup = renderMarkup();
// The legacy fallback's signature was a card with a single empty
// h-24 block and no structured rows. The structured silhouettes
// always carry the headline-value row (`h-[30px]`) inside each
// card; assert it is present and the legacy block is gone.
expect(markup).not.toContain("h-24");
expect(markup).toContain("h-[30px]");
});
});
describe("RootLoading — single skeleton source (wiring pins)", () => {
const loadingSrc = readFileSync(
join(process.cwd(), "src/app/loading.tsx"),
"utf8",
);
const pageSrc = readFileSync(join(process.cwd(), "src/app/page.tsx"), "utf8");
it("imports the same silhouette components the page's loading phase uses", () => {
expect(loadingSrc).toContain(
'import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"',
);
expect(loadingSrc).toContain(
'import { ChartSkeleton } from "@/components/charts/chart-skeleton"',
);
expect(pageSrc).toContain(
'import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"',
);
});
it("shares the grid-track literal with the page so the footprint matches at swap time", () => {
expect(loadingSrc).toContain(GRID_TRACK);
expect(pageSrc).toContain(GRID_TRACK);
});
});
+17 -5
View File
@@ -127,17 +127,29 @@ export const POST = apiHandler(async (request: NextRequest) => {
// so a taken username or weak password can never burn one of the
// invite's uses. The guarded increment inside `consumeInviteToken`
// makes the last-use race safe under concurrent signups.
//
// v1.16.1 — consume whenever a token was sent, not only when the
// invite is the admission requirement. A signup through an invite
// link while registration happens to be open used to leave the
// invite untouched, so the issuer's ledger (uses, consumers) was
// silently incomplete. The consume stays a hard 403 only when
// registration is closed (the invite is the door key); under open
// registration a stale or exhausted token is annotated and the
// signup proceeds uninvited.
let inviteId: string | null = null;
if (!registrationEnabled && inviteToken) {
if (inviteToken) {
const consumed = await consumeInviteToken(inviteToken);
if (!consumed.ok) {
if (consumed.ok) {
inviteId = consumed.inviteId;
} else {
annotate({
action: { name: "auth.register.invite_rejected" },
meta: { reason: consumed.reason },
meta: { reason: consumed.reason, registration_open: registrationEnabled },
});
return apiError("Invalid or expired invite", 403);
if (!registrationEnabled) {
return apiError("Invalid or expired invite", 403);
}
}
inviteId = consumed.inviteId;
}
// First user becomes admin
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
},
}));
@@ -81,6 +82,9 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
},
}));
@@ -67,6 +68,9 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
});
describe("GET /api/gamification/achievements?format=ios", () => {
@@ -17,6 +17,10 @@ import {
buildExpansionMetricValues,
getEarnabilityFlags,
} from "@/lib/gamification/expansion-metrics";
import {
getMissFreeDayKeys,
getWeeklyConsistency,
} from "@/lib/gamification/care-metrics";
import {
classifyBMI,
classifyBP,
@@ -43,11 +47,19 @@ export const dynamic = "force-dynamic";
const DAY_MS = 24 * 60 * 60 * 1000;
const ROLLING_WINDOW_DAYS = 30;
// v1.16.1 — weekly measurement-consistency badge: 4 consecutive weeks
// with at least 5 distinct active vitals days each. Mirrors the
// `measurement-weeks-4` definition's target.
const MEASUREMENT_CONSISTENCY_MIN_DAYS_PER_WEEK = 5;
const MEASUREMENT_CONSISTENCY_TARGET_WEEKS = 4;
type IntakeEventRecord = {
medicationId: string;
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
/** v1.16.1 — feeds the miss-free day streak. */
autoMissed: boolean;
};
type MedicationScheduleRecord = {
@@ -527,6 +539,8 @@ async function buildAchievementsResult(user: AuthedUser) {
passkeys,
auditEvents,
moodEntries,
sleepMeasurements,
healthProfile,
] = await Promise.all([
prisma.measurement.findMany({
where: {
@@ -560,6 +574,9 @@ async function buildAchievementsResult(user: AuthedUser) {
scheduledFor: true,
takenAt: true,
skipped: true,
// v1.16.1 — the miss-free streak disqualifies a day on any
// auto-missed slot.
autoMissed: true,
},
}),
prisma.medication.findMany({
@@ -623,6 +640,32 @@ async function buildAchievementsResult(user: AuthedUser) {
moodLoggedAt: true,
},
}),
// v1.16.1 — sleep samples feed the sleep-logging streak. Read
// separately from the vitals query above so passive sleep syncs
// never inflate the entry-day / consistent-month engagement
// metrics, which reward ACTIVE tracking.
prisma.measurement.findMany({
where: {
userId,
measuredAt: { gte: startDate, lte: now },
source: { not: "IMPORT" },
type: "SLEEP_DURATION",
deletedAt: null,
},
select: { measuredAt: true },
}),
// v1.16.1 — presence-only read of the self-context questionnaire
// (nothing is decrypted) for the maintained-self-report badge.
prisma.userHealthProfile.findUnique({
where: { userId },
select: {
aboutMeEncrypted: true,
conditionsEncrypted: true,
allergiesEncrypted: true,
coachFocusEncrypted: true,
updatedAt: true,
},
}),
]);
const schedulesByMedicationId = new Map(
@@ -665,6 +708,28 @@ async function buildAchievementsResult(user: AuthedUser) {
now,
);
// v1.16.1 — care-routine series. Miss-free days and sleep-logging
// days reuse the standard day-series plumbing; the weekly measurement
// consistency folds distinct active vitals days into Monday-anchored
// weeks (≥5 active days each, 4 consecutive weeks for the badge).
const missFreeSeries = toDaySeries(getMissFreeDayKeys(intakeEvents));
const sleepSeries = getEventDaySeries(
sleepMeasurements.map((m) => m.measuredAt),
);
const weeklyConsistency = getWeeklyConsistency(
measurements.map((m) => toBerlinDayKey(m.measuredAt)),
MEASUREMENT_CONSISTENCY_MIN_DAYS_PER_WEEK,
MEASUREMENT_CONSISTENCY_TARGET_WEEKS,
);
const selfContextComplete =
healthProfile !== null &&
healthProfile.aboutMeEncrypted !== null &&
healthProfile.conditionsEncrypted !== null &&
healthProfile.allergiesEncrypted !== null &&
healthProfile.coachFocusEncrypted !== null
? 1
: 0;
const expansionValues = buildExpansionMetricValues({
measurements,
moodEntries,
@@ -679,6 +744,7 @@ async function buildAchievementsResult(user: AuthedUser) {
bpCount: expansionValues.bpMeasurementCount,
pulseCount: expansionValues.pulseMeasurementCount,
},
sleepSampleCount: sleepMeasurements.length,
});
const metrics = {
@@ -696,6 +762,11 @@ async function buildAchievementsResult(user: AuthedUser) {
loginDayStreak: calculateLongestStreak(loginDaySeries.dayKeys),
bugReportCount: bugReportDates.length,
...expansionValues,
// v1.16.1 — care-routine metrics.
missFreeDayStreak: calculateLongestStreak(missFreeSeries.dayKeys),
measurementConsistencyWeeks: weeklyConsistency.longestRunWeeks,
selfContextCompleteCount: selfContextComplete,
sleepLogDayStreak: calculateLongestStreak(sleepSeries.dayKeys),
};
const completionDates: Partial<Record<string, Date>> = {};
@@ -821,6 +892,43 @@ async function buildAchievementsResult(user: AuthedUser) {
complianceSeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
// ── v1.16.1 ─ care-routine completion dates ─────────────────────
if (definition.metric === "missFreeDayStreak") {
const completedAt = findStreakCompletionDate(
missFreeSeries.dayKeys,
definition.target,
missFreeSeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "sleepLogDayStreak") {
const completedAt = findStreakCompletionDate(
sleepSeries.dayKeys,
definition.target,
sleepSeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "measurementConsistencyWeeks") {
if (weeklyConsistency.completionDayKey) {
completionDates[definition.id] = dayKeyToDate(
weeklyConsistency.completionDayKey,
);
}
continue;
}
if (definition.metric === "selfContextCompleteCount") {
if (selfContextComplete === 1 && healthProfile) {
completionDates[definition.id] = healthProfile.updatedAt;
}
}
}
+15
View File
@@ -57,6 +57,7 @@ import {
listConversations,
} from "@/lib/ai/coach/persistence";
import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
import {
buildDateKey,
enforceBudget,
@@ -331,6 +332,20 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// rolling summary + extract durable facts off the request path. Fire-and-
// forget: this turn uses whatever summary is already on disk; the refresh
// makes the next long turn fresh. No-ops without an embedded worker.
// v1.16.1 — always-remember categories (allergies, intolerances, explicit
// self-reported diagnoses) must not wait for the >TURN_CAP memory refresh:
// a health-critical statement in the second message of a short chat used
// to never reach the fact store. The deterministic pattern pass is
// provider-free and deduped, so it fires on every user turn.
void storeDeterministicFacts({
conversationId: workingConversationId,
userId,
message,
locale,
}).catch(() => {
// Fact capture must never break the chat turn; the >TURN_CAP LLM
// extraction remains as the catch-all on long conversations.
});
if (allTurns.length > TURN_CAP) {
void enqueueCoachMemoryRefresh({
conversationId: workingConversationId,
@@ -0,0 +1,51 @@
/**
* GET /api/insights/coach/nudge-status — has the proactive Coach nudge
* something the caller has not engaged with yet?
*
* v1.16.1 — feeds the floating Coach bubble: the bubble renders only
* while an unseen Coach-initiated nudge exists, instead of being a
* permanent FAB. "Unseen" is derived, not stored: the latest successful
* `COACH_NUDGE` dispatch (the `push_attempts` ledger the nudge cron
* already anchors its frequency cap on) counts as read once the user
* has sent a Coach message after it. The client additionally remembers
* a local "seen" stamp so opening the chat without sending also clears
* the bubble on that device.
*
* No new table, no migration: both timestamps come from existing rows,
* scoped to the caller.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const [lastNudge, lastUserMessage] = await Promise.all([
prisma.pushAttempt.findFirst({
where: { userId: user.id, eventType: "COACH_NUDGE", result: "ok" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
prisma.coachMessage.findFirst({
where: { role: "user", conversation: { userId: user.id } },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
]);
const nudgedAt = lastNudge?.createdAt ?? null;
const unread =
nudgedAt !== null &&
(lastUserMessage === null || lastUserMessage.createdAt < nudgedAt);
return apiSuccess({
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
unread,
});
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
},
medicationIntakeEvent: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
apiToken: {
@@ -32,6 +33,13 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
// v1.16.1 — the schedule-replace branch recomputes the compliance
// rollups of the tombstoned slots; stub the rollup module so the route
// test never reaches `$executeRaw`.
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
dayKeyForScheduledFor: vi.fn(() => "2026-06-10"),
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medication-category", () => ({
deleteMedicationCategory: vi.fn().mockResolvedValue(undefined),
getMedicationCategories: vi.fn().mockResolvedValue({}),
@@ -55,6 +63,10 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { getMedicationCategories } from "@/lib/medication-category";
import { auditLog } from "@/lib/auth/audit";
import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -78,6 +90,17 @@ beforeEach(() => {
id: "m1",
userId: "user-1",
} as never);
// `resetAllMocks` clears the factory-level resolved values, so the
// schedule-replace reads need fresh defaults each test.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.medicationSchedule.deleteMany).mockResolvedValue({
count: 0,
} as never);
});
describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => {
@@ -230,6 +253,40 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
expect(arg.data.syncVersion).toEqual({ increment: 1 });
});
it("recomputes the compliance rollups of the tombstoned days (v1.16.1)", async () => {
// Two pendings on the same local day + one on another day → exactly
// two distinct day-key recomputes, not three.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{ scheduledFor: new Date("2026-06-10T06:00:00Z") },
{ scheduledFor: new Date("2026-06-10T18:00:00Z") },
{ scheduledFor: new Date("2026-06-11T06:00:00Z") },
] as never);
vi.mocked(dayKeyForScheduledFor).mockImplementation(
(scheduledFor: Date) => scheduledFor.toISOString().slice(0, 10),
);
const res = await PUT(
putReq({
schedules: [{ windowStart: "20:00", windowEnd: "20:30" }],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
"user-1",
"m1",
"2026-06-10",
"Europe/Berlin",
);
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
"user-1",
"m1",
"2026-06-11",
"Europe/Berlin",
);
});
it("does NOT touch intake rows when the body carries no schedules array", async () => {
const res = await PUT(putReq({ name: "Renamed" }), ROUTE_CTX);
expect(res.status).toBe(200);
@@ -439,7 +439,9 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
expect(vi.mocked(cached)).toHaveBeenCalledTimes(1);
const [, key] = vi.mocked(cached).mock.calls[0];
// `${userId}|...` prefix is what `invalidateUserMedications` sweeps.
expect(key).toBe("user-1|med-1|compliance");
// The trailing tz segment (v1.16.1) makes a timezone change miss the
// old entry instead of serving day buckets computed for the prior zone.
expect(key).toBe("user-1|med-1|compliance|UTC");
});
it("bounds the intake-event read to a trailing window and the consumed columns", async () => {
@@ -258,10 +258,14 @@ export const GET = apiHandler(
// Read-through the per-user compliance cache (15 min TTL). Every
// intake / medication write flushes the `${userId}|` prefix via
// `invalidateUserMedications`, so a warm entry can only be stale
// relative to wall-clock drift, never to a user action.
// relative to wall-clock drift, never to a user action. The key
// carries the timezone the payload was bucketed in: day keys, the
// heatmap, and the overdue cut all derive from `userTz`, so a
// timezone change must miss the old entry instead of serving day
// buckets computed for the previous zone until the TTL lapses.
const payload = await cached(
caches.medicationCompliance as ServerCache<CompliancePayload>,
`${user.id}|${id}|compliance`,
`${user.id}|${id}|compliance|${userTz}`,
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
@@ -1,6 +1,10 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PATCH
* /api/medications/[id]/inventory/[itemId].
*
* v1.16.1 — stock-correction contract: `dosesRemaining` sets the count
* absolutely, clamps to `dosesTotal`, and the canonical state machine
* derives the next state (0 ⇒ USED_UP).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -124,3 +128,55 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stock correction (v1.16.1)", () => {
beforeEach(() => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "IN_USE",
dosesTotal: 4,
dosesRemaining: 3,
printedExpiry: null,
notes: null,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockImplementation(
(async (args: { data: Record<string, unknown> }) => ({
id: "i1",
...args.data,
})) as never,
);
});
it("sets the remaining count absolutely", async () => {
const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as { data: { dosesRemaining: number } };
expect(update.data.dosesRemaining).toBe(1);
});
it("derives USED_UP when corrected to zero", async () => {
const res = await PATCH(patchReq({ dosesRemaining: 0 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as { data: { dosesRemaining: number; state: string } };
expect(update.data.dosesRemaining).toBe(0);
expect(update.data.state).toBe("USED_UP");
});
it("clamps a raise above the item's capacity to dosesTotal", async () => {
const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as { data: { dosesRemaining: number } };
expect(update.data.dosesRemaining).toBe(4);
});
it("rejects a negative correction with 422", async () => {
const res = await PATCH(patchReq({ dosesRemaining: -1 }), ROUTE_CTX);
expect(res.status).toBe(422);
});
});
@@ -6,6 +6,9 @@
* - mark-as-used-up (terminal — operator override when a pen is
* physically discarded but the dose ledger hasn't reached zero)
* - update printed expiry (e.g. carton label correction)
* - stock correction via `dosesRemaining` (v1.16.1 — the Bestand
* tab's adjust / withdraw flow; clamped to `dosesTotal`, state
* re-derived by the canonical state machine)
* - update notes
*
* DELETE /api/medications/[id]/inventory/[itemId]
@@ -67,8 +70,13 @@ export const PATCH = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } =
parsed.data;
const {
markAsFirstUseAt,
markAsUsedUp,
printedExpiry,
dosesRemaining,
notes,
} = parsed.data;
// Compose the next-row shape. Each mutation field is optional and
// commutative — applying them in any order produces the same row.
@@ -88,6 +96,14 @@ export const PATCH = apiHandler(
nextPrintedExpiry = printedExpiry;
}
if (dosesRemaining !== undefined) {
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
// Clamp to the item's capacity; the state-machine re-run below
// owns every state consequence (0 ⇒ USED_UP, a raise out of 0
// re-evaluates against the expiry clocks).
nextDosesRemaining = Math.min(dosesRemaining, existing.dosesTotal);
}
if (markAsUsedUp === true) {
nextDosesRemaining = 0;
nextState = "USED_UP";
+45 -13
View File
@@ -18,6 +18,10 @@ import {
} from "@/lib/medication-category";
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { getUserTodayBounds } from "@/lib/timezone";
import { NextRequest } from "next/server";
@@ -287,22 +291,50 @@ export const PUT = apiHandler(
// `syncVersion` so delta-sync clients drop them. The projector and
// the reminder worker re-mint the anchors for the new times on their
// next pass — the route does not pre-create them.
const { start: todayStart } = getUserTodayBounds(
new Date(),
user.timezone || "Europe/Berlin",
);
const userTz = user.timezone || "Europe/Berlin";
const { start: todayStart } = getUserTodayBounds(new Date(), userTz);
const tombstoneWhere = {
userId: user.id,
medicationId: id,
deletedAt: null,
takenAt: null,
skipped: false,
autoMissed: false,
scheduledFor: { gte: todayStart },
} as const;
// Capture the affected slots BEFORE the tombstone so the compliance
// rollups for those days can be recomputed immediately below —
// `updateMany` does not return the touched rows. Without the
// recompute a tombstoned pending kept counting as `scheduled` in
// the rollup row until the next event-driven write for that day,
// depressing the rate the cards read in the meantime.
const tombstoned = await prisma.medicationIntakeEvent.findMany({
where: tombstoneWhere,
select: { scheduledFor: true },
});
await prisma.medicationIntakeEvent.updateMany({
where: {
userId: user.id,
medicationId: id,
deletedAt: null,
takenAt: null,
skipped: false,
autoMissed: false,
scheduledFor: { gte: todayStart },
},
where: tombstoneWhere,
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
// Recompute each affected rollup day (deduped via the user-tz day
// key). Best-effort like every other rollup write-hook: a failure
// is annotated, never blocks the schedule replace.
const staleDayKeys = new Set(
tombstoned.map((row) => dayKeyForScheduledFor(row.scheduledFor, userTz)),
);
for (const dayKey of staleDayKeys) {
try {
await recomputeMedicationComplianceForDay(user.id, id, dayKey, userTz);
} catch (err) {
annotate({
meta: {
medication_compliance_rollup_failed: true,
medication_compliance_rollup_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
await prisma.medicationSchedule.deleteMany({
where: { medicationId: id },
});
+7
View File
@@ -171,6 +171,11 @@
--secondary-foreground: #f8f8f2;
--muted: #44475a;
--muted-foreground: #9aa3b3;
/* NativeSelect chevron — a data-URI background-image cannot resolve
* `currentColor` (it falls back to black per the SVG spec), so the
* muted-foreground value is baked into the stroke per theme. Keep in
* sync with `--muted-foreground` above. */
--native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239aa3b3' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
--accent: #44475a;
--accent-foreground: #f8f8f2;
--destructive: #ff5555;
@@ -266,6 +271,8 @@
--secondary-foreground: #1f1f1f;
--muted: #dcdeef;
--muted-foreground: #5b6273;
/* Light-theme counterpart of the NativeSelect chevron (see dark block). */
--native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235b6273' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
--accent: #dcdeef;
--accent-foreground: #1f1f1f;
--destructive: #cb3a2a;
+52 -11
View File
@@ -3,23 +3,64 @@
* route without its own `loading.tsx`).
*
* Defense-in-depth for the navigation-freeze class: a segment that streams
* paints this tile-grid skeleton instead of a blank frame. Pure server
* component — no client hooks, no data fetch. More specific segments (e.g.
* `/insights`) provide their own skeleton and take precedence.
* paints this skeleton instead of a blank frame. More specific segments
* (e.g. `/insights`) provide their own skeleton and take precedence.
*
* v1.16.1 — the silhouette now mirrors the dashboard's own in-page loading
* phase instead of a generic card grid. Pre-fix this fallback painted six
* EMPTY `bg-card` rectangles in a 3-column grid; the dashboard then swapped
* to its structured `<TrendCardSkeleton>` strip + `<ChartSkeleton>` band, so
* the very first frame a user saw was a flash of bare dark cards followed by
* a layout jump. The fix reuses the exact same silhouette components and the
* same auto-fit grid track (`repeat(auto-fit, minmax(min(100%, 11rem), 1fr))`)
* as `src/app/page.tsx` — ONE skeleton source for every load phase, zero
* intermediate "empty card" state, zero footprint change at swap time.
*/
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
import { Skeleton } from "@/components/ui/skeleton";
import {
DASHBOARD_WIDGET_IDS,
DEFAULT_DASHBOARD_LAYOUT,
} from "@/lib/dashboard-layout";
/**
* Default-layout visible web tiles. The page's own skeleton phase keys its
* silhouette count off the user's resolved layout (`configuredTileCount` in
* `src/app/page.tsx`); this fallback paints before any user data exists, so
* the default layout is the best available guess — and the two counts agree
* for any account that has not trimmed the strip in Settings → Dashboard.
* Same filter expression as the page: web-known ids only, `tileVisible`
* falling back to the legacy `visible` flag.
*/
const webWidgetIds = new Set<string>(DASHBOARD_WIDGET_IDS);
const DEFAULT_TILE_COUNT = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
(w) => webWidgetIds.has(w.id) && (w.tileVisible ?? w.visible),
).length;
export default function RootLoading() {
return (
<div data-slot="dashboard-loading" className="space-y-6 p-4">
<Skeleton className="bg-muted h-7 w-44 rounded" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-card rounded-xl border p-4">
<Skeleton className="bg-muted mb-3 h-4 w-24 rounded" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
<div data-slot="dashboard-loading" className="space-y-6">
{/* Header row — title + welcome line, mirroring the dashboard hero
(`text-2xl` heading + `text-sm` subtitle). */}
<div>
<Skeleton className="h-8 w-44 rounded" />
<Skeleton className="mt-2 h-4 w-64 rounded" />
</div>
{/* Tile strip — same auto-fit grid track + structured silhouettes as
the in-page `data-slot="dashboard-tile-strip-skeleton"` phase. */}
<div
aria-hidden="true"
data-slot="dashboard-tile-strip-skeleton"
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))] gap-3"
>
{Array.from({ length: DEFAULT_TILE_COUNT }).map((_, i) => (
<TrendCardSkeleton key={`tile-skeleton-${i}`} />
))}
</div>
{/* Chart band — the first chart cell's silhouette (header row + range
tabs + chart area), identical to the reveal-gated cells' overlay. */}
<ChartSkeleton />
</div>
);
}
+6 -7
View File
@@ -171,15 +171,14 @@ export default function MedicationsPage() {
queryClient.setQueryData(queryKeys.medicationDetail(med.id), med);
}
// v1.15.20 — the kebab's Edit opens the medication wizard directly:
// it navigates to the detail page with `?edit=1`, which the tabbed
// shell reads synchronously to mount the wizard in edit mode (the same
// `editOpen` path the hero's "Vollständig bearbeiten" uses). History
// still lands on the Verlauf tab; the former Advanced item is retired —
// the Erweitert tab stays reachable through the card body.
// v1.16.1 — the kebab's Edit lands on the detail page's Übersicht tab,
// exactly like tapping the medication name. Every everyday edit lives
// inline in the tabs there; the structural wizard is reachable via
// Erweitert → Lebenszyklus (and still answers `?edit=1` deep-links).
// History still lands on the Verlauf tab.
function openEdit(med: Medication) {
seedDetail(med);
router.push(`/medications/${med.id}?edit=1`);
router.push(`/medications/${med.id}`);
}
function openHistory(med: Medication) {
+3 -5
View File
@@ -13,11 +13,11 @@ import {
Percent,
Pill,
Plus,
Settings2,
Smile,
Target,
TrendingUp,
Waves,
Wrench,
} from "lucide-react";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { cn } from "@/lib/utils";
@@ -844,7 +844,7 @@ export default function DashboardPage() {
<p className="text-muted-foreground mt-1 text-sm">{welcomeText}</p>
</div>
<div className="flex items-center gap-2">
{/* Wrench shortcut to the dashboard-customization settings
{/* Customize shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
the left of the add button as a monochrome ghost icon, with
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
@@ -862,7 +862,7 @@ export default function DashboardPage() {
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
<Settings2 className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
@@ -1456,7 +1456,6 @@ export default function DashboardPage() {
colors={["#bd93f9"]}
unit="kg"
valueBands={weightBands}
targetSettingsHref="/settings/thresholds"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
@@ -1507,7 +1506,6 @@ export default function DashboardPage() {
unit="mmHg"
yAxisUnit="Hg"
targetZones={bpTargetZones}
targetSettingsHref="/settings/thresholds"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
@@ -159,7 +159,7 @@ export function ApiTokenOverviewSection() {
return (
<div className="bg-card border-border overflow-hidden rounded-xl border p-4 sm:p-6">
<div className="flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
<Key className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.apiTokens")}</div>
</div>
@@ -116,7 +116,7 @@ export function AppLogPreviewSection() {
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<header className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<FileText className="text-primary mt-0.5 h-5 w-5" />
<FileText className="text-muted-foreground mt-0.5 h-5 w-5" />
<div>
<h2 className="text-lg font-semibold">
{t("admin.section.app-logs.title")}
+2 -2
View File
@@ -107,10 +107,10 @@ export function AssistantSection() {
return (
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
<div className="flex items-center gap-2">
<Sparkles className="text-primary h-5 w-5" />
<Sparkles className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.assistant.title")}</div>
</div>
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground pl-7 text-sm">
{t("admin.assistant.description")}
</p>
+1 -1
View File
@@ -349,7 +349,7 @@ export function BackupsSection() {
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Database className="text-primary h-5 w-5" />
<Database className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.section.backups.title")}
</div>
+1 -1
View File
@@ -48,7 +48,7 @@ export function BugReportSection() {
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Bug className="text-primary h-5 w-5" />
<Bug className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.bugReportGithub")}
</h2>
@@ -74,7 +74,7 @@ export function FeedbackInboxSection() {
`/admin/feedback`, so the page header already provides the
title + description — the card-level repetition was just
visual noise. The icon stays as a low-key card affordance. */}
<Inbox className="text-primary h-5 w-5" aria-hidden="true" />
<Inbox className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<Tabs
value={activeStatus}
@@ -27,7 +27,7 @@ export function GeneralSettingsSection() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Settings className="text-primary h-5 w-5" />
<Settings className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.appSettings")}</div>
</div>
<div className="mt-4 space-y-4">
+1 -1
View File
@@ -75,7 +75,7 @@ export function GlitchtipSection() {
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<AlertTriangle className="text-primary h-5 w-5" />
<AlertTriangle className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.glitchtipTitle")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
+27 -14
View File
@@ -21,7 +21,10 @@
*
* The QR data-URL is rendered inside the mutation (not in an effect):
* `qrcode`'s `toDataURL` is async, so folding it into `mutationFn`
* keeps the component free of setState-in-effect churn.
* keeps the component free of setState-in-effect churn. The library
* itself loads via dynamic `import()` at that call site (v1.16.1) —
* it is only needed the moment an admin mints an invite, so it stays
* out of the admin route bundle entirely.
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -35,7 +38,6 @@ import {
Ticket,
Users,
} from "lucide-react";
import QRCode from "qrcode";
import { toast } from "sonner";
import {
@@ -108,10 +110,7 @@ interface MintedInvite {
type InviteStatus = "active" | "expired" | "exhausted" | "revoked";
export function deriveInviteStatus(
invite: Pick<
AdminInvite,
"uses" | "maxUses" | "expiresAt" | "revokedAt"
>,
invite: Pick<AdminInvite, "uses" | "maxUses" | "expiresAt" | "revokedAt">,
now: Date,
): InviteStatus {
if (invite.revokedAt !== null) return "revoked";
@@ -150,7 +149,13 @@ const STATUS_STYLES: Record<InviteStatus, { dot: string; chip: string }> = {
},
};
function StatusChip({ status, label }: { status: InviteStatus; label: string }) {
function StatusChip({
status,
label,
}: {
status: InviteStatus;
label: string;
}) {
const styles = STATUS_STYLES[status];
return (
<span
@@ -201,6 +206,8 @@ export function InviteTokensSection() {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()).data as Omit<MintedInvite, "qrDataUrl">;
// Lazy chunk: qrcode is only ever needed right here, at mint time.
const QRCode = (await import("qrcode")).default;
const qrDataUrl = await QRCode.toDataURL(data.url, {
width: 240,
margin: 1,
@@ -289,8 +296,7 @@ export function InviteTokensSection() {
function redemptionSummary(invite: AdminInvite): string {
if (invite.redemptions.length === 0) return "—";
const first = invite.redemptions[0];
const name =
first.user?.username ?? t("admin.invites.redeemerDeleted");
const name = first.user?.username ?? t("admin.invites.redeemerDeleted");
if (invite.redemptions.length === 1) return name;
return t("admin.invites.redeemedByMore", {
username: name,
@@ -308,15 +314,15 @@ export function InviteTokensSection() {
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ticket className="text-primary h-5 w-5" aria-hidden="true" />
<Ticket className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2 id="admin-invites-title" className="text-lg font-semibold">
{t("admin.invites.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-sm">
<p className="text-muted-foreground mt-1 pl-7 text-sm">
{t("admin.invites.description")}
</p>
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 text-xs">
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 pl-7 text-xs">
<ShieldCheck className="size-3.5 shrink-0" aria-hidden="true" />
{t("admin.invites.adminOnlyHint")}
</p>
@@ -345,7 +351,11 @@ export function InviteTokensSection() {
title={t("admin.invites.emptyTitle")}
description={t("admin.invites.emptyDescription")}
action={
<Button type="button" variant="outline" onClick={openCreateDialog}>
<Button
type="button"
variant="outline"
onClick={openCreateDialog}
>
<Plus className="size-4" aria-hidden="true" />
{t("admin.invites.create")}
</Button>
@@ -544,7 +554,10 @@ export function InviteTokensSection() {
<legend className="text-sm font-medium">
{t("admin.invites.ttlLegend")}
</legend>
<div className="mt-2 grid grid-cols-3 gap-2" role="radiogroup">
<div
className="mt-2 grid grid-cols-3 gap-2"
role="radiogroup"
>
{TTL_CHOICES.map((days) => (
<button
key={days}
@@ -198,7 +198,7 @@ export function LoginOverviewSection() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<ScrollText className="text-primary h-5 w-5" />
<ScrollText className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.loginOverview")}</div>
</div>
@@ -59,7 +59,7 @@ export function RecentAuditPreview() {
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ScrollText className="text-primary h-5 w-5" aria-hidden="true" />
<ScrollText className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-audit-heading"
className="text-lg font-semibold"
+2 -2
View File
@@ -111,12 +111,12 @@ export function RemindersSection() {
{/* v1.4.22 D / F-58 — icon swap so the section header matches
the admin-shell nav tile (Bell). Clock now lives on the
timing chips inline instead of competing as the page icon. */}
<Bell className="text-primary h-5 w-5" />
<Bell className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.medicationReminders")}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.medicationRemindersDescription")}
</p>
+2 -2
View File
@@ -12,10 +12,10 @@ export function ServicesSection() {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Globe className="text-primary h-5 w-5" />
<Globe className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.servicesGlobal")}</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.servicesGlobalDescription")}
</p>
<div className="mt-4 space-y-3">
@@ -53,7 +53,7 @@ export function SystemStatusSection() {
<HostMetricsChart />
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Server className="text-primary h-5 w-5" />
<Server className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.systemStatus")}</div>
</div>
{status ? (
@@ -37,7 +37,7 @@ export function SystemStatusSummary() {
className="bg-card border-border rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Server className="text-primary h-5 w-5" aria-hidden="true" />
<Server className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-snapshot-heading"
className="text-lg font-semibold"
+1 -1
View File
@@ -78,7 +78,7 @@ export function UmamiSection() {
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Activity className="text-primary h-5 w-5" />
<Activity className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.umamiTitle")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -243,7 +243,7 @@ export function UserManagementSection() {
floating into the title's gap. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<div className="flex items-center gap-2">
<Users className="text-primary h-5 w-5" />
<Users className="text-muted-foreground h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.userManagement")}
</div>
@@ -77,7 +77,7 @@ export function VersionTileSection() {
className="bg-card border-border rounded-xl border p-6"
>
<div className="flex flex-wrap items-center gap-2">
<Tag className="text-primary h-5 w-5" aria-hidden="true" />
<Tag className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-version-heading"
className="text-lg font-semibold"
@@ -51,7 +51,7 @@ export function WebPushVapidSection() {
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<BellRing className="text-primary h-5 w-5" />
<BellRing className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.webPushVapidTitle")}
</h2>
+2 -1
View File
@@ -267,6 +267,7 @@ export function MoodHeatmap({
height={cellSize}
rx={2}
fill={cell.color}
fillOpacity={cell.cell ? 0.55 : 1}
className="cursor-pointer"
onPointerEnter={(e) => {
if (e.pointerType === "touch") return;
@@ -317,7 +318,7 @@ export function MoodHeatmap({
<span key={label} className="flex items-center gap-1">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: color }}
style={{ backgroundColor: color, opacity: 0.55 }}
/>
{label}
</span>
+13 -4
View File
@@ -22,15 +22,24 @@ import { cn } from "@/lib/utils";
*
* 1. every gated chart has reported its data query settled
* (`onDataReady` — the Promise.all moment), or
* 2. the reveal timeout elapses (`CHART_REVEAL_TIMEOUT_MS`, 2 s) so a
* 2. the reveal timeout elapses (`CHART_REVEAL_TIMEOUT_MS`) so a
* single slow widget cannot hold the whole row hostage — the late
* chart simply finishes on its own skeleton-free cell afterwards.
*
* Both signals are monotonic: the ready-set only grows and the timeout
* only fires once, so after the first 2 s the reveal can never regress
* even when a later mutation makes an additional chart visible.
* only fires once, so after the timeout window the reveal can never
* regress even when a later mutation makes an additional chart visible.
*
* There is deliberately NO minimum delay: when every gated query is fast
* the row reveals the moment the last one settles. The timeout is only
* the worst-case cap on how long fast charts wait for the slowest
* sibling — v1.16.1 lowered it 2 s → 1.2 s after the maintainer flagged
* the dashboard as feeling slower: with warm caches most charts settle
* in well under a second, and capping the hostage window at 1.2 s keeps
* the synchronized reveal while shaving up to 800 ms of artificial wait
* when one widget lags.
*/
export const CHART_REVEAL_TIMEOUT_MS = 2_000;
export const CHART_REVEAL_TIMEOUT_MS = 1_200;
/**
* Pure resolver behind `useDashboardChartReveal` — exported so the gate
@@ -1,106 +1,79 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// SSR test render has no Next.js app-router context; stub the two
// navigation hooks the bubble uses.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { LayoutCoachFab } from "../layout-coach-fab";
import { isNudgeUnread, LayoutCoachFab } from "../layout-coach-fab";
/**
* v1.4.33 (F15) — layout-level Coach FAB contract.
* v1.16.1 — the layout FAB is nudge-driven now: it renders ONLY while
* an unseen proactive `COACH_NUDGE` exists (per
* `/api/insights/coach/nudge-status` + the device-local seen stamp).
* No permanent launcher remains — the inline pill is the everyday
* entry point.
*
* The FAB sits at the mobile bottom-right. Before v1.4.33 it overlaid
* Recharts tooltips when the user tapped a data point in the chart's
* lower-right corner — the maintainer flagged this as F15 in the
* runtime audit. The fix wires the FAB to `useChartTooltipActive()`
* which reads a singleton MutationObserver of every
* `.recharts-tooltip-wrapper`; while a wrapper is visible the FAB
* fades and drops `pointer-events`.
*
* Project convention is SSR-only tests (no `@testing-library/react`),
* so we pin the initial-paint state — no tooltip can be active before
* hydration, so the FAB renders without the active-flag attribute
* and stays interactive on first paint. The end-to-end auto-hide
* behaviour is exercised in the Playwright mobile suite.
* Project convention is SSR-only tests (no `@testing-library/react`).
* On first paint the nudge-status query has no data, so the bubble
* must render nothing; the unread derivation is pinned through the
* exported pure helper instead.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const client = new QueryClient();
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LayoutCoachFab>", () => {
it("returns nothing when no <CoachLaunchProvider> is mounted", () => {
// The hook returns `null` outside the provider; the FAB then
// renders nothing rather than crashing — same posture as
// `<CoachLaunchButton>`.
const html = render(<LayoutCoachFab />);
expect(html).not.toContain('data-slot="coach-nudge-bubble"');
});
it("renders nothing on first paint (no nudge-status data yet)", () => {
// The bubble only exists for an unseen nudge; before the status
// query resolves there is nothing to show — no permanent FAB.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).not.toContain('data-slot="coach-nudge-bubble"');
expect(html).not.toContain('data-slot="coach-launch-fab"');
});
});
it("mounts the FAB with the launch-fab slot under the provider", () => {
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toMatch(/data-slot="coach-launch-fab"/);
describe("isNudgeUnread", () => {
const nudgedAt = "2026-06-09T05:15:00.000Z";
it("is false without status or without a nudge", () => {
expect(isNudgeUnread(undefined, null)).toBe(false);
expect(isNudgeUnread({ nudgedAt: null, unread: false }, null)).toBe(false);
});
it("renders without the chart-tooltip-active flag on first paint", () => {
// SSR snapshot of `useChartTooltipActive()` is `false` so the
// FAB stays interactive on initial render. The active classes
// (opacity-0 + pointer-events-none) are applied client-side only
// once a Recharts tooltip wrapper flips its inline visibility.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
// The data attribute is `undefined` when inactive — React drops
// it from the DOM so the rendered markup never carries it.
expect(html).not.toContain('data-chart-tooltip-active="true"');
// The shadcn `<Button>` primitive bakes in
// `disabled:pointer-events-none` and `[&_svg]:pointer-events-none`,
// so we look for the unprefixed token that the fade-out adds.
// Match it as a class atom (preceded by space/start, followed by
// space/end-of-class-list/quote) so the primitive's prefixed
// variants can't false-match.
expect(html).not.toMatch(/(?:^|["\s])pointer-events-none(?=["\s])/);
expect(html).not.toContain("opacity-0");
it("is true for a server-unread nudge the device has not seen", () => {
expect(isNudgeUnread({ nudgedAt, unread: true }, null)).toBe(true);
expect(isNudgeUnread({ nudgedAt, unread: true }, "2026-01-01")).toBe(true);
});
it("carries the transition class so the fade is animated", () => {
// The fade-out + fade-in shouldn't pop visually — the FAB
// animates opacity over 150ms.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toContain("transition-opacity");
expect(html).toContain("duration-150");
it("is false once the server counts the nudge as read", () => {
// The user sent a Coach message after the nudge.
expect(isNudgeUnread({ nudgedAt, unread: false }, null)).toBe(false);
});
it("hides the FAB above lg via `lg:hidden`", () => {
// The desktop Coach surface is the inline pill; the FAB stays
// mobile-only.
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
);
expect(html).toContain("lg:hidden");
});
it("renders the localised label in German", () => {
const html = render(
<CoachLaunchProvider>
<LayoutCoachFab />
</CoachLaunchProvider>,
"de",
);
expect(html).toContain("Coach fragen");
it("is false once this device stored the matching seen stamp", () => {
expect(isNudgeUnread({ nudgedAt, unread: true }, nudgedAt)).toBe(false);
});
});
@@ -91,10 +91,10 @@ export function CoachLaunchButton({
);
}
// Inline header-style action on `lg+`. Hidden below `lg` because the
// layout-level FAB (`<LayoutCoachFab>`) covers that breakpoint —
// mounting both at the same time would put two affordances in front
// of the user.
// Inline header-style action. v1.16.1 — visible on every viewport:
// the layout-level FAB is no longer a permanent launcher (it only
// surfaces an unseen proactive nudge), so the pill is the everyday
// Coach entry point on mobile too.
return (
<Button
type="button"
@@ -102,7 +102,7 @@ export function CoachLaunchButton({
size="sm"
data-slot="coach-launch-inline"
onClick={() => launch.askCoach(prefill ?? null)}
className={cn("hidden h-10 gap-2 self-end lg:inline-flex", className)}
className={cn("inline-flex h-10 gap-2 self-end", className)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
@@ -5,17 +5,15 @@ import { I18nProvider } from "@/lib/i18n/context";
import { CoachDrawerBody } from "../coach-drawer-body";
/**
* v1.4.20 phase B4 — Coach drawer mobile rail trays.
* v1.4.20 phase B4 — Coach drawer rail-tray strip.
*
* Two chevron-button triggers along the thread sub-header open
* side-sheets surfacing the history + sources rails.
*
* v1.12.0 — the conversation history is no longer an inline column on
* any viewport. The "Conversations" trigger is the only entry to the
* history rail and is therefore visible at every breakpoint; the
* sources rail stays inline at xl+ so its trigger keeps `xl:hidden`.
* The clinical-decisions disclaimer renders once, directly above the
* composer.
* v1.16.1 — the body lost its inline xl+ sources column (the sources
* rail is tray-only on every viewport, hidden until toggled) and the
* clinical-decisions disclaimer slot. The history affordance forks by
* surface: the drawer hands off to the full-page route via
* `onHistoryClick`; the page passes an inline `historyRail` slot that
* renders as a left column on lg+ (the strip button collapses to
* `lg:hidden` there).
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -25,33 +23,23 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
const baseProps = {
sourcesRail: <span>sources</span>,
thread: <span>thread</span>,
composer: <span>composer</span>,
disclaimer: <span>disclaimer</span>,
onOpenHistoryTray: () => undefined,
onHistoryClick: () => undefined,
onOpenSourcesTray: () => undefined,
};
describe("<CoachDrawerBody> — mobile rail trays", () => {
it("renders both tray triggers", () => {
describe("<CoachDrawerBody> — rail-tray strip", () => {
it("renders both strip triggers", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(/data-slot="coach-drawer-history-tray-trigger"/);
expect(html).toMatch(/data-slot="coach-drawer-sources-tray-trigger"/);
});
it("keeps the history trigger on every breakpoint and hides the sources trigger on >=xl", () => {
// v1.12.0 — the history rail is tray-only now, so the
// "Conversations" toggle is always visible (no `lg:hidden`) and
// the strip wrapper no longer carries `xl:hidden`. The sources
// trigger keeps `xl:hidden` because the sources rail is inline at
// xl+.
it("keeps both triggers visible on every breakpoint without an inline historyRail", () => {
// v1.16.1 — the sources rail is tray-only now, so its trigger lost
// the `xl:hidden` cap; the history trigger stays visible too.
const html = render(<CoachDrawerBody {...baseProps} />);
const strip = html.match(
/<div[^>]*data-slot="coach-drawer-rail-tray-strip"[^>]*>/,
);
expect(strip?.[0]).not.toContain("xl:hidden");
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
@@ -61,37 +49,43 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
const sourcesTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-sources-tray-trigger"[^>]*>/,
);
expect(sourcesTrigger?.[0]).toContain("xl:hidden");
expect(sourcesTrigger).not.toBeNull();
expect(sourcesTrigger?.[0]).not.toContain("xl:hidden");
});
it("no longer mounts an inline history column; sources rail stays inline at xl+", () => {
it("no longer mounts an inline sources column", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).not.toContain('data-slot="coach-drawer-history"');
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-sources"[^>]*xl:flex/,
);
expect(html).not.toContain('data-slot="coach-drawer-sources"');
});
it("renders the message thread + composer slots and the disclaimer above the composer", () => {
it("mounts an inline history column on lg+ when historyRail is passed (page surface)", () => {
const html = render(
<CoachDrawerBody {...baseProps} historyRail={<span>history</span>} />,
);
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-history"[^>]*lg:flex/,
);
expect(html).toContain("history");
// The strip button collapses on lg+ where the inline column shows.
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(historyTrigger?.[0]).toContain("lg:hidden");
});
it("renders the message thread + composer slots without a disclaimer line", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(/data-slot="coach-drawer-thread"/);
expect(html).toMatch(/data-slot="coach-drawer-composer"/);
// Slot content forwarded verbatim.
expect(html).toContain("thread");
expect(html).toContain("composer");
// The disclaimer slot renders inside the composer container, before
// the composer itself.
expect(html).toContain("disclaimer");
const composerBlock = html.match(
/data-slot="coach-drawer-composer"[^>]*>([\s\S]*?)<\/div>/,
);
expect(composerBlock?.[1]).toContain("disclaimer");
const disclaimerIdx = html.indexOf("disclaimer");
const composerIdx = html.lastIndexOf("composer");
expect(disclaimerIdx).toBeLessThan(composerIdx);
// v1.16.1 — the clinical-decisions disclaimer was removed from the
// chat UI entirely.
expect(html).not.toContain("coach-composer-disclaimer");
});
it("uses chevron-style trigger labels — History on the left, Sources on the right", () => {
it("uses the localized trigger labels — History on the left, Sources on the right", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>[\s\S]*?<\/button>/,
@@ -114,9 +108,8 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
it("renders the rail triggers in a sub-header strip above the thread", () => {
// v1.4.27 R3d MB2 — the triggers were lifted out of the absolute
// overlay into a sub-header strip so the chevrons sit at a 44 px
// tap target and never overlay the first message bubble. The
// strip lives above the thread within the centre column.
// overlay into a sub-header strip so they sit at a 44 px tap
// target and never overlay the first message bubble.
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(/data-slot="coach-drawer-rail-tray-strip"/);
const trigger = html.match(
@@ -26,22 +26,18 @@ describe("<CoachInput>", () => {
expect(html).not.toContain("Coach replies are generated");
});
it("renders the localised placeholder + the popover-trigger hint", () => {
// v1.4.27 F15 — the verbose prose hint footer was replaced by an
// Info icon button. The same `composerHint` translation surfaces
// as the trigger's `aria-label` (so screen-readers announce it on
// focus) and as the popover body for sighted users on tap.
//
// v1.4.27 MB3 / CF-31 — the disclosure swapped off Radix Tooltip
// (no reliable tap-toggle on touch surfaces) onto Radix Popover,
// so the contract is now "tap to open" instead of "hover".
it("renders the localised placeholder without the retired shortcut hint", () => {
// v1.16.1 — the Enter/Shift+Enter hint (prose footer, later an
// Info-popover) is gone entirely; the behaviour itself is
// unchanged. The composer is a single row: textarea + send button
// on the same baseline.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toContain("Ask anything about your data");
expect(html).toMatch(
/aria-label="Press Enter to send[^"]*"\s+data-slot="coach-input-hint"/,
);
expect(html).not.toContain('data-slot="coach-input-hint"');
expect(html).not.toContain("Press Enter to send");
expect(html).toContain('data-slot="coach-input-send"');
});
it("renders the German placeholder under the 'de' locale", () => {
@@ -1,123 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { toast } from "sonner";
import { I18nProvider } from "@/lib/i18n/context";
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
// v1.4.23 W6 (Design-H3 + H4) — verify the settings sheet renders a
// skeleton placeholder while the persisted prefs are loading (no
// `DEFAULT_COACH_PREFS` ghost form) and emits a sonner toast on save
// success.
//
// The shadcn `<Sheet>` wraps Radix Dialog which uses Portals — those
// don't materialise under `renderToStaticMarkup`. We mock the sheet
// primitives down to plain wrappers so the form/skeleton body is
// reachable in the static markup.
const queryState: { data: unknown } = { data: undefined };
const mutationCallbacks: { onSuccess: ((env: unknown) => void) | null } = {
onSuccess: null,
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: queryState.data }),
useMutation: (opts: { onSuccess?: (env: unknown) => void }) => {
mutationCallbacks.onSuccess = opts.onSuccess ?? null;
return {
mutate: (next: unknown) => {
mutationCallbacks.onSuccess?.({ data: next });
},
isPending: false,
};
},
useQueryClient: () => ({
setQueryData: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn() },
}));
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-slot="mock-sheet">{children}</div> : null,
SheetContent: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-sheet-content">{children}</div>
),
SheetHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SheetTitle: ({ children }: { children: React.ReactNode }) => (
<h2>{children}</h2>
),
SheetDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
),
// v1.4.27 R3d MB1 — the settings sheet now renders an inline
// `<SheetClose>` in the header instead of the primitive's
// absolutely-positioned close-X. Mock it as a passthrough so the
// existing skeleton + form snapshot assertions keep working.
SheetClose: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import { CoachSettingsSheet } from "../coach-settings-sheet";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
beforeEach(() => {
queryState.data = undefined;
mutationCallbacks.onSuccess = null;
vi.mocked(toast.success).mockClear();
});
describe("<CoachSettingsSheet>", () => {
it("renders a skeleton shell while persisted prefs are loading (Design-H3)", () => {
queryState.data = undefined;
const html = render(
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
);
expect(html).toContain('data-slot="coach-prefs-skeleton"');
// Form body markers must be absent during the skeleton state so
// the user never sees the DEFAULT_COACH_PREFS ghost form.
expect(html).not.toContain('data-slot="coach-prefs-tone"');
expect(html).not.toContain('data-slot="coach-prefs-evidence"');
});
it("renders the form once persisted prefs land", () => {
queryState.data = DEFAULT_COACH_PREFS;
const html = render(
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
);
expect(html).not.toContain('data-slot="coach-prefs-skeleton"');
expect(html).toContain('data-slot="coach-prefs-tone"');
// v1.4.27 F14 — the `showEvidenceByDefault` switch is retired.
expect(html).not.toContain('data-slot="coach-prefs-evidence"');
});
it("fires a sonner toast on save success (Design-H4)", () => {
queryState.data = DEFAULT_COACH_PREFS;
render(<CoachSettingsSheet open={true} onOpenChange={() => {}} />);
// Static markup doesn't dispatch click events; trigger the captured
// mutation success path directly. Verifies the wired-up onSuccess
// emits the toast with the localised "Saved." string.
mutationCallbacks.onSuccess?.({ data: DEFAULT_COACH_PREFS });
expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Saved.");
});
// v1.4.25 W5 — defaultWindow picker added.
it("renders the default-window picker once persisted prefs land", () => {
queryState.data = DEFAULT_COACH_PREFS;
const html = render(
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
);
expect(html).toContain('data-slot="coach-prefs-default-window"');
expect(html).toContain("Default analysis window");
});
});
@@ -68,19 +68,19 @@ describe("<MobileRailTray>", () => {
expect(html).toContain("sources-content");
});
it("keeps the history tray on every breakpoint and hides the sources tray on xl+", () => {
// v1.12.0 — the history rail is tray-only now (no inline column on
// any viewport), so the history tray drops its `lg:hidden` cap and
// is available at every breakpoint. The sources rail still mounts
// inline at xl+, so its tray keeps `xl:hidden`.
it("keeps both trays available on every breakpoint", () => {
// v1.16.1 — the sources rail lost its inline xl+ column; the tray
// is the one sources surface everywhere, so the `xl:hidden` cap is
// gone. The history tray was breakpoint-free already.
const html = render(<MobileRailTray {...baseProps} />);
const historyTray = html.match(
/data-slot="coach-drawer-history-tray"[^>]*class="([^"]*)"/,
);
expect(historyTray?.[1]).not.toContain("lg:hidden");
expect(html).toMatch(
/data-slot="coach-drawer-sources-tray"[^>]*class="[^"]*xl:hidden/,
const sourcesTray = html.match(
/data-slot="coach-drawer-sources-tray"[^>]*class="([^"]*)"/,
);
expect(sourcesTray?.[1]).not.toContain("xl:hidden");
});
it("renders both header titles localised", () => {
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Plus, Settings, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -9,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input";
import { CoachSettingsSheet } from "./coach-settings-sheet";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { MobileRailTray } from "./mobile-rail-tray";
@@ -25,10 +25,10 @@ import { useCoachConversation, useSendCoachMessage } from "./use-coach";
*
* This component is the single source of truth for the Coach
* conversation: it owns the active-conversation id, the streaming send
* hook, the composer value, the settings sheet, and the mobile rail
* trays — every piece of Coach v2 behaviour (incremental streaming,
* collapsible history + provenance, the single composer disclaimer,
* the auto-grow composer, refusal / budget handling) lives here once.
* hook, the composer value, and the rail trays — every piece of Coach
* v2 behaviour (incremental streaming, collapsible history +
* provenance, the auto-grow composer, refusal / budget handling)
* lives here once.
* The drawer and the page differ ONLY in their chrome: the drawer wraps
* this surface in a `<Sheet>` and supplies a close button + a maximize
* control; the page renders it edge-to-edge and supplies a minimize
@@ -79,6 +79,14 @@ export interface CoachConversationProps {
* tears the stream down on its own.
*/
registerReset?: (reset: () => void) => void;
/**
* v1.16.1 — drawer-only: the "Conversations" affordance no longer
* opens an in-panel left tray (it kept breaking inside the sheet);
* it hands the user off to the full-page Coach route instead. The
* drawer wires this to its maximize handler (close sheet + route);
* the page omits it and renders the list inline / as a tray.
*/
onRequestFullView?: () => void;
/** className passthrough for the outer flex column. */
className?: string;
/** data-variant on the root, surfaced for e2e + styling hooks. */
@@ -93,6 +101,7 @@ export function CoachConversation({
trailingHeaderActions,
autoFocusComposer,
registerReset,
onRequestFullView,
className,
surface,
}: CoachConversationProps) {
@@ -103,7 +112,6 @@ export function CoachConversation({
>(null);
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
const { data: conversation } = useCoachConversation(currentConversationId);
@@ -188,23 +196,36 @@ export function CoachConversation({
>
<Plus className="size-4" aria-hidden="true" />
</Button>
{/* v1.16.1 — the Coach preferences moved into Settings → AI
(one place for model + behaviour). The header keeps a gear
that deep-links there instead of opening an in-chat sheet. */}
<Button
type="button"
asChild
variant="ghost"
size="icon"
onClick={() => setSettingsOpen(true)}
data-slot="coach-settings"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Settings className="size-4" aria-hidden="true" />
<Link
href="/settings/ai"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
>
<Settings className="size-4" aria-hidden="true" />
</Link>
</Button>
{trailingHeaderActions}
</header>
<CoachDrawerBody
sourcesRail={<SourcesRail />}
historyRail={
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => setCurrentConversationId(id)}
/>
) : undefined
}
thread={
<MessageThread
conversation={conversation ?? null}
@@ -212,14 +233,6 @@ export function CoachConversation({
optimisticUser={send.optimisticUser}
/>
}
disclaimer={
<p
data-slot="coach-composer-disclaimer"
className="text-muted-foreground text-xs leading-relaxed"
>
{t("insights.coach.composerDisclaimer")}
</p>
}
composer={
<div>
{/* v1.16.0 — pending self-context questions as tappable
@@ -241,11 +254,14 @@ export function CoachConversation({
/>
</div>
}
onOpenHistoryTray={() => setHistoryTrayOpen(true)}
onHistoryClick={
surface === "drawer" && onRequestFullView
? onRequestFullView
: () => setHistoryTrayOpen(true)
}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
<CoachSettingsSheet open={settingsOpen} onOpenChange={setSettingsOpen} />
<MobileRailTray
historyOpen={historyTrayOpen}
onHistoryOpenChange={setHistoryTrayOpen}
@@ -15,66 +15,72 @@ import { useTranslations } from "@/lib/i18n/context";
* gives the test a node-renderable surface; the `<CoachDrawer>`
* itself stays the integrating shell that owns state + the trays.
*
* The component is stateless: the parent owns `historyTrayOpen` /
* `sourcesTrayOpen` and passes the open-toggles + slot content. This
* keeps the trigger logic + the desktop rail layout in one place
* while delegating the actual side-sheet to the parent.
* The component is stateless: the parent owns the tray state and
* passes the open-toggles + slot content.
*
* v1.12.0 — the history rail is no longer an inline column on any
* viewport; it lives behind the always-visible "Conversations" toggle
* and opens as the left-edge tray. The body therefore no longer takes
* a `historyRail` slot — the parent wires that rail straight into the
* `<MobileRailTray>`.
* v1.16.1 — two structural changes:
* - The sources rail lost its inline xl+ column. "What I draw on" is
* now hidden by default on every viewport and slides in as the
* right-edge tray when the user taps the toggle — the rail is a
* look-up surface, not something worth a permanent 280 px gutter.
* - The history affordance forked by surface: the drawer's
* "Conversations" button hands off to the full-page Coach route
* (the in-panel left tray kept breaking inside the sheet), while
* the page renders the conversation list inline as a left column
* on lg+ via the `historyRail` slot (tray below lg).
*/
export interface CoachDrawerBodyProps {
sourcesRail: React.ReactNode;
thread: React.ReactNode;
composer: React.ReactNode;
/** Clinical-decisions disclaimer line, pinned above the composer. */
disclaimer: React.ReactNode;
onOpenHistoryTray: () => void;
/**
* Inline conversation list (page surface only). When set, it renders
* as a left column on lg+ and the history button collapses to the
* sub-lg tray trigger. The drawer omits it.
*/
historyRail?: React.ReactNode;
onHistoryClick: () => void;
onOpenSourcesTray: () => void;
}
export function CoachDrawerBody({
sourcesRail,
thread,
composer,
disclaimer,
onOpenHistoryTray,
historyRail,
onHistoryClick,
onOpenSourcesTray,
}: CoachDrawerBodyProps) {
const { t } = useTranslations();
return (
<div
data-slot="coach-drawer-body"
// v1.12.0 — the conversation history is no longer a permanent
// left column. It used to mount inline on lg+ (a fixed 260px
// gutter) which ate the chat width on laptops and read as a
// takeover on the narrowed drawer cap. The rail now lives behind
// the "Conversations" toggle on EVERY viewport (opens as the
// left-edge tray), so the thread keeps the full width by default
// and the user summons history on demand. The sources rail stays
// inline at xl+ where there is room; below xl it stays on the
// chevron tray.
className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[1fr_280px]"
className={
historyRail
? "grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[260px_1fr]"
: "grid min-h-0 flex-1 grid-cols-1"
}
>
{/* Inline history column — page surface, lg+ only. */}
{historyRail && (
<aside
data-slot="coach-drawer-history"
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
>
<h2 className="border-border/70 text-muted-foreground border-b p-3 text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.historyTitle")}
</h2>
<div className="min-h-0 flex-1 overflow-y-auto">{historyRail}</div>
</aside>
)}
{/* Centre — message thread. */}
<main
data-slot="coach-drawer-thread"
className="flex h-full min-h-0 flex-col"
>
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
absolute overlay into a sub-header strip so the chevrons
absolute overlay into a sub-header strip so the buttons
sit at a 44 px tap target and never overlay the first
message bubble.
v1.12.0 — the strip is now ALWAYS rendered because the
"Conversations" toggle is the only way to reach the history
rail on every viewport (the rail is no longer an inline
column). The history trigger stays visible at all
breakpoints; the sources trigger keeps `xl:hidden` since the
sources rail is inline at xl+. */}
message bubble. */}
<div
data-slot="coach-drawer-rail-tray-strip"
className="border-border/70 flex items-center justify-between gap-2 border-b px-3 py-2"
@@ -82,23 +88,29 @@ export function CoachDrawerBody({
<Button
type="button"
variant="ghost"
onClick={onOpenHistoryTray}
onClick={onHistoryClick}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="min-h-11 gap-1.5 text-xs"
// With an inline history column (page surface, lg+) the
// button is redundant above lg and hides there.
className={
historyRail
? "min-h-11 gap-1.5 text-xs lg:hidden"
: "min-h-11 gap-1.5 text-xs"
}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
{/* Sources tray trigger stays visible up to xl since the
inline sources rail is xl+ only. */}
{/* Sources toggle — the rail is hidden by default on every
viewport and opens as the right-edge tray. */}
<Button
type="button"
variant="ghost"
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="min-h-11 gap-1.5 text-xs xl:hidden"
className="text-muted-foreground hover:text-foreground ml-auto min-h-11 gap-1.5 text-xs"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-4" aria-hidden="true" />
@@ -106,28 +118,14 @@ export function CoachDrawerBody({
</div>
<div className="min-h-0 flex-1">{thread}</div>
{/* Composer pinned to the bottom. v1.12.0 — the clinical-
decisions disclaimer renders directly above the composer
(the single, always-visible home; the thread-bottom and
sources-rail copies were removed). */}
{/* Composer pinned to the bottom. */}
<div
data-slot="coach-drawer-composer"
className="border-border/70 flex flex-col gap-2 border-t p-3 sm:p-4"
>
{disclaimer}
{composer}
</div>
</main>
{/* Sources rail — xl+ only. lg viewports surface it via the
mobile chevron tray to keep the underlying /insights column
readable behind the narrowed drawer cap. */}
<aside
data-slot="coach-drawer-sources"
className="border-border/70 hidden h-full min-h-0 border-l xl:flex xl:flex-col"
>
{sourcesRail}
</aside>
</div>
);
}
@@ -117,6 +117,10 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) {
prefill={prefill}
autoFocusComposer
registerReset={registerReset}
// v1.16.1 — the "Conversations" affordance hands off to the
// full-page route (same path as maximize) instead of opening
// the broken in-panel left tray.
onRequestFullView={handleMaximize}
// Radix requires an accessible name + description on the
// dialog content. Mount them through the title/description
// render-props so the page can swap in a plain heading.
@@ -7,14 +7,9 @@ import {
useEffect,
useRef,
} from "react";
import { Info, Loader2, Send, Square } from "lucide-react";
import { Loader2, Send, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -186,10 +181,17 @@ export function CoachInput({
onSubmit={handleFormSubmit}
className="flex flex-col"
>
{/* v1.16.1 — modern chat-app composer: a single row with the
textarea and the send control on the same baseline. The
textarea starts at one line and auto-grows; `items-end` keeps
the button pinned to the input's last line as it grows. The
old footer row (Info-popover hint + right-aligned button) is
gone — Enter still sends and Shift+Enter still inserts a
newline, the affordance just no longer needs explaining. */}
<div
className={cn(
"border-border/60 bg-muted/40 group rounded-md border",
"p-2.5 transition-colors",
"flex items-end gap-2 p-2 transition-colors",
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:ring-2",
)}
>
@@ -221,86 +223,51 @@ export function CoachInput({
// 16 px floor and yanks the viewport on every Coach tap.
// Desktop shrinks back to `text-sm` for the compact
// composer.
"w-full resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
// Centre the single-line state against the 36 px send
// button so the placeholder and the icon share a baseline.
"py-1.5",
"max-h-[9.5rem] overflow-auto",
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
{/* v1.4.27 F15 — the verbose "Enter to send, Shift+Enter for
new line" prose footer used to render under the textarea;
it ate ~140 px of vertical room for an Apple-Health-like
single-message exchange. The hint now sits behind a tiny
Info icon left of the send button; the existing
translation string surfaces as the popover body so
screen-reader users still hear it on focus.
v1.4.27 MB3 / CF-31 — swapped off Radix `<Tooltip>` (which
never tap-toggles reliably on touch) onto `<Popover>` so
mobile users can open the hint via a plain tap on the icon
button. The aria-label still labels the icon for screen
readers; the popover body carries the long-form copy. */}
<div className="mt-1.5 flex items-center justify-end gap-2">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t("insights.coach.composerHint")}
data-slot="coach-input-hint"
className={cn(
"text-muted-foreground hover:text-foreground",
"focus-visible:ring-ring/50 inline-flex h-11 w-11",
"items-center justify-center rounded",
"focus-visible:ring-2 focus-visible:outline-none",
)}
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
data-slot="coach-input-hint-body"
>
{t("insights.coach.composerHint")}
</PopoverContent>
</Popover>
{isStreaming && onCancel ? (
// While a reply streams, swap the send button for a Stop
// control bound to the abort handler so the user can
// interrupt a long or off-track reply instead of waiting it
// out. The spinner stays inside the Stop affordance so the
// in-progress state is still legible.
<Button
type="button"
size="sm"
variant="outline"
onClick={onCancel}
data-slot="coach-input-stop"
className="gap-1.5"
>
<Square
className="size-3.5 fill-current"
{isStreaming && onCancel ? (
// While a reply streams, swap the send button for a Stop
// control bound to the abort handler so the user can
// interrupt a long or off-track reply instead of waiting it
// out.
<Button
type="button"
size="icon"
variant="outline"
onClick={onCancel}
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
className="size-9 shrink-0"
>
<Square className="size-3.5 fill-current" aria-hidden="true" />
</Button>
) : (
<Button
type="submit"
size="icon"
disabled={!canSubmit}
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
className="size-9 shrink-0"
>
{isStreaming ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<span>{t("insights.coach.stop")}</span>
</Button>
) : (
<Button
type="submit"
size="sm"
disabled={!canSubmit}
data-slot="coach-input-send"
className="gap-1.5"
>
{isStreaming ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<Send className="size-3.5" aria-hidden="true" />
)}
<span>{t("insights.coach.send")}</span>
</Button>
)}
</div>
) : (
<Send className="size-4" aria-hidden="true" />
)}
</Button>
)}
</div>
</form>
);
@@ -1,518 +0,0 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { X } from "lucide-react";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import {
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachDataClusterEnum,
type CoachDataCluster,
type CoachDefaultWindow,
type CoachExcludeMetric,
type CoachPrefs,
type CoachTone,
type CoachVerbosity,
} from "@/lib/validations/coach-prefs";
/**
* v1.4.23 H4 — per-user Coach prompt-tuning sheet.
*
* Surfaced from the Coach drawer header cog. Reads + writes
* `/api/auth/me/coach-prefs`. Mounted as a right-edge `<Sheet>` so it
* sits on top of the drawer without conflicting with the existing
* mobile rail trays (history = left, sources = right tray on `<xl`,
* settings = right sheet — fine because the user only opens one at a
* time).
*
* The four controls match the v1.4.23 H4 spec exactly: tone (3-state),
* verbosity (3-state), excludeMetrics (multi-toggle), and
* showEvidenceByDefault (single toggle).
*
* v1.4.23 W6 design-pass:
* - Skeleton placeholder while the prefs are loading (Design-H3) so
* the user never sees the `DEFAULT_COACH_PREFS` ghost form snap to
* their saved values mid-paint.
* - On save success the sheet emits a sonner toast (Design-H4) before
* closing so the action is visibly acknowledged.
*/
export interface CoachSettingsSheetProps {
open: boolean;
onOpenChange: (next: boolean) => void;
}
const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
"bp",
"weight",
"pulse",
"mood",
"compliance",
"hrv",
"sleep",
"resting_hr",
"steps",
];
// v1.4.36 W3 T2 — optional context blocks the user can opt out of.
// Surfaced as a separate group below the per-metric exclude list so
// the intent ("don't ship my profile / medication context to the
// model") reads distinctly from the per-metric data toggles above.
// Each row gates a single labelled block at the snapshot/feature layer
// so empty blocks never reach the prompt.
const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
"sleep",
"medications",
"anthropometrics",
];
// v1.4.25 W5 — picker options for the new `defaultWindow` preference.
// Mirrors `CoachScopeWindow` so the chat route can fold the selection
// straight into `scope.window` when the client didn't supply one.
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
"last7days",
"last30days",
"last90days",
"allTime",
];
// v1.7.0 — clustered, opt-in data sources. Order matches the design
// sketch (default-on clinical clusters first). Each row shows the
// cluster label + a member-preview hint + a toggle. Toggling writes
// `dataClusters`; the snapshot builder expands the enabled set into the
// source set, then subtracts the existing `excludeMetrics` post-filter.
const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options;
export function CoachSettingsSheet({
open,
onOpenChange,
}: CoachSettingsSheetProps) {
const { t } = useTranslations();
const { data: persisted } = useCoachPrefs({ enabled: open });
// Local working copy edited by the form. Re-syncs to the persisted
// shape on every fetch via the same in-render setState pattern as
// the drawer's `useResettableValue` (no useEffect — the
// `react-hooks/set-state-in-effect` rule rejects setState inside an
// effect when the source is a query result).
const [draft, setDraft] = useState<CoachPrefs>(DEFAULT_COACH_PREFS);
const [lastSeenPersisted, setLastSeenPersisted] = useState<CoachPrefs | null>(
null,
);
if (persisted && persisted !== lastSeenPersisted) {
setLastSeenPersisted(persisted);
setDraft(persisted);
}
// v1.7.2 — write through the shared `useSaveCoachPrefs` mutation so
// the cog and the chat-side sources rail persist via one code path
// and seed the same `coachPrefs()` cache key on success.
const save = useSaveCoachPrefs({
onSuccess: () => {
toast.success(t("insights.coach.settingsSaved"));
onOpenChange(false);
},
});
function toggleExclude(metric: CoachExcludeMetric, next: boolean) {
setDraft((prev) => ({
...prev,
excludeMetrics: next
? Array.from(new Set([...prev.excludeMetrics, metric]))
: prev.excludeMetrics.filter((m) => m !== metric),
}));
}
// v1.7.0 — the persisted `dataClusters` may be `undefined` (the user
// never opened the picker). Materialise the legacy default set the
// first time the user toggles a cluster so the on-screen switches
// reflect what the snapshot builder actually resolves.
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
setDraft((prev) => {
const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
const updated = next
? Array.from(new Set([...current, cluster]))
: current.filter((c) => c !== cluster);
return { ...prev, dataClusters: updated };
});
}
// The switch state mirrors the resolved set: undefined → legacy
// defaults, otherwise the explicit array (including the empty
// "everything off" state).
const enabledClusters = new Set<CoachDataCluster>(
draft.dataClusters ?? DEFAULT_COACH_CLUSTERS,
);
// Render a skeleton shell while the persisted prefs are loading so
// we never render the form against `DEFAULT_COACH_PREFS` and snap
// the controls when the fetch resolves a moment later.
const isLoading = open && !persisted;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
// v1.4.27 R3d MB1 — the default `<SheetContent>` close-X is an
// absolutely positioned `opacity-70 rounded-xs` button that
// clashed with the in-header layout. Match the coach-drawer
// pattern instead: hide the primitive's close-X and render a
// matching ghost-icon `<SheetClose>` inline in the header so
// the close affordance sits on the same baseline as the rest
// of the sheet chrome and clears the 44 px tap target.
showCloseButton={false}
data-slot="coach-settings-sheet"
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-[420px]"
>
<SheetHeader className="border-border/70 flex-row items-start gap-2 border-b p-4">
<div className="min-w-0 flex-1">
<SheetTitle className="text-sm font-semibold">
{t("insights.coach.settingsTitle")}
</SheetTitle>
<SheetDescription className="text-muted-foreground text-xs">
{t("insights.coach.settingsDescription")}
</SheetDescription>
</div>
<SheetClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="coach-settings-sheet-close"
aria-label={t("common.close")}
title={t("common.close")}
className="text-muted-foreground hover:text-foreground -mt-1 shrink-0"
>
<X className="size-4" aria-hidden="true" />
</Button>
</SheetClose>
</SheetHeader>
{isLoading ? (
<div
data-slot="coach-prefs-skeleton"
className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-9 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-9 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-44 w-full" />
</div>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-1 flex-col gap-1">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-2/3" />
</div>
<Skeleton className="h-5 w-9 rounded-full" />
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4">
{/* v1.13.1 — Tone + Verbosity side by side (2-col on ≥sm,
stacked on mobile) so the two short selects don't add a
full extra scroll length to the sheet. */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{/* Tone */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-tone"
className="text-xs font-medium"
>
{t("insights.coach.settingsToneLabel")}
</Label>
<Select
value={draft.tone}
onValueChange={(value) =>
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
}
>
<SelectTrigger
id="coach-prefs-tone"
data-slot="coach-prefs-tone"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="warm">
{t("insights.coach.settingsToneWarm")}
</SelectItem>
<SelectItem value="neutral">
{t("insights.coach.settingsToneNeutral")}
</SelectItem>
<SelectItem value="concise">
{t("insights.coach.settingsToneConcise")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Verbosity */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-verbosity"
className="text-xs font-medium"
>
{t("insights.coach.settingsVerbosityLabel")}
</Label>
<Select
value={draft.verbosity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
verbosity: value as CoachVerbosity,
}))
}
>
<SelectTrigger
id="coach-prefs-verbosity"
data-slot="coach-prefs-verbosity"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="brief">
{t("insights.coach.settingsVerbosityBrief")}
</SelectItem>
<SelectItem value="default">
{t("insights.coach.settingsVerbosityDefault")}
</SelectItem>
<SelectItem value="detailed">
{t("insights.coach.settingsVerbosityDetailed")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* v1.4.25 W5 — default analysis window. Sets the snapshot
scope window the Coach reads when the client doesn't
supply a per-conversation override. The drawer header
carries a small pill that overrides the picked default
for the current chat only. */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-default-window"
className="text-xs font-medium"
>
{t("insights.coach.settingsDefaultWindowLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsDefaultWindowHint")}
</p>
<Select
value={draft.defaultWindow}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultWindow: value as CoachDefaultWindow,
}))
}
>
<SelectTrigger
id="coach-prefs-default-window"
data-slot="coach-prefs-default-window"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEFAULT_WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w}>
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* v1.7.0 — clustered, opt-in data sources. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsClustersLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsClustersHint")}
</p>
<ul
data-slot="coach-prefs-cluster-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CLUSTER_OPTIONS.map((cluster) => {
const id = `coach-prefs-cluster-${cluster}`;
const checked = enabledClusters.has(cluster);
return (
<li
key={cluster}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex min-w-0 flex-col">
<Label
htmlFor={id}
className="cursor-pointer text-xs font-medium"
>
{t(`insights.coach.cluster.${cluster}.label`)}
</Label>
<span className="text-muted-foreground text-[11px] leading-snug">
{t(`insights.coach.cluster.${cluster}.hint`)}
</span>
</div>
<Switch
id={id}
data-slot={`coach-prefs-cluster-${cluster}`}
checked={checked}
onCheckedChange={(next) => toggleCluster(cluster, next)}
/>
</li>
);
})}
</ul>
</div>
{/* Exclude metrics — advanced fine-tune. Subtracts a single
metric inside an otherwise-enabled cluster. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsExcludeLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsExcludeHint")}
</p>
<ul
data-slot="coach-prefs-exclude-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{EXCLUDE_OPTIONS.map((metric) => {
const id = `coach-prefs-exclude-${metric}`;
const checked = draft.excludeMetrics.includes(metric);
return (
<li
key={metric}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<Label htmlFor={id} className="cursor-pointer text-xs">
{t(`insights.coach.metric.${metric}`)}
</Label>
<Switch
id={id}
data-slot={`coach-prefs-exclude-${metric}`}
checked={checked}
onCheckedChange={(next) => toggleExclude(metric, next)}
/>
</li>
);
})}
</ul>
</div>
{/* v1.4.36 W3 T2 — optional context blocks. Mirrors the
EXCLUDE_OPTIONS list mechanically (same on/off switch
pattern, same toggleExclude handler) but groups the
profile / medication context toggles in their own
section so the intent reads distinctly. Empty blocks
are dropped at the snapshot/feature layer when the
toggle is on. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsContextLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsContextHint")}
</p>
<ul
data-slot="coach-prefs-context-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CONTEXT_OPTIONS.map((metric) => {
const id = `coach-prefs-context-${metric}`;
const checked = draft.excludeMetrics.includes(metric);
return (
<li
key={metric}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<Label htmlFor={id} className="cursor-pointer text-xs">
{t(`insights.coach.metric.${metric}`)}
</Label>
<Switch
id={id}
data-slot={`coach-prefs-context-${metric}`}
checked={checked}
onCheckedChange={(next) => toggleExclude(metric, next)}
/>
</li>
);
})}
</ul>
</div>
{/* v1.4.27 F14 — the `showEvidenceByDefault` switch was
retired. The evidence disclosure is now always closed
by default and the user expands by click; surfacing
raw values automatically created an UX trap. The
persisted `coachPrefs.showEvidenceByDefault` field
stays in the schema for backward compatibility, but no
UI reads or writes it. */}
</div>
)}
<div className="border-border/70 flex items-center justify-end gap-2 border-t p-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
data-slot="coach-prefs-cancel"
>
{t("insights.coach.settingsCancel")}
</Button>
<Button
type="button"
size="sm"
onClick={() => save.mutate(draft)}
disabled={save.isPending || isLoading}
data-slot="coach-prefs-save"
>
{save.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
{t("insights.coach.settingsSave")}
</Button>
</div>
</SheetContent>
</Sheet>
);
}
@@ -78,7 +78,7 @@ export function HistoryRail({
(the rail still has to stand alone on desktop). */}
<h3
data-slot="coach-history-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
className="text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase"
>
<MessagesSquare
className="text-muted-foreground size-3.5"
@@ -444,7 +444,6 @@ function ChatBubble({
"border-border/60 bg-muted/40 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed whitespace-pre-wrap",
inProgress && "animate-pulse motion-reduce:animate-none",
)}
>
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from
@@ -452,12 +451,19 @@ function ChatBubble({
raw `content` is the streamed Coach reply (or the
persisted twin after `done` fires); both paths are AI-
authored so they share the same leak surface as the
insight prose elsewhere. */}
{content
? stripChartTokens(content)
: inProgress
? t("insights.coach.thinking")
: ""}
insight prose elsewhere.
v1.16.1 — before the first token lands, the bubble shows
a classic typing indicator (three pulsing dots) instead
of the old whole-bubble pulse + "Thinking…" prose. The
prose stays as the screen-reader announcement. */}
{content ? (
stripChartTokens(content)
) : inProgress ? (
<TypingDots label={t("insights.coach.thinking")} />
) : (
""
)}
</div>
{safeError && (
<p className="text-warning/90 text-xs">{safeError}</p>
@@ -572,6 +578,33 @@ function ChatBubble({
);
}
/**
* v1.16.1 — classic chat typing indicator: three dots pulsing in
* sequence inside the assistant bubble, shown only between submit and
* the first streamed token. Uses the stock `animate-pulse` keyframe
* with staggered delays so no custom keyframe is introduced;
* `motion-reduce` freezes the dots and the `label` stays as the
* screen-reader text either way.
*/
function TypingDots({ label }: { label: string }) {
return (
<span
data-slot="coach-typing-indicator"
className="inline-flex items-center gap-1 py-1"
>
<span className="sr-only">{label}</span>
{[0, 1, 2].map((i) => (
<span
key={i}
aria-hidden="true"
className="bg-muted-foreground/70 size-1.5 animate-pulse rounded-full motion-reduce:animate-none"
style={{ animationDelay: `${i * 250}ms` }}
/>
))}
</span>
);
}
interface CoachMessageFeedbackProps {
messageId: string;
}
@@ -68,13 +68,15 @@ export function MobileRailTray({
<div className="h-full min-h-0 overflow-y-auto">{historyRail}</div>
</SheetContent>
</Sheet>
{/* Sources tray — slides in from the right edge. `xl:hidden`
because the sources rail is inline on xl+. */}
{/* Sources tray — slides in from the right edge. v1.16.1 — the
rail no longer has an inline xl+ column; the tray is the one
sources surface on every viewport (hidden by default, opened
via the toggle in the rail-tray strip). */}
<Sheet open={sourcesOpen} onOpenChange={onSourcesOpenChange}>
<SheetContent
side="right"
data-slot="coach-drawer-sources-tray"
className="w-[88vw] max-w-[320px] p-0 xl:hidden"
className="w-[88vw] max-w-[320px] p-0"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
@@ -95,11 +95,20 @@ export function SourcesRail({ className }: SourcesRailProps) {
return (
<div
data-slot="coach-sources-rail"
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
// v1.16.1 — `min-w-0` + `overflow-x-hidden` pin the rail to its
// column width: the switch rows used to push past the 280/320 px
// container and paint a horizontal scrollbar under the rail.
className={cn(
"flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-x-hidden p-3",
className,
)}
>
<h3
data-slot="coach-sources-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
// No horizontal inset — the heading sits flush with the row
// boxes below it (headings and their content share one left
// edge across the Coach surfaces).
className="text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase"
>
<Eye className="text-muted-foreground size-3.5" aria-hidden="true" />
{t("insights.coach.sourcesTitle")}
@@ -111,7 +120,7 @@ export function SourcesRail({ className }: SourcesRailProps) {
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
<label
htmlFor="coach-sources-window-select"
className="text-muted-foreground px-1 text-[10px] font-medium tracking-wide uppercase"
className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase"
>
{t("insights.coach.windowLabel")}
</label>
@@ -60,10 +60,9 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const advisorEnabled =
isAuthenticated && flags.enabled && flags.briefing;
// v1.12.6 — routes that carry their own purpose-built disclaimer must NOT
// also show the generic footer below (otherwise two disclaimers stack).
// The Coach full page renders its own `composerDisclaimer` under the
// composer, so its segment is excluded here. Every other segment — the
// v1.12.6 — the Coach segment is excluded from the generic disclaimer
// footer (v1.16.1 dropped the in-chat disclaimer line entirely; the
// chat surface stays footer-free by design). Every other segment — the
// overview (null segment), the metric sub-pages, mood, medications, the
// raw values table — keeps the single generic footer.
const segment = useSelectedLayoutSegment();
+105 -51
View File
@@ -1,5 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -8,88 +11,139 @@ import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { queryKeys } from "@/lib/query-keys";
import { useChartTooltipActive } from "./use-chart-tooltip-active";
/**
* Layout-level mobile Coach launch FAB.
* v1.16.1 — the layout FAB is no longer a permanent launcher. It now
* renders ONLY while an unseen Coach-initiated nudge exists (the
* proactive `COACH_NUDGE` dispatch, surfaced by
* `/api/insights/coach/nudge-status`): a floating circle at the bottom
* right that opens the full-page Coach chat. It disappears once the
* nudge counts as read — the user sent a Coach message after the
* nudge (server-derived), or opened the Coach on this device (local
* seen stamp keyed by the nudge timestamp).
*
* v1.4.28 R3c — promoted out of `<CoachLaunchButton>` (which previously
* rendered both the FAB and the inline pill from a single component;
* each sub-page mount therefore painted a duplicate FAB into the DOM
* before `fixed` positioning collapsed them visually). Mounting the
* FAB once at the layout level keeps the affordance reachable from
* every routed Insights surface while leaving the a11y tree with a
* single button.
* The previous always-on mobile FAB drew attention to nothing; the
* inline "Ask Coach" pill in each page's action row stays the everyday
* entry point.
*
* Hidden on `lg+` viewports — desktop users reach the Coach via the
* inline `<CoachLaunchButton>` pill that each page mounts in its
* action row.
*
* v1.4.33 (F15) — the FAB sits at `bottom-right` and used to overlay
* Recharts tooltips on the bottom-right of any insight chart, so a
* mobile user tapping a data point near the chart's lower-right
* couldn't read the bubble. The button now auto-hides while a chart
* tooltip is active (the wrapper's `visibility: visible` flips us
* to fade-out + pointer-events-none). When the user releases / scrolls
* away the tooltip clears and the FAB fades back in.
* v1.4.33 (F15) — the bubble keeps the chart-tooltip auto-hide so it
* never overlays a Recharts tooltip on the lower right.
*/
const NUDGE_SEEN_STORAGE_KEY = "healthlog-coach-nudge-seen";
export interface CoachNudgeStatus {
nudgedAt: string | null;
unread: boolean;
}
/**
* Pure unread derivation — server signal AND-ed with the local
* seen-stamp. Exported so the unit test pins the contract without a
* QueryClient round-trip.
*/
export function isNudgeUnread(
status: CoachNudgeStatus | undefined,
seenStamp: string | null,
): boolean {
if (!status?.unread || status.nudgedAt === null) return false;
return seenStamp !== status.nudgedAt;
}
export function LayoutCoachFab() {
const { t } = useTranslations();
const router = useRouter();
const pathname = usePathname();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const tooltipActive = useChartTooltipActive();
if (!launch) return null;
// v1.4.31 — operator can hide the Coach surface app-wide. The
// FAB returns null when the master OR the coach sub-flag is off.
if (!flags.coach) return null;
// v1.4.47 W3 — per-user opt-out beats the operator's "on" default.
// A privacy-leaning user who toggles "Hide Coach" in Settings → Insights
// gets the FAB short-circuited here, identical to the global-off
// branch above. The hook defaults to `false` in the absence of a
// QueryClient (legacy SSR-only snapshot tests).
if (disableCoach) return null;
const accessibleLabel = t("insights.heroActionAskCoach");
// Local "seen on this device" stamp — the nudge timestamp the user
// last dismissed by opening the chat. Lazy initialiser per the
// `react-hooks/set-state-in-effect` rule.
const [seenStamp, setSeenStamp] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(NUDGE_SEEN_STORAGE_KEY);
} catch {
return null;
}
});
const coachAvailable = !!launch && flags.coach && !disableCoach;
const onCoachPage = pathname?.startsWith("/insights/coach") ?? false;
const { data: status } = useQuery({
queryKey: queryKeys.coachNudgeStatus(),
queryFn: async (): Promise<CoachNudgeStatus> => {
const res = await fetch("/api/insights/coach/nudge-status");
if (!res.ok) throw new Error("coach-nudge-status.failed");
return (await res.json()).data as CoachNudgeStatus;
},
enabled: coachAvailable,
staleTime: 5 * 60 * 1000,
});
const nudgedAt = status?.nudgedAt ?? null;
const unread = isNudgeUnread(status, seenStamp);
// Visiting the Coach page itself counts as reading the nudge on this
// device — persist the stamp so the bubble stays gone after leaving.
useEffect(() => {
if (!onCoachPage || nudgedAt === null) return;
try {
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
} catch {
// Storage unavailable (private mode) — the server-side read
// signal still clears the bubble once the user sends a message.
}
}, [onCoachPage, nudgedAt]);
if (!coachAvailable) return null;
if (onCoachPage || !unread) return null;
const accessibleLabel = t("insights.coach.nudgeBubbleLabel");
const handleOpen = () => {
if (nudgedAt !== null) {
setSeenStamp(nudgedAt);
try {
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
} catch {
// Best effort — see above.
}
}
router.push("/insights/coach");
};
return (
<Button
type="button"
size="lg"
data-slot="coach-launch-fab"
size="icon"
data-slot="coach-nudge-bubble"
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
onClick={() => launch.askCoach(null)}
onClick={handleOpen}
aria-label={accessibleLabel}
title={accessibleLabel}
// v1.4.33 (F15) — `aria-hidden` + `tabIndex={-1}` while a chart
// tooltip is painted so screen-reader users + keyboard users
// don't trip over a button that's visually faded; pointer-events
// are gone, so a tap can't land on the invisible FAB either.
aria-hidden={tooltipActive ? true : undefined}
tabIndex={tooltipActive ? -1 : undefined}
className={cn(
// Sit above the 64 px bottom-nav + the iPhone home-indicator
// safe-area inset. `calc(env(safe-area-inset-bottom,0)+5rem)`
// keeps the FAB clear of the nav on notched iPhones (where the
// inset is ~34 px) without forcing extra padding into the
// layout grid.
"fixed right-4 z-40 h-12 rounded-full px-4 shadow-lg",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
// safe-area inset on mobile; plain bottom offset on desktop.
"fixed right-4 z-40 size-12 rounded-full shadow-lg",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] lg:bottom-6",
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
"lg:hidden",
// v1.4.33 (F15) — fade out while a Recharts tooltip is open.
// 150 ms transition matches the rest of the Coach drawer's
// fade timing; `motion-reduce:transition-none` honours user
// preference. `pointer-events-none` keeps the faded FAB from
// intercepting taps that should reach the tooltip behind.
// Fade out while a Recharts tooltip is open (see header note).
"transition-opacity duration-150 motion-reduce:transition-none",
tooltipActive && "pointer-events-none opacity-0",
)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
<Sparkles className="size-5" aria-hidden="true" />
</Button>
);
}
@@ -68,7 +68,7 @@ const ADVISOR_TIMEOUT_MS = 8_000;
* payload, the mutation only invalidated (re-reading the OLD cache), yet
* the server had often already written the fresh briefing. Give the force
* branch the same 45 s budget the out-of-band warm job uses
* (`FORCE_WARM_COMPREHENSIVE_TIMEOUT_MS`) so a slow success is kept, not
* (`COMPREHENSIVE_WARM_TIMEOUT_MS`) so a slow success is kept, not
* dropped. The READ path stays fast at 8 s — only the explicit user tap
* may wait. Per `.planning/v1.15.18-daily-briefing-audit.md` Fix 1b.
*/
@@ -72,15 +72,18 @@ function render(locale: "en" | "de" = "en") {
}
describe("MeasurementList — filter bar + multi-select chrome", () => {
it("renders the source filter control", () => {
// v1.16.1 — the per-page Select/date-input row migrated to the unified
// `<FilterBar>` pill rail (date range · type · source pills + count).
// The date inputs now live inside the pill's popover (closed in SSR),
// so the guard pins the labelled pill triggers instead.
it("renders the filter rail with type, source and date-range pills", () => {
const html = render("en");
expect(html).toContain("Filter by source");
});
it("renders the date-range labels wired to from/to", () => {
const html = render("en");
expect(html).toContain('for="measurements-from"');
expect(html).toContain('for="measurements-to"');
expect(html).toContain('data-slot="filter-bar"');
expect(html).toContain('aria-label="Type"');
expect(html).toContain('aria-label="Source"');
expect(html).toContain('aria-label="Date range"');
const pills = html.match(/data-slot="filter-bar-pill"/g);
expect(pills?.length).toBe(3);
});
it("renders labelled selection checkboxes (per-row + select-all)", () => {
@@ -95,7 +98,8 @@ describe("MeasurementList — filter bar + multi-select chrome", () => {
it("localises the new chrome in German", () => {
const html = render("de");
expect(html).toContain("Nach Quelle filtern");
expect(html).toContain('aria-label="Quelle"');
expect(html).toContain('aria-label="Zeitraum"');
expect(html).toContain('aria-label="Alle auf dieser Seite auswählen"');
});
});
@@ -61,12 +61,15 @@ function render(lockedType?: string) {
}
describe("MeasurementList — lockedType", () => {
it("hides the type selector when locked to a metric", () => {
it("hides the filter rail when locked to a metric", () => {
const locked = render("WEIGHT");
const unlocked = render();
// The unlocked list paints the filter trigger; the locked one does
// not (the metric is fixed by the route).
expect(unlocked).toContain("Filter by type");
// v1.16.1 — the unlocked list paints the unified `<FilterBar>` pill
// rail (type pill included); the locked one suppresses the whole
// rail (the metric is fixed by the route) and keeps only the count.
expect(unlocked).toContain('data-slot="filter-bar"');
expect(unlocked).toContain('aria-label="Type"');
expect(locked).not.toContain('data-slot="filter-bar"');
expect(locked).not.toContain('role="combobox"');
});
+74 -118
View File
@@ -6,12 +6,10 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
FilterBar,
FilterBarDateRange,
FilterBarSelect,
} from "@/components/ui/filter-bar";
import {
AlertDialog,
AlertDialogAction,
@@ -65,7 +63,7 @@ import {
MEASUREMENT_NOTES_MAX_LENGTH,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { DateInput, DateTimeInput } from "@/components/ui/date-input";
import { DateTimeInput } from "@/components/ui/date-input";
import { Checkbox } from "@/components/ui/checkbox";
import {
SortableHead,
@@ -643,117 +641,75 @@ export function MeasurementList({
return (
<>
<div className="space-y-4">
{/* v1.4.27 MB7 / CF-46 — the filter row stacks the SelectTrigger
and the count caption vertically on `<sm` so the trigger
fills the column and the count drops to a separate line.
Pre-fix the trigger had a fixed `w-48` (192 px) which on
Pixel 5 (375 px content width) left only ~120 px for the
count, which wrapped to 2 lines. */}
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
{lockedType ? (
// v1.8.5 — the insights "all readings" subpage knows the
// metric from the route, so the global type selector is
// suppressed; the count caption still anchors the column.
<span className="sr-only">{t("measurements.filterByType")}</span>
) : (
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("measurements.filterByType")}
</Label>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger
className="w-full sm:w-44"
aria-label={t("measurements.filterByType")}
>
<SelectValue placeholder={t("measurements.allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("measurements.allTypes")}
</SelectItem>
{Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => (
<SelectItem key={val} value={val}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* v1.15.13 — source filter + date range. Suppressed on the
locked-type insights subpage, which wants a focused list
scoped to the route's single metric (matches the type-
selector suppression above). */}
{!lockedType && (
<>
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("measurements.filterBySource")}
</Label>
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger
className="w-full sm:w-40"
aria-label={t("measurements.filterBySource")}
>
<SelectValue placeholder={t("dataList.allSources")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("dataList.allSources")}
</SelectItem>
{MEASUREMENT_SOURCE_OPTIONS.map((src) => (
<SelectItem key={src} value={src}>
{formatMeasurementSource(src, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="measurements-from"
className="text-muted-foreground text-xs"
>
{t("dataList.dateFrom")}
</Label>
<DateInput
id="measurements-from"
className="w-full sm:w-40"
value={fromDay}
max={toDay || undefined}
onChange={(e) => setFromDay(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="measurements-to"
className="text-muted-foreground text-xs"
>
{t("dataList.dateTo")}
</Label>
<DateInput
id="measurements-to"
className="w-full sm:w-40"
value={toDay}
min={fromDay || undefined}
onChange={(e) => setToDay(e.target.value)}
/>
</div>
</>
)}
</div>
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("measurements.measurementCount", {
count: fmt.integer(data.meta.total),
})}
</span>
)}
</div>
{/* v1.16.1 — unified filter rail (`<FilterBar>`): compact pill
triggers in the canonical order date range · type · source,
active filters fold into their pill as removable chips, plus
a reset action and the result count. Filter state and query
keys are unchanged — this swap is presentation-only.
On the locked-type insights subpage the rail is suppressed
entirely (the metric is fixed by the route); only the count
line remains. */}
{lockedType ? (
data?.meta?.total !== undefined && (
<div className="flex items-center justify-end">
<span className="text-muted-foreground text-sm tabular-nums">
{t("measurements.measurementCount", {
count: fmt.integer(data.meta.total),
})}
</span>
</div>
)
) : (
<FilterBar
isFiltered={
typeFilter !== "ALL" ||
sourceFilter !== "ALL" ||
fromDay !== "" ||
toDay !== ""
}
onReset={() => {
setTypeFilter("ALL");
setSourceFilter("ALL");
setFromDay("");
setToDay("");
}}
count={
data?.meta?.total !== undefined
? t("measurements.measurementCount", {
count: fmt.integer(data.meta.total),
})
: undefined
}
>
<FilterBarDateRange
label={t("dataList.dateRange")}
from={fromDay}
to={toDay}
onFromChange={setFromDay}
onToChange={setToDay}
idPrefix="measurements"
/>
<FilterBarSelect
label={t("dataList.typeLabel")}
value={typeFilter}
onValueChange={setTypeFilter}
allLabel={t("measurements.allTypes")}
options={Object.entries(TYPE_LABEL_KEYS).map(
([val, labelKey]) => ({ value: val, label: t(labelKey) }),
)}
/>
<FilterBarSelect
label={t("dataList.sourceLabel")}
value={sourceFilter}
onValueChange={setSourceFilter}
allLabel={t("dataList.allSources")}
options={MEASUREMENT_SOURCE_OPTIONS.map((src) => ({
value: src,
label: formatMeasurementSource(src, t),
}))}
/>
</FilterBar>
)}
{isLoading ? (
<div className="flex h-32 items-center justify-center">
@@ -61,6 +61,26 @@ describe("resolveDisplayedSlotInstant", () => {
expect(slot!.toISOString()).toBe("2026-06-05T17:00:00.000Z");
});
it("anchors on the matched band's timeOfDay over the stale legacy windowStart (v1.16.1)", () => {
// The production regression fixture: degenerate 07:00 / 07:00 window
// left behind while the dose times moved to 09:00 / 21:00. The matched
// band carries timeOfDay 09:00 — the recorded slot must be 09:00, not
// the stale window's 07:00.
const inBandNow = new Date("2026-06-05T07:30:00.000Z"); // 09:30 Berlin
const slot = resolveDisplayedSlotInstant({
currentWindowStatus: {
status: "in_window",
schedule: { windowStart: "07:00", windowEnd: "07:00" },
window: { timeOfDay: "09:00" },
},
nextDueAt: null,
now: inBandNow,
timeZone: "Europe/Berlin",
});
// 09:00 Berlin on 2026-06-05 is 07:00 UTC (CEST = +2).
expect(slot!.toISOString()).toBe("2026-06-05T07:00:00.000Z");
});
it("falls back to the server's next-due instant when no window is currently actionable", () => {
const nextDueAt = "2026-06-06T05:00:00.000Z";
const slot = resolveDisplayedSlotInstant({
@@ -33,10 +33,17 @@ export interface ResolveDisplayedSlotInstantInput {
* The card's current-window status. When its `status` is non-null the
* card is surfacing that schedule's window today (in_window / late /
* very_late), so the recorded dose is that window's slot today.
*
* v1.16.1 — `window` carries the matched dose band's anchor
* (`timeOfDay`, a `timesOfDay` entry). It is the canonical slot time;
* the schedule's `windowStart` stays only as the legacy fallback for
* rows without `timesOfDay`, so a stale / degenerate window can no
* longer mis-anchor the recorded dose.
*/
currentWindowStatus: {
status: "in_window" | "late" | "very_late" | null;
schedule: DisplayedSlotSchedule | null;
window?: { timeOfDay: string } | null;
};
/**
* The server-computed next-due instant (`computeNextDueAt`, the canonical
@@ -130,10 +137,15 @@ export function resolveDisplayedSlotInstant(
const { currentWindowStatus, nextDueAt, now, timeZone = "Europe/Berlin" } =
input;
// The card surfaces the current/overdue window — record that window's
// slot on today's calendar day.
// The card surfaces the current/overdue dose band — record that band's
// slot on today's calendar day. The matched band's `timeOfDay` is the
// canonical dose anchor; `windowStart` is only the legacy fallback for
// schedules without `timesOfDay`.
if (currentWindowStatus.status && currentWindowStatus.schedule) {
const hm = parseHm(currentWindowStatus.schedule.windowStart);
const anchor =
currentWindowStatus.window?.timeOfDay ??
currentWindowStatus.schedule.windowStart;
const hm = parseHm(anchor);
if (hm) return localHmTodayAsUtc(now, timeZone, hm.hour, hm.minute);
}
@@ -151,6 +151,7 @@ const TAB_SLUGS = [
"bestand",
"verlauf",
"injektion",
"api",
"erweitert",
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
@@ -337,31 +338,19 @@ export function MedicationDetailTabs({
</Link>
</Button>
{/* HERO — always-visible read-only summary + the structural-edit
jump into the wizard. Everyday levers live in the tabs below. */}
<div className="flex items-start justify-between gap-3">
<MedicationDetailSummary
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="medication-detail-full-edit"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span className="hidden sm:inline">
{t("medications.detail.shell.fullEdit")}
</span>
</Button>
</div>
{/* HERO — always-visible read-only summary. The structural edit
(wizard) moved out of the header: it lives as a row under
Erweitert → Lebenszyklus (the header button read as "edit this
page" and misled). `?edit=1` still opens the wizard directly. */}
<MedicationDetailSummary
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
startsOn={medication.startsOn}
/>
<Tabs
value={activeTab}
@@ -528,13 +517,28 @@ export function MedicationDetailTabs({
doseWindows: s.doseWindows,
}))}
/>
{/* Quiet cross-link: WHICH channels deliver the reminder
(push / Telegram / ntfy, quiet hours) is configured
globally, not per medication. */}
<p className="text-muted-foreground text-xs">
<Link
href="/settings/notifications"
className="focus-visible:ring-ring rounded-sm underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
data-slot="zeitplan-manage-channels-link"
>
{t("medications.detail.zeitplan.manageChannels")}
</Link>
</p>
</div>
</MedicationDetailSection>
</TabsContent>
{/* BESTAND — inventory readout for all meds. */}
{/* BESTAND — inventory readout + register / correct flows. */}
<TabsContent value="bestand" className="space-y-4 pt-2">
<InventorySection medicationId={id} />
<InventorySection
medicationId={id}
dosesPerUnit={medication.dosesPerUnit}
/>
</TabsContent>
{/* VERLAUF — the dose-history ledger: every expected slot with its
@@ -586,36 +590,14 @@ export function MedicationDetailTabs({
</TabsContent>
)}
{/* ERWEITERT — the dissolved advanced-settings sheet, now grouped
un-stacked: Lifecycle → Externe API → Daten → Gefahrenzone. */}
<TabsContent value="erweitert" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.erweitert.group.lifecycle")}
dataSlot="erweitert-group-lifecycle"
>
<div className="py-3">
<LifecycleManageBody
medicationId={id}
medicationName={medication.name}
active={medication.active}
/>
</div>
{isGlp1 && (
<div className="py-3">
<PhasesRow
medicationId={id}
treatmentClass={medication.treatmentClass}
startsOn={medication.startsOn}
endsOn={medication.endsOn}
onRequestPhaseSheet={() => setPhaseSheetOpen(true)}
/>
</div>
)}
</SettingsGroup>
{/* API — the external-ingest surface in its own tab: per-medication
tokens + the drug-coding identifiers external systems key on.
Pulled out of Erweitert so the destructive zone and the token
management stop sharing one surface. */}
<TabsContent value="api" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.erweitert.group.externalApi")}
dataSlot="erweitert-group-external-api"
dataSlot="api-group-external-api"
>
<div className="py-3">
<ApiTokensRow
@@ -631,6 +613,61 @@ export function MedicationDetailTabs({
/>
</div>
</SettingsGroup>
</TabsContent>
{/* ERWEITERT — Lifecycle → Daten → Gefahrenzone. The external-API
group moved to its own API tab. */}
<TabsContent value="erweitert" className="space-y-4 pt-2">
<SettingsGroup
label={t("medications.detail.erweitert.group.lifecycle")}
dataSlot="erweitert-group-lifecycle"
>
<div className="py-3">
<LifecycleManageBody
medicationId={id}
medicationName={medication.name}
active={medication.active}
/>
</div>
{/* The structural editor (the create/edit wizard) — name,
class, cadence kind, schedules. Replaces the former hero
"Vollständig bearbeiten" button, which read as "edit this
page". */}
<div
className="flex items-center justify-between gap-3 py-3"
data-slot="erweitert-editor-row"
>
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.erweitert.editor.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.erweitert.editor.helper")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="medication-detail-full-edit"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.erweitert.editor.button")}
</Button>
</div>
{isGlp1 && (
<div className="py-3">
<PhasesRow
medicationId={id}
treatmentClass={medication.treatmentClass}
startsOn={medication.startsOn}
endsOn={medication.endsOn}
onRequestPhaseSheet={() => setPhaseSheetOpen(true)}
/>
</div>
)}
</SettingsGroup>
<SettingsGroup
label={t("medications.detail.erweitert.group.data")}
@@ -763,18 +800,18 @@ function ComplianceGatedDangerZone({
const { t } = useTranslations();
return (
// v1.16.1 — same neutral card chrome as every other group; only the
// action buttons stay destructive. A red-washed card read as a
// constant alarm on a page the user visits routinely.
<SettingsGroup
label={t("medications.detail.erweitert.group.danger")}
dataSlot="erweitert-group-danger"
className="border-destructive/40 bg-destructive/5"
>
<div className="py-3">
<DangerZoneBody
medicationId={medicationId}
medicationName={medicationName}
intakeCount={intakeCount ?? 0}
/>
</div>
<DangerZoneBody
medicationId={medicationId}
medicationName={medicationName}
intakeCount={intakeCount ?? 0}
/>
</SettingsGroup>
);
}
@@ -52,6 +52,13 @@ interface ScheduleLite {
label: string | null;
dose: string | null;
daysOfWeek: string | null;
/**
* v1.16.1 — first-class dose times + explicit per-dose bands; the
* window-status helper derives its bands from these (canonical) and
* only falls back to the legacy window when they are absent.
*/
timesOfDay?: string[];
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
}
interface DoseChangeLite {
@@ -402,8 +409,10 @@ export function Glp1MedicationCard({
currentWindowStatus.status
? {
status: currentWindowStatus.status,
windowStart: currentWindowStatus.schedule!.windowStart,
windowEnd: currentWindowStatus.schedule!.windowEnd,
// v1.16.1 — the pill shows the MATCHED dose band, not the
// legacy schedule window (which may be stale / degenerate).
windowStart: currentWindowStatus.window!.start,
windowEnd: currentWindowStatus.window!.end,
}
: null
}
@@ -9,9 +9,10 @@
* affordance instead of a cluster. The card body itself navigates to the
* detail page (the former chevron target); this menu owns the edit and
* history actions. v1.15.20 retires the "Advanced settings" item — the
* detail page's Erweitert tab is reachable through the card body, and
* Edit now opens the medication wizard directly instead of landing on
* the Zeitplan tab.
* detail page's Erweitert tab is reachable through the card body.
* v1.16.1 — Edit navigates to the detail page's Übersicht tab (the same
* target as the name tap); the structural wizard lives under the detail
* page's Erweitert → Lebenszyklus row.
*
* The optional `onLogSideEffect` slot exists for the GLP-1 card to fold a
* "Log side effect" item into the SAME menu, but it is NOT wired on the
+21 -3
View File
@@ -34,6 +34,14 @@ interface Schedule {
label: string | null;
dose: string | null;
daysOfWeek: string | null;
/**
* v1.16.1 — first-class dose times + explicit per-dose bands. The
* window-status helper derives its bands from these (canonical) and
* only falls back to `windowStart` / `windowEnd` when they are absent,
* so a stale window can no longer drive the pill or the recorded slot.
*/
timesOfDay?: string[];
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
}
interface Medication {
@@ -318,10 +326,18 @@ export function MedicationCard({
}
}
// v1.16.1 — the TIME is the canonical next-due instant (the
// recurrence engine's timesOfDay-anchored slot), not the legacy
// schedule window. A stale `windowStart` / `windowEnd` pair
// (e.g. 07:00 / 07:00 while the times moved to 09:00 / 21:00)
// used to print "today, 07:00" here. The window range stays as
// the fallback for rows the server has no next-due for.
return (
<>
{dayLabel && `${dayLabel}, `}
{formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
{nextAt
? formatTime(new Date(nextAt).toISOString())
: formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
{s.label && (
<span className="hidden sm:inline"> ({s.label})</span>
)}
@@ -350,8 +366,10 @@ export function MedicationCard({
currentWindowStatus.status
? {
status: currentWindowStatus.status,
windowStart: currentWindowStatus.schedule!.windowStart,
windowEnd: currentWindowStatus.schedule!.windowEnd,
// v1.16.1 — the pill shows the MATCHED dose band, not the
// legacy schedule window (which may be stale / degenerate).
windowStart: currentWindowStatus.window!.start,
windowEnd: currentWindowStatus.window!.end,
}
: null
}
@@ -43,7 +43,6 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
@@ -233,8 +232,10 @@ export function LifecycleManageBody({
/**
* v1.7.0 — irreversible danger zone: purge intake history + delete the
* medication. Wrapped in a `border-destructive/40` card to keep the
* visual warning convention.
* medication. v1.16.1 — the body is bare rows (the caller's
* `SettingsGroup` supplies the neutral card chrome and dividers); the
* destructive signal lives ONLY in the red action buttons + the confirm
* dialogs, not in a tinted surface.
*/
export function DangerZoneBody({
medicationId,
@@ -317,8 +318,8 @@ export function DangerZoneBody({
}
return (
<Card
className="border-destructive/40 bg-destructive/5 divide-destructive/20 gap-0 divide-y px-4 py-1"
<div
className="divide-border/60 divide-y"
data-slot="destructive-zone-card-b"
>
{/* Tier 3a — Verlauf löschen. Matched pair with the delete row
@@ -440,7 +441,7 @@ export function DangerZoneBody({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
</div>
);
}
@@ -3,23 +3,42 @@
/**
* v1.15.18 — Bestand (supply) tab body.
*
* Read-only inventory readout for the medication detail page's Supply
* tab. Reads `GET /api/medications/[id]/inventory` (the same per-item
* list the pen/vial CRUD route serves) and renders a calm summary — the
* doses remaining across every non-terminal item plus a per-item list
* with its state badge. Shown for ALL medications (pill packs count too);
* the richer pen math is implicit in the per-item doses figures.
* v1.16.1 — the tab grows its write affordances:
*
* No write affordance here yet — registering a pen still rides the
* dedicated inventory route; the tab is the read surface the spec asks
* for. An empty inventory shows a quiet empty state, not a spinner.
* - REGISTER: a "+" flow (header button + the empty-state CTA) that
* records a new pen / pack via `POST /api/medications/[id]/inventory`
* — quantity in doses/units, optional printed expiry. The quantity
* prefills from the medication's `dosesPerUnit` when configured.
* - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with
* `dosesRemaining`) for stock corrections and manual withdrawals —
* 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).
*
* Reads stay on `GET /api/medications/[id]/inventory` (the same
* per-item list the pen/vial CRUD route serves): a calm summary — the
* doses remaining across every non-terminal item — plus a per-item list
* with its state badge. Shown for ALL medications (pill packs count
* too); the richer pen math is implicit in the per-item doses figures.
*/
import { useQuery } from "@tanstack/react-query";
import { Loader2, PackageOpen } from "lucide-react";
import { useState, type FormEvent } from "react";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { SettingsGroup } from "@/components/medications/settings-group";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -45,8 +64,17 @@ const STATE_BADGE: Record<InventoryState, "secondary" | "outline" | "destructive
USED_UP: "outline",
};
export function InventorySection({ medicationId }: { medicationId: string }) {
export function InventorySection({
medicationId,
dosesPerUnit,
}: {
medicationId: string;
/** Prefills the register flow's quantity when the medication tracks it. */
dosesPerUnit?: number | null;
}) {
const { t } = useTranslations();
const [addOpen, setAddOpen] = useState(false);
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
const { data, isLoading } = useQuery<InventoryResponse>({
queryKey: queryKeys.medicationInventory(medicationId),
@@ -80,13 +108,42 @@ export function InventorySection({ medicationId }: { medicationId: string }) {
const remaining = live.reduce((sum, i) => sum + i.dosesRemaining, 0);
const total = live.reduce((sum, i) => sum + i.dosesTotal, 0);
const dialogs = (
<>
{addOpen && (
<AddInventoryDialog
medicationId={medicationId}
defaultDosesTotal={dosesPerUnit ?? null}
onClose={() => setAddOpen(false)}
/>
)}
{adjustItem && (
<AdjustInventoryDialog
medicationId={medicationId}
item={adjustItem}
onClose={() => setAdjustItem(null)}
/>
)}
</>
);
if (items.length === 0) {
return (
<EmptyState
icon={<PackageOpen className="size-6" />}
title={t("medications.detail.bestand.empty")}
description={t("medications.detail.bestand.emptyHelper")}
/>
<>
<EmptyState
icon={<PackageOpen className="size-6" />}
title={t("medications.detail.bestand.empty")}
description={t("medications.detail.bestand.emptyHelper")}
ctaSize="lg"
action={
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.bestand.emptyCta")}
</Button>
}
/>
{dialogs}
</>
);
}
@@ -96,10 +153,20 @@ export function InventorySection({ medicationId }: { medicationId: string }) {
label={t("medications.detail.bestand.title")}
dataSlot="inventory-summary-group"
>
<div className="py-3">
<div className="flex items-center justify-between gap-3 py-3">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.bestand.summary", { remaining, total })}
</p>
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="inventory-add-button"
>
<Plus aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.bestand.addButton")}
</Button>
</div>
</SettingsGroup>
@@ -119,12 +186,278 @@ export function InventorySection({ medicationId }: { medicationId: string }) {
total: item.dosesTotal,
})}
</span>
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
{t(`medications.detail.bestand.state.${item.state}`)}
</Badge>
<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"
size="sm"
onClick={() => setAdjustItem(item)}
className="min-h-11 sm:min-h-9"
data-slot="inventory-adjust-button"
>
{t("medications.detail.bestand.adjustButton")}
</Button>
</span>
</div>
))}
</SettingsGroup>
{dialogs}
</div>
);
}
/**
* Register a new pen / pack. Two fields (quantity + optional printed
* expiry) → Dialog per ui-guidelines §2.3. The quantity prefills from
* the medication's `dosesPerUnit` so the common case is one tap.
*/
function AddInventoryDialog({
medicationId,
defaultDosesTotal,
onClose,
}: {
medicationId: string;
defaultDosesTotal: number | null;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [dosesTotal, setDosesTotal] = useState(
defaultDosesTotal && defaultDosesTotal >= 1 && defaultDosesTotal <= 100
? String(defaultDosesTotal)
: "",
);
const [expiry, setExpiry] = useState("");
const [busy, setBusy] = useState(false);
const parsed = Number(dosesTotal);
const quantityValid =
Number.isInteger(parsed) && parsed >= 1 && parsed <= 100;
async function submit(e: FormEvent) {
e.preventDefault();
if (!quantityValid || busy) return;
setBusy(true);
try {
const res = await fetch(`/api/medications/${medicationId}/inventory`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dosesTotal: parsed,
printedExpiry: expiry
? new Date(`${expiry}T00:00:00`).toISOString()
: null,
}),
});
if (!res.ok) {
toast.error(t("medications.detail.bestand.addFailed"));
return;
}
await queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
});
toast.success(t("medications.detail.bestand.addSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.addFailed"));
} finally {
setBusy(false);
}
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.detail.bestand.addTitle")}</DialogTitle>
<DialogDescription>
{t("medications.detail.bestand.addDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-add-quantity"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addQuantityLabel")}
</label>
<Input
id="inventory-add-quantity"
type="number"
inputMode="numeric"
min={1}
max={100}
step={1}
required
autoComplete="off"
value={dosesTotal}
onChange={(e) => setDosesTotal(e.target.value)}
aria-describedby="inventory-add-quantity-helper"
/>
<p
id="inventory-add-quantity-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.addQuantityHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-expiry"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addExpiryLabel")}
</label>
<Input
id="inventory-add-expiry"
type="date"
autoComplete="off"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!quantityValid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.addSubmit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
/**
* Stock correction for one item: sets the remaining-dose count
* absolutely (covers both "I miscounted" and a manual withdrawal). The
* server clamps to the item's capacity and re-derives the state; 0
* marks the item used up.
*/
function AdjustInventoryDialog({
medicationId,
item,
onClose,
}: {
medicationId: string;
item: InventoryItem;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [value, setValue] = useState(String(item.dosesRemaining));
const [busy, setBusy] = useState(false);
const parsed = Number(value);
const valid =
Number.isInteger(parsed) && parsed >= 0 && parsed <= item.dosesTotal;
async function submit(e: FormEvent) {
e.preventDefault();
if (!valid || busy) return;
setBusy(true);
try {
const res = await fetch(
`/api/medications/${medicationId}/inventory/${item.id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dosesRemaining: parsed }),
},
);
if (!res.ok) {
toast.error(t("medications.detail.bestand.adjustFailed"));
return;
}
await queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
});
toast.success(t("medications.detail.bestand.adjustSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.adjustFailed"));
} finally {
setBusy(false);
}
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("medications.detail.bestand.adjustTitle")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.bestand.adjustDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-adjust-remaining"
className="text-sm font-medium"
>
{t("medications.detail.bestand.adjustQuantityLabel")}
</label>
<Input
id="inventory-adjust-remaining"
type="number"
inputMode="numeric"
min={0}
max={item.dosesTotal}
step={1}
required
autoComplete="off"
value={value}
onChange={(e) => setValue(e.target.value)}
aria-describedby="inventory-adjust-helper"
/>
<p
id="inventory-adjust-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.adjustHelper", {
total: item.dosesTotal,
})}
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!valid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.adjustSubmit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -72,15 +72,16 @@ function render(locale: "en" | "de" = "en") {
}
describe("MoodList — filter bar + multi-select chrome", () => {
it("renders the source filter control", () => {
// v1.16.1 — the per-page Select/date-input row migrated to the unified
// `<FilterBar>` pill rail; same grammar as the measurements list.
it("renders the filter rail with mood, source and date-range pills", () => {
const html = render("en");
expect(html).toContain("Filter by source");
});
it("renders the date-range labels wired to from/to", () => {
const html = render("en");
expect(html).toContain('for="mood-from"');
expect(html).toContain('for="mood-to"');
expect(html).toContain('data-slot="filter-bar"');
expect(html).toContain('aria-label="Mood"');
expect(html).toContain('aria-label="Source"');
expect(html).toContain('aria-label="Date range"');
const pills = html.match(/data-slot="filter-bar-pill"/g);
expect(pills?.length).toBe(3);
});
it("renders labelled selection checkboxes (per-row + select-all)", () => {
+59 -94
View File
@@ -6,12 +6,10 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
FilterBar,
FilterBarDateRange,
FilterBarSelect,
} from "@/components/ui/filter-bar";
import {
AlertDialog,
AlertDialogAction,
@@ -37,7 +35,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DateInput, DateTimeInput } from "@/components/ui/date-input";
import { DateTimeInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import {
Table,
@@ -415,93 +413,60 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
return (
<>
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("mood.moodLevel")}
</Label>
<Select value={moodFilter} onValueChange={setMoodFilter}>
<SelectTrigger className="w-full sm:w-44">
<SelectValue placeholder={t("mood.allMoods")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t("mood.allMoods")}</SelectItem>
{MOOD_LEVELS_LIST.map((val) => (
<SelectItem key={val} value={val}>
{MOOD_SCORES[val]} ({t(MOOD_LABEL_KEYS[val])})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* v1.15.13 — source filter. */}
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("mood.filterBySource")}
</Label>
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger
className="w-full sm:w-40"
aria-label={t("mood.filterBySource")}
>
<SelectValue placeholder={t("dataList.allSources")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("dataList.allSources")}
</SelectItem>
{MOOD_SOURCE_OPTIONS.map((src) => (
<SelectItem key={src} value={src}>
{formatMoodSource(src, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* v1.15.13 — optional date range, wired to from/to. */}
<div className="flex flex-col gap-1">
<Label
htmlFor="mood-from"
className="text-muted-foreground text-xs"
>
{t("dataList.dateFrom")}
</Label>
<DateInput
id="mood-from"
className="w-full sm:w-40"
value={fromDay}
max={toDay || undefined}
onChange={(e) => setFromDay(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="mood-to"
className="text-muted-foreground text-xs"
>
{t("dataList.dateTo")}
</Label>
<DateInput
id="mood-to"
className="w-full sm:w-40"
value={toDay}
min={fromDay || undefined}
onChange={(e) => setToDay(e.target.value)}
/>
</div>
</div>
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("mood.entryCount", {
count: fmt.integer(data.meta.total),
})}
</span>
)}
</div>
{/* v1.16.1 — unified filter rail (`<FilterBar>`), same grammar as
the measurements list: date range · mood · source as compact
pills, active filters as removable chips, reset + count.
Filter state and query keys unchanged. */}
<FilterBar
isFiltered={
moodFilter !== "ALL" ||
sourceFilter !== "ALL" ||
fromDay !== "" ||
toDay !== ""
}
onReset={() => {
setMoodFilter("ALL");
setSourceFilter("ALL");
setFromDay("");
setToDay("");
}}
count={
data?.meta?.total !== undefined
? t("mood.entryCount", {
count: fmt.integer(data.meta.total),
})
: undefined
}
>
<FilterBarDateRange
label={t("dataList.dateRange")}
from={fromDay}
to={toDay}
onFromChange={setFromDay}
onToChange={setToDay}
idPrefix="mood"
/>
<FilterBarSelect
label={t("mood.moodLevel")}
value={moodFilter}
onValueChange={setMoodFilter}
allLabel={t("mood.allMoods")}
options={MOOD_LEVELS_LIST.map((val) => ({
value: val,
label: `${MOOD_SCORES[val]} (${t(MOOD_LABEL_KEYS[val])})`,
}))}
/>
<FilterBarSelect
label={t("dataList.sourceLabel")}
value={sourceFilter}
onValueChange={setSourceFilter}
allLabel={t("dataList.allSources")}
options={MOOD_SOURCE_OPTIONS.map((src) => ({
value: src,
label: formatMoodSource(src, t),
}))}
/>
</FilterBar>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
+68 -20
View File
@@ -164,28 +164,51 @@ function computeTooltipPosition(
left: rect.left - tooltipSize.width - GAP,
});
}
// Pick the first candidate that fits the viewport; otherwise the
// first candidate (the renderer will clamp inside the box).
// Pick the first candidate that fits the viewport; otherwise fall back to
// centred. EVERY returned position is clamped into the viewport with a
// collision padding — a candidate computed off a target near the edge must
// never push the card (and its footer buttons) out of view.
const clamp = (c: {
placement: TourStop["placement"];
top: number;
left: number;
}) => ({
placement: c.placement,
top: Math.min(
Math.max(COLLISION_PAD, c.top),
Math.max(COLLISION_PAD, vh - tooltipSize.height - COLLISION_PAD),
),
left: Math.min(
Math.max(COLLISION_PAD, c.left),
Math.max(COLLISION_PAD, vw - tooltipSize.width - COLLISION_PAD),
),
});
for (const c of candidates) {
if (
c.top >= 8 &&
c.left >= 8 &&
c.top + tooltipSize.height <= vh - 8 &&
c.left + tooltipSize.width <= vw - 8
c.top >= COLLISION_PAD &&
c.left >= COLLISION_PAD &&
c.top + tooltipSize.height <= vh - COLLISION_PAD &&
c.left + tooltipSize.width <= vw - COLLISION_PAD
) {
return c;
}
}
// Last-resort: centred bottom of viewport.
return {
// None fits as-is: prefer the first requested candidate clamped into the
// viewport (keeps the card near its target) over jumping to centre.
if (candidates.length > 0) return clamp(candidates[0]);
return clamp({
placement: "center",
top: Math.max(16, (vh - tooltipSize.height) / 2),
left: Math.max(16, (vw - tooltipSize.width) / 2),
};
top: (vh - tooltipSize.height) / 2,
left: (vw - tooltipSize.width) / 2,
});
}
const TOOLTIP_WIDTH = 320;
const TOOLTIP_HEIGHT = 220; // enough headroom for two-line title + 4-line body + footer
/** Minimum gap the tooltip keeps from every viewport edge. */
const COLLISION_PAD = 8;
/** Below this viewport width the tooltip renders as a bottom sheet. */
const SHEET_BREAKPOINT = 480;
export function OnboardingTour({
includeAchievements = true,
@@ -333,8 +356,19 @@ export function OnboardingTour({
const counter = stepCounter(state);
const isLast = counter.current === counter.total;
// Responsive sizing: the card never claims more width than the viewport
// minus the collision padding, and very small viewports skip the anchored
// popover entirely in favour of a bottom sheet — anchoring a 20 rem card
// next to a target on a ~400 px screen always ends up clipped.
const viewportWidth =
typeof window !== "undefined" ? window.innerWidth : 1024;
const asSheet = viewportWidth < SHEET_BREAKPOINT;
const tooltipWidth = Math.min(
TOOLTIP_WIDTH,
viewportWidth - COLLISION_PAD * 2,
);
const tooltipPos = computeTooltipPosition(rect, stop.placement, {
width: TOOLTIP_WIDTH,
width: tooltipWidth,
height: TOOLTIP_HEIGHT,
});
@@ -481,13 +515,24 @@ export function OnboardingTour({
<div
ref={tooltipRef}
data-testid="onboarding-tour-tooltip"
data-placement={tooltipPos.placement}
className="bg-card border-border pointer-events-auto absolute max-h-[80vh] overflow-y-auto rounded-xl border p-5 shadow-2xl"
style={{
top: `${tooltipPos.top}px`,
left: `${tooltipPos.left}px`,
width: `${TOOLTIP_WIDTH}px`,
}}
data-placement={asSheet ? "sheet" : tooltipPos.placement}
className={
asSheet
? // Bottom-sheet fallback for small viewports — full-width card
// pinned above the bottom edge; no anchored positioning that
// could clip the footer buttons off-screen.
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 max-h-[70vh] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
: "bg-card border-border pointer-events-auto absolute max-h-[80vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
}
style={
asSheet
? undefined
: {
top: `${tooltipPos.top}px`,
left: `${tooltipPos.left}px`,
width: `${tooltipWidth}px`,
}
}
>
<header className="flex items-start justify-between gap-3">
<div>
@@ -516,7 +561,10 @@ export function OnboardingTour({
on the iPad / iPhone PWA shell. Bumping to `min-h-11` (44 px)
keeps the desktop visual close enough — the buttons grow
~4 px taller — while making mobile usable. */}
<footer className="mt-5 flex items-center justify-between gap-2">
{/* `flex-wrap` keeps long localised labels (Skip / Back / Next) from
forcing a horizontal scrollbar inside the card — the row wraps
instead, so the primary action is always reachable. */}
<footer className="mt-5 flex flex-wrap items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
+4 -2
View File
@@ -33,7 +33,9 @@ import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SettingsCardHeaderProps {
/** Lucide icon component; rendered as `text-primary h-5 w-5`. */
/** Lucide icon component; rendered as `text-muted-foreground h-5 w-5`.
* Neutral by design — primary/purple stays reserved for actions and
* highlight states, never for section-header iconography. */
icon: LucideIcon;
/** Card title — rendered as `<h2 class="text-lg font-semibold">`.
* Accepts a node so a card can wrap the title in a link. */
@@ -68,7 +70,7 @@ export function SettingsCardHeader({
return (
<header className={cn("flex items-start gap-2", className)}>
<Icon
className="text-primary mt-0.5 h-5 w-5 shrink-0"
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
{/* Title + description share one column to the RIGHT of the icon, so the
+4 -4
View File
@@ -229,7 +229,7 @@ export function AboutMeSection({
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 pl-7 sm:grid-cols-2">
{structuredField(
"conditions",
t("settings.ai.aboutMe.conditionsLabel"),
@@ -249,7 +249,7 @@ export function AboutMeSection({
</div>
</div>
<div className="space-y-1.5">
<div className="space-y-1.5 pl-7">
<Label htmlFor="settings-about-me-freetext">
{t("settings.ai.aboutMe.freeTextLabel")}
</Label>
@@ -267,7 +267,7 @@ export function AboutMeSection({
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3 pl-7">
<p
data-testid="settings-about-me-count"
className="text-muted-foreground text-xs tabular-nums"
@@ -354,7 +354,7 @@ export function AboutMeSection({
</p>
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
<p className="text-muted-foreground border-border border-t pt-3 pl-7 text-xs">
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
</section>
+11 -11
View File
@@ -238,7 +238,7 @@ export function AboutSection() {
<header className="space-y-1">
<h1
id="settings-section-about-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.about.title")}
</h1>
@@ -255,14 +255,14 @@ export function AboutSection() {
<SettingsCardHeader icon={Info} title="HealthLog" className="mb-4" />
{isLoading || !version ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 pl-7">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
</div>
) : (
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 pl-7 text-sm">
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.version")}
@@ -327,7 +327,7 @@ export function AboutSection() {
title={t("settings.about.linksHeading")}
className="mb-4"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<div className="flex flex-col gap-2 pl-7 sm:flex-row sm:flex-wrap">
<Button asChild variant="outline" size="sm">
<a
href={version.repository}
@@ -372,16 +372,16 @@ export function AboutSection() {
tour" card so the two surfaces feel consistent. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Compass className="text-primary h-5 w-5" />
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("settings.about.tourReplay")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.about.tourReplayHint")}
</p>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.about.tourReplayHint")}
</p>
</div>
<Button
type="button"
@@ -402,7 +402,7 @@ export function AboutSection() {
{tourFeedback && (
<p
role="alert"
className={`mt-2 text-xs ${
className={`mt-2 pl-7 text-xs ${
tourFeedback.type === "success"
? "text-success"
: "text-destructive"
+20 -18
View File
@@ -383,7 +383,7 @@ export function AccountSection() {
<header className="space-y-1">
<h1
id="settings-section-account-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.account.title")}
</h1>
@@ -402,7 +402,7 @@ export function AccountSection() {
title={t("settings.profile")}
className="mb-4"
/>
<form onSubmit={handleSaveProfile} className="space-y-4">
<form onSubmit={handleSaveProfile} className="space-y-4 pl-7">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
@@ -615,8 +615,10 @@ export function AccountSection() {
title={t("settings.passkeys")}
className="mb-4"
/>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<div className="mt-4 flex justify-end">
<div className="pl-7">
<PasskeyListSection isAuthenticated={isAuthenticated} />
</div>
<div className="mt-4 flex justify-end pl-7">
<Button
variant="outline"
onClick={handleAddPasskey}
@@ -653,16 +655,16 @@ export function AccountSection() {
right border by ~48 px). */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<div className="flex items-start gap-2">
<Shield className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("settings.passwordReset")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.changePasswordDescription")}
</p>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.changePasswordDescription")}
</p>
</div>
<Button
type="button"
@@ -683,16 +685,16 @@ export function AccountSection() {
as the password card so both action surfaces look identical. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Compass className="text-primary h-5 w-5" />
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("onboarding.tour.restart")}
</h2>
<p className="text-muted-foreground text-xs">
{t("onboarding.tour.restartHint")}
</p>
</div>
<p className="text-muted-foreground text-xs">
{t("onboarding.tour.restartHint")}
</p>
</div>
<Button
type="button"
@@ -1156,10 +1158,10 @@ function AvatarSection() {
title={t("settings.avatar.title")}
className="mb-4"
/>
<p className="text-muted-foreground mb-4 text-sm">
<p className="text-muted-foreground mb-4 pl-7 text-sm">
{t("settings.avatar.description")}
</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 pl-7">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="text-base">
+3 -3
View File
@@ -57,7 +57,7 @@ export function AdvancedSection() {
<header className="space-y-1">
<h1
id="settings-section-advanced-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.advanced.title")}
</h1>
@@ -154,12 +154,12 @@ function ResearchModeCard() {
data-slot="settings-research-mode-card"
>
<div className="flex items-center gap-2">
<BookOpenCheck className="text-primary h-5 w-5" />
<BookOpenCheck className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.researchMode.sectionTitle")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("settings.researchMode.subtitle")}
</p>
+65 -50
View File
@@ -35,7 +35,9 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { AboutMeSection } from "@/components/settings/about-me-section";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -183,7 +185,7 @@ export function AiSection() {
<header className="space-y-1">
<h1
id="settings-section-ai-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.ai.title")}
</h1>
@@ -199,6 +201,12 @@ export function AiSection() {
through provider configuration before finding the toggle. */}
<DisableCoachCard isAuthenticated={isAuthenticated} />
{/* v1.16.1 — Coach preferences (tone, verbosity, data scope)
moved here from the in-chat sheet; the chat header gear
deep-links to this section. Gated on the Coach being
enabled. */}
{coachEnabled && <CoachPrefsSection isAuthenticated={isAuthenticated} />}
{/* v1.11.2 — "What the Coach remembers": durable-fact review +
forget controls. Gated on the Coach being enabled. */}
{coachEnabled && <CoachMemorySection isAuthenticated={isAuthenticated} />}
@@ -447,57 +455,64 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
{/* v1.4.33 IW7 — the dedicated H2 ("KI-Insights") used to repeat
the parent section title ("KI-Auswertungen"). Both are gone now:
the section renders "Auswertungen" once at the top, and this
card carries only the Sparkles icon + the live provider-status
badges, mirroring the description that lives on the section
header. The configuration description is moved one level up
into the section subtitle so the visual hierarchy stays
two-level (section -> form) instead of three-level
(section -> card title -> form). */}
<div className="flex flex-wrap items-center justify-between gap-2">
<Sparkles className="text-primary h-5 w-5" aria-hidden="true" />
<ProviderStatusBadges
settings={insightsSettings}
activeProvider={chainData?.activeProvider ?? null}
{/* The card used to render an icon-only header row (Sparkles +
status badges, no title), which left the tile unanchored next
to its titled siblings. It now follows the shared
`SettingsCardHeader` contract: icon column, title + description
in the content column, status badges top-right — and the body
below indents to the title column (`pl-7`). */}
<SettingsCardHeader
icon={Sparkles}
title={t("settings.ai.providerCardTitle")}
description={t("settings.kiInsightsDescription")}
status={
<ProviderStatusBadges
settings={insightsSettings}
activeProvider={chainData?.activeProvider ?? null}
/>
}
/>
<div className="space-y-4 pl-7">
<ActiveProviderSelect
value={selectedProvider}
onChange={pickProvider}
/>
<ProviderConfigCard
provider={selectedProvider}
insightsSettings={insightsSettings}
userProvider={userProvider}
/>
<FallbackChainCard
chain={chainData?.configuredChain ?? []}
selected={selectedProvider}
onSelect={pickProvider}
/>
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
canRegenerate={
insightsSettings?.codexStatus === "connected" ||
insightsSettings?.hasAdminKey ||
Boolean(userProvider?.provider)
}
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
onRegenerated={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.insightsRoot(),
})
}
onPrivacyChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.insightsRoot(),
})
}
/>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.kiInsightsDescription")}
</p>
<ActiveProviderSelect value={selectedProvider} onChange={pickProvider} />
<ProviderConfigCard
provider={selectedProvider}
insightsSettings={insightsSettings}
userProvider={userProvider}
/>
<FallbackChainCard
chain={chainData?.configuredChain ?? []}
selected={selectedProvider}
onSelect={pickProvider}
/>
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
canRegenerate={
insightsSettings?.codexStatus === "connected" ||
insightsSettings?.hasAdminKey ||
Boolean(userProvider?.provider)
}
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
onRegenerated={() =>
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() })
}
onPrivacyChanged={() =>
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() })
}
/>
</div>
);
}
+2 -2
View File
@@ -43,7 +43,7 @@ export function ApiSection() {
<header className="space-y-1">
<h1
id="settings-section-api-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.api.title")}
</h1>
@@ -262,7 +262,7 @@ function ApiTokensCard() {
}
/>
<div className="mt-4 space-y-4">
<div className="mt-4 space-y-4 pl-7">
{/* The button mirrors the Input's responsive height (h-11 / sm:h-10)
so the row reads as one aligned control group instead of a tall
input next to a shorter `sm` button. */}
+2 -2
View File
@@ -139,8 +139,8 @@ export function CoachNudgeCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
}
>
{msg}
@@ -0,0 +1,410 @@
"use client";
/**
* v1.16.1 — Settings → AI "Coach preferences" card.
*
* The prompt-tuning form moved out of the in-chat sheet
* (`coach-settings-sheet.tsx`, retired) into the AI settings section so
* model + behaviour live in one place; the chat header keeps a gear
* that deep-links here. Reads + writes `/api/auth/me/coach-prefs`
* through the shared `useCoachPrefs` / `useSaveCoachPrefs` pair — the
* same persisted row the chat's "What I draw on" rail edits.
*
* One source of truth for data scope: `excludeMetrics`. The optional
* context group (sleep / medications / profile) used to render its own
* switches with the same checked-polarity as the exclude list, so
* "exclude sleep" and "send sleep context" could read as both ON at
* once. The context switches now DERIVE from the exclusion list
* (checked = not excluded = sent), so the two groups can never
* contradict each other.
*/
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import {
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachDataClusterEnum,
type CoachDataCluster,
type CoachDefaultWindow,
type CoachExcludeMetric,
type CoachPrefs,
type CoachTone,
type CoachVerbosity,
} from "@/lib/validations/coach-prefs";
const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
"bp",
"weight",
"pulse",
"mood",
"compliance",
"hrv",
"sleep",
"resting_hr",
"steps",
];
// Optional context blocks. Derived from `excludeMetrics` (see header
// comment): a switch here reads "send this block" and is simply the
// inverse of the exclusion entry.
const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
"sleep",
"medications",
"anthropometrics",
];
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
"last7days",
"last30days",
"last90days",
"allTime",
];
const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options;
export interface CoachPrefsSectionProps {
isAuthenticated: boolean;
}
export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
const { t } = useTranslations();
const { data: persisted } = useCoachPrefs({ enabled: isAuthenticated });
// Local working copy edited by the form; re-syncs to the persisted
// shape on every fetch via the in-render setState pattern (the
// `react-hooks/set-state-in-effect` rule rejects setState inside an
// effect when the source is a query result).
const [draft, setDraft] = useState<CoachPrefs>(DEFAULT_COACH_PREFS);
const [lastSeenPersisted, setLastSeenPersisted] = useState<CoachPrefs | null>(
null,
);
if (persisted && persisted !== lastSeenPersisted) {
setLastSeenPersisted(persisted);
setDraft(persisted);
}
const save = useSaveCoachPrefs({
onSuccess: () => {
toast.success(t("insights.coach.settingsSaved"));
},
});
function setExcluded(metric: CoachExcludeMetric, excluded: boolean) {
setDraft((prev) => ({
...prev,
excludeMetrics: excluded
? Array.from(new Set([...prev.excludeMetrics, metric]))
: prev.excludeMetrics.filter((m) => m !== metric),
}));
}
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
setDraft((prev) => {
const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
const updated = next
? Array.from(new Set([...current, cluster]))
: current.filter((c) => c !== cluster);
return { ...prev, dataClusters: updated };
});
}
const enabledClusters = new Set<CoachDataCluster>(
draft.dataClusters ?? DEFAULT_COACH_CLUSTERS,
);
const isLoading = isAuthenticated && !persisted;
return (
<Card data-slot="coach-prefs-section">
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t("insights.coach.settingsTitle")}
</CardTitle>
<CardDescription>
{t("insights.coach.settingsDescription")}
</CardDescription>
</CardHeader>
{isLoading ? (
<CardContent
data-slot="coach-prefs-skeleton"
className="flex flex-col gap-5"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-9 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-9 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-44 w-full" />
</div>
</CardContent>
) : (
<CardContent className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{/* Tone */}
<div className="flex flex-col gap-2">
<Label htmlFor="coach-prefs-tone" className="text-xs font-medium">
{t("insights.coach.settingsToneLabel")}
</Label>
<Select
value={draft.tone}
onValueChange={(value) =>
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
}
>
<SelectTrigger id="coach-prefs-tone" data-slot="coach-prefs-tone">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="warm">
{t("insights.coach.settingsToneWarm")}
</SelectItem>
<SelectItem value="neutral">
{t("insights.coach.settingsToneNeutral")}
</SelectItem>
<SelectItem value="concise">
{t("insights.coach.settingsToneConcise")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Verbosity */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-verbosity"
className="text-xs font-medium"
>
{t("insights.coach.settingsVerbosityLabel")}
</Label>
<Select
value={draft.verbosity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
verbosity: value as CoachVerbosity,
}))
}
>
<SelectTrigger
id="coach-prefs-verbosity"
data-slot="coach-prefs-verbosity"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="brief">
{t("insights.coach.settingsVerbosityBrief")}
</SelectItem>
<SelectItem value="default">
{t("insights.coach.settingsVerbosityDefault")}
</SelectItem>
<SelectItem value="detailed">
{t("insights.coach.settingsVerbosityDetailed")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Default analysis window */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-default-window"
className="text-xs font-medium"
>
{t("insights.coach.settingsDefaultWindowLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsDefaultWindowHint")}
</p>
<Select
value={draft.defaultWindow}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultWindow: value as CoachDefaultWindow,
}))
}
>
<SelectTrigger
id="coach-prefs-default-window"
data-slot="coach-prefs-default-window"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEFAULT_WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w}>
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Clustered, opt-in data sources */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsClustersLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsClustersHint")}
</p>
<ul
data-slot="coach-prefs-cluster-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CLUSTER_OPTIONS.map((cluster) => {
const id = `coach-prefs-cluster-${cluster}`;
const checked = enabledClusters.has(cluster);
return (
<li
key={cluster}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex min-w-0 flex-col">
<Label
htmlFor={id}
className="cursor-pointer text-xs font-medium"
>
{t(`insights.coach.cluster.${cluster}.label`)}
</Label>
<span className="text-muted-foreground text-[11px] leading-snug">
{t(`insights.coach.cluster.${cluster}.hint`)}
</span>
</div>
<Switch
id={id}
data-slot={`coach-prefs-cluster-${cluster}`}
checked={checked}
onCheckedChange={(next) => toggleCluster(cluster, next)}
/>
</li>
);
})}
</ul>
</div>
{/* Exclude metrics — checked = excluded. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsExcludeLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsExcludeHint")}
</p>
<ul
data-slot="coach-prefs-exclude-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{EXCLUDE_OPTIONS.map((metric) => {
const id = `coach-prefs-exclude-${metric}`;
const checked = draft.excludeMetrics.includes(metric);
return (
<li
key={metric}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<Label htmlFor={id} className="cursor-pointer text-xs">
{t(`insights.coach.metric.${metric}`)}
</Label>
<Switch
id={id}
data-slot={`coach-prefs-exclude-${metric}`}
checked={checked}
onCheckedChange={(next) => setExcluded(metric, next)}
/>
</li>
);
})}
</ul>
</div>
{/* Optional context blocks — checked = sent, derived as the
inverse of the exclusion list so the two groups can never
contradict each other. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsContextLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsContextHint")}
</p>
<ul
data-slot="coach-prefs-context-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CONTEXT_OPTIONS.map((metric) => {
const id = `coach-prefs-context-${metric}`;
const sent = !draft.excludeMetrics.includes(metric);
return (
<li
key={metric}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<Label htmlFor={id} className="cursor-pointer text-xs">
{t(`insights.coach.metric.${metric}`)}
</Label>
<Switch
id={id}
data-slot={`coach-prefs-context-${metric}`}
checked={sent}
onCheckedChange={(next) => setExcluded(metric, !next)}
/>
</li>
);
})}
</ul>
</div>
</CardContent>
)}
<CardFooter className="justify-end">
<Button
type="button"
size="sm"
onClick={() => save.mutate(draft)}
disabled={save.isPending || isLoading}
data-slot="coach-prefs-save"
>
{save.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
{t("insights.coach.settingsSave")}
</Button>
</CardFooter>
</Card>
);
}
@@ -72,7 +72,7 @@ export function CycleTrackingCard({
title={t("settings.cycleTracking.title")}
description={t("settings.cycleTracking.description")}
/>
<div className="mt-4 space-y-4">
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="cycle-tracking-enable" className="text-sm">
{t("settings.cycleTracking.enable")}
@@ -312,7 +312,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
zurücksetzen" copy clipping the card edge at narrow widths. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="flex items-center gap-2">
<LayoutDashboard className="text-primary h-5 w-5" />
<LayoutDashboard className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("dashboard.customizeTitle")}
</h2>
@@ -14,7 +14,7 @@ export function DashboardSection() {
<header className="space-y-1">
<h1
id="settings-section-dashboard-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.dashboard.title")}
</h1>
+2 -2
View File
@@ -80,7 +80,7 @@ export function ExportSection() {
<header className="space-y-1">
<h1
id="settings-section-export-title"
className="text-2xl font-semibold tracking-tight"
className="sr-only"
>
{t("settings.sections.export.title")}
</h1>
@@ -161,7 +161,7 @@ function ExportCardShell({
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="text-primary h-5 w-5 shrink-0" />
<Icon className="text-muted-foreground h-5 w-5 shrink-0" />
<h2 className="text-base font-semibold">{title}</h2>
</div>
<span className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-[11px] font-medium tracking-wide uppercase">
@@ -200,7 +200,7 @@ export function HealthRecordExportPanel() {
>
<div className="mb-1 flex items-center gap-2">
<FileText
className="text-primary h-5 w-5 shrink-0"
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h2
+1 -1
View File
@@ -95,7 +95,7 @@ function ImportCardShell({
className="bg-card border-border flex h-full flex-col rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
<Icon className="text-muted-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
@@ -74,7 +74,7 @@ export function InjectionSitesCard({
title={t("settings.globalExcludedInjectionSitesLabel")}
description={t("settings.globalExcludedInjectionSitesHint")}
/>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="mt-3 grid grid-cols-2 gap-2 pl-7">
{INJECTION_SITE_KEYS.map((site) => (
<label key={site} className="flex items-center gap-2 text-sm">
<Checkbox
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more