mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
145 files changed
+5296
-1921
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user