From ced4e4527221c7155d74ba5b5693abdddb20772c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:34:50 +0200 Subject: [PATCH 01/21] docs(self-hosting): reconcile DATABASE_URL/POSTGRES_PASSWORD guidance with the compose contract The bundled compose constructs DATABASE_URL inside the app service from POSTGRES_PASSWORD, so the getting-started 'edit CHANGE-ME' step was a no-op and .env.example's literal POSTGRES_PASSWORD collided with the quick-start's appended one. Comment both out in .env.example so the append pattern yields a single key, and rewrite step 2 to state compose derives the URL. Add a first-user note to close public registration on an internet-exposed host and to save the password (no-SMTP CLI reset). Refresh the stale onboarding step-2 source comment. --- .env.example | 25 ++++++++++++++++-------- docs/self-hosting/getting-started.md | 29 +++++++++++++++++++++++----- src/app/onboarding/[step]/page.tsx | 18 ++++++++--------- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 2f39edac0..2f200f2fd 100644 --- a/.env.example +++ b/.env.example @@ -18,15 +18,24 @@ # ----------------------------------------------------------------------------- # Database # ----------------------------------------------------------------------------- -# When using docker-compose.yml: the app container reaches Postgres via the -# service name "db", so the hostname is "db" — NOT "localhost". -# When developing without Docker (`pnpm dev` against a host-installed Postgres): -# change the hostname to "localhost" and adjust the credentials. -DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public" +# Bundled docker-compose.yml path (the common case): you do NOT set DATABASE_URL +# here. Compose CONSTRUCTS it inside the `app` service from POSTGRES_PASSWORD +# (see docker-compose.yml — the `environment:` block wins over any value in this +# file), so a DATABASE_URL line here is ignored for that stack. Set only +# POSTGRES_PASSWORD below and compose wires the connection string for you. +# +# Bare-Node / custom-compose path (`pnpm dev` against a host Postgres, or your +# own compose that does NOT derive DATABASE_URL): uncomment DATABASE_URL and set +# it explicitly. Hostname is "db" on the bundled compose network, or +# "localhost" for a host-installed Postgres. +# DATABASE_URL="postgresql://healthlog:YOUR_PASSWORD@localhost:5432/healthlog?schema=public" -# Postgres password — used both by the db service AND by DATABASE_URL above. -# Pick a strong random password, e.g. `openssl rand -hex 32`. -POSTGRES_PASSWORD="CHANGE-ME" +# Postgres password for the bundled `db` service — and the value compose splices +# into DATABASE_URL. The quick-start appends a generated one: +# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env +# Left commented here so that append does not create a duplicate key. Uncomment +# and set it directly instead if you prefer to edit the file by hand. +# POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32" # ----------------------------------------------------------------------------- diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 6e59c8a7f..c5d72fdef 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -53,11 +53,14 @@ echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env | `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` | | `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token | -Then open `.env` and confirm `DATABASE_URL` includes the password you -just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`) -references the literal string `CHANGE-ME`; replace it with the value -you put in `POSTGRES_PASSWORD`, or rewrite the URL to substitute -explicitly. +You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml` +stack: compose constructs the connection string inside the `app` +service from `${POSTGRES_PASSWORD}` (its `environment:` block overrides +any `DATABASE_URL` in `.env`), so the `openssl` line above is the only +password step. `DATABASE_URL` only matters if you run a **bare Node** +process (`pnpm dev` against a host Postgres) or a **custom compose** that +does not derive it — in that case uncomment and set `DATABASE_URL` +explicitly in `.env` (see the commented example there). ## 3. Bring the stack up @@ -92,6 +95,22 @@ After the first user lands, you can: - Open `/measurements` and add a weight or blood pressure to confirm the data path is wired end-to-end. +> **Close public registration if you expose the instance to the +> internet.** Registration stays **open** after the first admin so you +> can invite the rest of your household — but on a publicly reachable +> host that means strangers can still sign up. Once everyone who needs +> an account has one, turn it off in **`/admin` → registration toggle** +> (or keep it open and hand out invite links instead). The admin +> dashboard shows a reminder while registration is open and you are the +> only user. + +> **No email is configured out of the box** — a deliberate first-boot +> simplicity win, but it also means password reset has no self-service +> path until you set SMTP (see `docs/self-hosting/notifications.md`). If +> you are the sole admin, **save your password in a manager now**: a +> locked-out solo admin with no SMTP recovers only via the CLI script in +> `docs/ops/password-reset.md`. + ## 5. Point the instance at a public hostname Local-only is fine for a single-user trial. For anything more — and diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index ff0edadc8..9bcfb193f 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -13,18 +13,16 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; /** - * v1.4.25 W14b — onboarding wizard step page (foundation scaffold). + * Onboarding wizard step page. * * Routes: * /onboarding/0 welcome → carousel / value-prop intro * /onboarding/1 goals → "what do you want to track?" - * /onboarding/2 source → Withings / Apple Health / manual cards + * /onboarding/2 source → device/import source cards (see + * source-card-grid.tsx for the shipped set) * /onboarding/3 baseline → first measurement or sync confirmation * /onboarding/4 done → success screen + return to dashboard * - * This page ships the scaffold only — the step body is a placeholder - * `
` that the W14b-Content agent replaces with real step UI. - * * Step gating: * - Unauthenticated → `/auth/login` (the proxy also enforces this, * but the server-side check keeps the contract explicit). @@ -139,10 +137,12 @@ export default async function OnboardingStepPage({ params }: PageProps) { ); } - // Source (step 2) — four-card grid. Manual is the implicit default; - // Withings opens OAuth in a new tab; Apple Health is rendered as a - // "coming with v1.5" announce card; Garmin is omitted per the - // W14b-Content brief (not on the roadmap yet). + // Source (step 2) — card grid. Manual is the implicit default; the + // connectable sources (Apple Health, WHOOP, Oura, Polar, Nightscout, + // Fitbit/Pixel via Google Health) live in `source-card-grid.tsx`'s + // SOURCE_CARDS / MORE_SOURCES lists — refer to that component for the + // current shipped set rather than duplicating it here. The selection + // is non-binding: nothing here auto-configures a sync. if (requested === 2) { return ( From 2dc9947de213843f887b20ebc1405428d9853e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:35:41 +0200 Subject: [PATCH 02/21] fix(dashboard): link metric tiles to Insights detail + unify tile header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard metric tiles link to their /insights/ detail page (weight, blood pressure, pulse, glucose, mood, sleep, steps, VO2 max); tiles with no dedicated sub-page stay un-linked rather than misroute. - Daily-briefing key findings now route hrv, resting HR, active energy, flights, distance, body temperature and recovery to their sub-pages — the METRIC_HREF map had gone stale as those routes shipped. - RecentWorkoutsTile adopts the canonical TileHeader (foreground icon + title) instead of a muted hand-rolled row. - Hero-strip header glyph moves off text-primary to the foreground tier. --- src/app/page-client.tsx | 79 ++++++++++++++----- .../dashboard/recent-workouts-tile.tsx | 35 ++++---- .../__tests__/daily-briefing.test.tsx | 30 +++++++ src/components/insights/daily-briefing.tsx | 22 +++--- src/components/insights/hero-strip.tsx | 5 +- 5 files changed, 124 insertions(+), 47 deletions(-) diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 158f85062..955a0ba96 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -25,6 +25,7 @@ import { } from "@/lib/dashboard-layout"; import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; import dynamic from "next/dynamic"; +import Link from "next/link"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; @@ -131,6 +132,30 @@ import { resolveChartRowPlaceholderCount, } from "@/components/dashboard/dashboard-gates"; +/** + * Dashboard metric tiles are no longer dead-ends: each tile links to its + * Insights detail page (bands, correlations, history). Keyed by the stable + * tile `id` used in the `trendCards` builder; glucose contexts share one + * blood-glucose destination via the `glucose-` prefix. Tiles with no + * dedicated sub-page (body fat %) stay un-linked rather than misroute. + */ +const TILE_INSIGHT_HREF: Record = { + weight: "/insights/weight", + "bp-sys": "/insights/blood-pressure", + "bp-dia": "/insights/blood-pressure", + bpInTarget: "/insights/blood-pressure", + pulse: "/insights/pulse", + mood: "/insights/mood", + sleep: "/insights/sleep", + steps: "/insights/steps", + vo2Max: "/insights/cardio-fitness", +}; + +function tileInsightHref(id: string): string | null { + if (id.startsWith("glucose-")) return "/insights/blood-glucose"; + return TILE_INSIGHT_HREF[id] ?? null; +} + export default function DashboardPageClient() { const { isAuthenticated, user } = useAuth(); const mounted = useMounted(); @@ -1611,27 +1636,45 @@ export default function DashboardPageClient() { data-tour-id="dashboard-tile-strip" data-tile-count={trendCards.length} > - {trendCards.map((entry) => ( -
- {/* - * Per-tile `` boundary with a layout-stable - * placeholder that mirrors the trend-card chrome. Tile - * bodies are synchronous today so the fallback rarely - * paints, but a future RSC hoist of any tile slot - * would otherwise leave the grid track empty and - * trigger CLS as the cell paints in. - * - * v1.4.40 W-RSC — boundary added; v1.4.41 W-FRONTEND-FACTORY — fallback hoisted to layout-stable placeholder. - */} - {/* v1.16.0 — the fallback is the same structured - silhouette the tile-strip skeleton paints, so a - suspending tile slot previews the final shape - instead of an empty card. */} + {trendCards.map((entry) => { + /* + * Per-tile `` boundary with a layout-stable + * placeholder that mirrors the trend-card chrome. Tile + * bodies are synchronous today so the fallback rarely + * paints, but a future RSC hoist of any tile slot + * would otherwise leave the grid track empty and + * trigger CLS as the cell paints in. + * + * v1.16.0 — the fallback is the same structured + * silhouette the tile-strip skeleton paints, so a + * suspending tile slot previews the final shape + * instead of an empty card. + */ + const cell = ( }> {entry.node} -
- ))} + ); + // A tile is no longer a dead-end: it links to its Insights + // detail page. The link fills the grid cell and paints a + // ring on hover / focus so the whole tile reads as tappable. + const href = tileInsightHref(entry.id); + return href ? ( + + {cell} + + ) : ( +
+ {cell} +
+ ); + })}
)} {charts.map((entry) => ( diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index a4557b5c0..ffc508ce9 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -15,6 +15,7 @@ import { import { useTranslations } from "@/lib/i18n/context"; import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; import { cn } from "@/lib/utils"; +import { TileHeader } from "@/components/insights/tile-header"; /** * v1.4.32 — ``. @@ -107,23 +108,23 @@ export function RecentWorkoutsTile() { "bg-card border-border space-y-3 rounded-xl border p-4 md:p-6", )} > -
-
-
- - {t("dashboard.recentWorkouts.viewAll")} - -
+ {/* Canonical tile header (foreground icon + title) — the same + `TileHeader` contract the Insights tiles use, so the dashboard + stops speaking a separate header language. `size="sm"` matches the + compact dashboard tile. */} + + {t("dashboard.recentWorkouts.viewAll")} + + } + /> {isLoading ? ( // v1.4.43 W11-L6 — reserve roughly the loaded tile height diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx index 8db6630e4..25aa60033 100644 --- a/src/components/insights/__tests__/daily-briefing.test.tsx +++ b/src/components/insights/__tests__/daily-briefing.test.tsx @@ -95,6 +95,36 @@ describe("", () => { expect(html).toContain("Weight down 30 d"); }); + it("links the key-finding rows for metrics whose Insights sub-page exists", () => { + // Regression guard for the stale METRIC_HREF map: hrv / resting_hr / + // active_energy / flights / distance / body_temp / recovery all have + // dedicated Insights sub-pages now, so their briefing rows must be + // tappable `` links, not inert rows. + const html = render( + , + ); + expect(html).toContain('href="/insights/hrv"'); + expect(html).toContain('href="/insights/resting-pulse"'); + expect(html).toContain('href="/insights/active-energy"'); + expect(html).toContain('href="/insights/flights-climbed"'); + expect(html).toContain('href="/insights/walking-distance"'); + expect(html).toContain('href="/insights/body-temperature"'); + expect(html).toContain('href="/insights/recovery"'); + }); + it("renders the delta string when supplied", () => { const html = render(); expect(html).toMatch(/data-slot="daily-briefing-delta"/); diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index ce3da3cff..65eebed90 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -53,28 +53,28 @@ export const METRIC_HREF: Record< pulse: "/insights/pulse", mood: "/insights/mood", compliance: "/insights/medications", - // Apple Health / GLP-1 additive metrics — no dedicated sub-page yet. - // They render as static rows; v1.4.28 can wire each as they ship. - hrv: null, + // Apple Health / GLP-1 additive metrics — each now routes to its + // dedicated sub-page that shipped since the original map was written. + hrv: "/insights/hrv", sleep: "/insights/sleep", - resting_hr: null, + resting_hr: "/insights/resting-pulse", // v1.12.4 — steps has no dedicated metric sub-page, so it routes to the // generic readings view keyed to its `MeasurementType` (the same target // the category pages use to drill into a single type). Every other // briefing finding with a real destination is already tappable; steps // was the lone static row. steps: "/insights/steps", - active_energy: null, - flights: null, - distance: null, + active_energy: "/insights/active-energy", + flights: "/insights/flights-climbed", + distance: "/insights/walking-distance", vo2_max: "/insights/pulse", - body_temp: null, + body_temp: "/insights/body-temperature", glp1_plateau: "/insights/medications", // ── v1.10.0 derived-wellness additive ── - // Readiness has a score-anatomy detail page; recovery is a read-only - // ring with no dedicated sub-page yet (renders as a static row). + // Readiness has a score-anatomy detail page; recovery has its own + // read-only sub-page. readiness: "/insights/scores/readiness", - recovery: null, + recovery: "/insights/recovery", }; /** diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index d97dbf8b9..24570cf17 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -284,8 +284,11 @@ export function HeroStrip({
+ {/* Header glyph stays in the foreground tier — UI-STANDARDS §1 + reserves primary/accent for actions, not decorative header + icons. */}

Date: Wed, 8 Jul 2026 23:36:48 +0200 Subject: [PATCH 03/21] feat(auth): warn on a Secure-cookie / plain-HTTP transport mismatch at login The most common self-host login loop is a Secure session cookie issued over plain HTTP: the browser drops it and bounces the user back to the sign-in page with no error. Detect it at cookie-mint time (detectInsecureCookieTransport) and emit a clear warn-log plus a session_cookie_secure_transport_mismatch wide-event, pointing at SESSION_COOKIE_SECURE=false / X-Forwarded-Proto. Non-fatal; login still completes. --- src/lib/auth/__tests__/secure-cookie.test.ts | 62 +++++++++++++++++++- src/lib/auth/login-response.ts | 18 +++++- src/lib/auth/secure-cookie.ts | 54 +++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/src/lib/auth/__tests__/secure-cookie.test.ts b/src/lib/auth/__tests__/secure-cookie.test.ts index e56d5645e..c329851ab 100644 --- a/src/lib/auth/__tests__/secure-cookie.test.ts +++ b/src/lib/auth/__tests__/secure-cookie.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -import { shouldEmitSecureCookie } from "../secure-cookie"; +import { + shouldEmitSecureCookie, + detectInsecureCookieTransport, +} from "../secure-cookie"; + +function req(headers: Record, url = "http://10.0.0.5:3000/x") { + return { headers: new Headers(headers), url }; +} describe("shouldEmitSecureCookie", () => { afterEach(() => { @@ -47,3 +54,56 @@ describe("shouldEmitSecureCookie", () => { expect(shouldEmitSecureCookie()).toBe(false); }); }); + +describe("detectInsecureCookieTransport", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns null when the Secure flag is off (SESSION_COOKIE_SECURE=false)", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", "false"); + expect(detectInsecureCookieTransport(req({}))).toBeNull(); + }); + + it("warns when a Secure cookie will be set on a plain-HTTP request (no proxy)", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + const msg = detectInsecureCookieTransport(req({})); + expect(msg).toContain("SESSION_COOKIE_SECURE=false"); + }); + + it("returns null behind a TLS proxy that forwards X-Forwarded-Proto: https", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + expect( + detectInsecureCookieTransport(req({ "x-forwarded-proto": "https" })), + ).toBeNull(); + }); + + it("reads the left-most entry of a comma-separated proto chain", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + expect( + detectInsecureCookieTransport( + req({ "x-forwarded-proto": "https, http" }), + ), + ).toBeNull(); + }); + + it("warns when the proxy forwards X-Forwarded-Proto: http explicitly", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + expect( + detectInsecureCookieTransport(req({ "x-forwarded-proto": "http" })), + ).not.toBeNull(); + }); + + it("returns null when the request URL is already https", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + expect( + detectInsecureCookieTransport(req({}, "https://healthlog.example.com/x")), + ).toBeNull(); + }); +}); diff --git a/src/lib/auth/login-response.ts b/src/lib/auth/login-response.ts index d0998afd8..d9330dd83 100644 --- a/src/lib/auth/login-response.ts +++ b/src/lib/auth/login-response.ts @@ -27,6 +27,8 @@ import { } from "@/lib/auth/native-client"; import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; import { recordSignInDevice } from "@/lib/auth/login-alert"; +import { detectInsecureCookieTransport } from "@/lib/auth/secure-cookie"; +import { annotate } from "@/lib/logging/context"; import type { User } from "@/generated/prisma/client"; export interface FinishLoginParams { @@ -116,8 +118,20 @@ export async function finishLogin( }); } - // Browser path — session cookie. `createSession` anchors the onboarding - // cookie itself; thread the user's onboarding state through. + // Browser path — session cookie. Before minting it, surface the single + // most common self-host snag: a Secure cookie about to be set on a plain-HTTP + // request drops silently and loops the login. Warn-log + wide-event so an + // operator sees the cause; the login still completes. + const transportWarning = detectInsecureCookieTransport(request); + if (transportWarning) { + console.warn( + `[auth] session-cookie transport mismatch: ${transportWarning}`, + ); + annotate({ meta: { session_cookie_secure_transport_mismatch: true } }); + } + + // `createSession` anchors the onboarding cookie itself; thread the user's + // onboarding state through. await createSession( user.id, user.onboardingCompletedAt == null, diff --git a/src/lib/auth/secure-cookie.ts b/src/lib/auth/secure-cookie.ts index c2fb8b931..d200b9af0 100644 --- a/src/lib/auth/secure-cookie.ts +++ b/src/lib/auth/secure-cookie.ts @@ -28,3 +28,57 @@ export function shouldEmitSecureCookie(): boolean { if (override === "true") return true; return process.env.NODE_ENV === "production"; } + +/** + * One-line diagnostic for the single most common self-host login-loop cause: + * the app is about to set a `Secure` session cookie + * (`shouldEmitSecureCookie() === true`) while the request itself arrived over + * plain HTTP. The browser silently drops a `Secure` cookie on an `http://` + * origin, so the next request is unauthenticated and the user bounces back to + * the login page with no error at all — it reads like a wrong password forever. + * + * Returns a human-readable message when the mismatch is detected, else `null`. + * The caller logs it at login time (`console.warn` survives the prod + * `console.*` strip) so an operator sees WHY login loops without having to find + * the note buried in three docs. Non-fatal: the login still completes; the + * cookie just won't stick until `SESSION_COOKIE_SECURE=false` is set (or TLS is + * fronted). + * + * A request counts as HTTPS when `x-forwarded-proto` names https (the shape + * every documented TLS reverse proxy sets) OR the request URL is already + * https. A proxy that terminates TLS but omits `x-forwarded-proto` can produce + * a benign false positive — the message names that case so it stays actionable + * either way. + */ +export function detectInsecureCookieTransport(req: { + headers: Headers; + url: string; +}): string | null { + if (!shouldEmitSecureCookie()) return null; + + // `x-forwarded-proto` can be a comma-separated proto chain; the left-most + // entry is the client-facing scheme the browser actually spoke. + const clientProto = req.headers + .get("x-forwarded-proto") + ?.split(",")[0] + ?.trim() + .toLowerCase(); + if (clientProto === "https") return null; + + let urlIsHttps = false; + try { + urlIsHttps = new URL(req.url).protocol === "https:"; + } catch { + urlIsHttps = false; + } + if (urlIsHttps) return null; + + return ( + "This request arrived over plain HTTP but the session cookie is being " + + "issued with the `Secure` flag — the browser will drop it and login will " + + "loop back to the sign-in page with no error message. If you serve HTTP " + + "directly (LAN / NAS / Tailscale, no TLS reverse proxy), set " + + "SESSION_COOKIE_SECURE=false. If a reverse proxy terminates TLS in front " + + "of this container, make sure it forwards `X-Forwarded-Proto: https`." + ); +} From 2e84498cf30048553c34bb34332131fe3a23f018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:39:59 +0200 Subject: [PATCH 04/21] fix(notifications): repoint channel CTA, add error/loading primitives, cross-link surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The 'no channels' CTA now points at Settings → Integrations#channels (where channels are actually configured since v1.25.7) instead of the reminder-types screen, which had become a dead-end. - A failed load renders the shared QueryErrorCard with Retry; the loading frame reserves a matrix skeleton instead of a lone spinner. - The globally-disabled hint reads as a full sentence rather than the bare '(Disabled)' inline tag. - The three notification surfaces (per-event matrix, reminder types, delivery channels) now cross-link each other. - noChannels copy names its destination. --- messages/de.json | 14 +- messages/en.json | 14 +- messages/es.json | 14 +- messages/fr.json | 14 +- messages/it.json | 14 +- messages/pl.json | 14 +- src/app/notifications/__tests__/page.test.tsx | 127 ++++++++++++++++++ src/app/notifications/page.tsx | 71 ++++++++-- .../settings/integrations-section.tsx | 11 ++ .../settings/notifications-section.tsx | 17 +++ 10 files changed, 273 insertions(+), 37 deletions(-) create mode 100644 src/app/notifications/__tests__/page.test.tsx diff --git a/messages/de.json b/messages/de.json index 5073c6e7b..42965e6ba 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4055,14 +4055,17 @@ "connectionsHeading": "Verbindungen", "connectionsDescription": "Datenquellen und Geräte verbinden und verwalten.", "channelsHeading": "Zustellkanäle", - "channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden." + "channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden.", + "matrixLink": "Benachrichtigungen je Kanal" }, "notifications": { "title": "Benachrichtigungen", "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.", "subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.", "remindersHeading": "Erinnerungen", - "remindersDescription": "Was du erhältst." + "remindersDescription": "Was du erhältst.", + "matrixLink": "Benachrichtigungen je Kanal", + "channelsLink": "Zustellkanäle" }, "layout": { "title": "Darstellung", @@ -6268,7 +6271,7 @@ "eventColumn": "Ereignis", "subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.", "loadError": "Benachrichtigungseinstellungen konnten nicht geladen werden.", - "noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal in den Einstellungen.", + "noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal unter Einstellungen → Integrationen.", "goToSettings": "Zu den Kanal-Einstellungen", "channelDisabled": "Deaktiviert", "admin": { @@ -6379,7 +6382,10 @@ "newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.", "unknownLocation": "unbekannter Ort" }, - "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten." + "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten.", + "someChannelsGloballyDisabled": "Ein oder mehrere Kanäle sind in den Einstellungen deaktiviert und erhalten keine Benachrichtigungen.", + "reminderSettingsLink": "Erinnerungseinstellungen", + "channelsLink": "Kanäle" }, "i18n": { "maintainershipBanner": { diff --git a/messages/en.json b/messages/en.json index e85e5c5fc..0deafe73f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4055,14 +4055,17 @@ "connectionsHeading": "Connections", "connectionsDescription": "Connect and manage your data sources and devices.", "channelsHeading": "Delivery channels", - "channelsDescription": "Where reminders and alerts are delivered." + "channelsDescription": "Where reminders and alerts are delivered.", + "matrixLink": "Per-channel notifications" }, "notifications": { "title": "Notifications", "description": "Every reminder in one place — what gets reminded and when, and where it's delivered.", "subtitle": "Choose which reminders you receive and where they're delivered.", "remindersHeading": "Reminders", - "remindersDescription": "What you receive." + "remindersDescription": "What you receive.", + "matrixLink": "Per-channel notifications", + "channelsLink": "Delivery channels" }, "layout": { "title": "Appearance", @@ -6268,7 +6271,7 @@ "eventColumn": "Event", "subtitle": "Choose which notifications you want to receive through which channel.", "loadError": "Notification preferences could not be loaded.", - "noChannels": "Configure a notification channel in Settings first.", + "noChannels": "Configure a notification channel in Settings → Integrations first.", "goToSettings": "Go to channel settings", "channelDisabled": "Disabled", "admin": { @@ -6379,7 +6382,10 @@ "newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.", "unknownLocation": "unknown location" }, - "loginRequired": "Please sign in to manage notifications." + "loginRequired": "Please sign in to manage notifications.", + "someChannelsGloballyDisabled": "One or more channels are turned off in Settings and won’t receive notifications.", + "reminderSettingsLink": "Reminder settings", + "channelsLink": "Channels" }, "i18n": { "maintainershipBanner": { diff --git a/messages/es.json b/messages/es.json index 80d3c7b3c..bf2109424 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4055,14 +4055,17 @@ "connectionsHeading": "Conexiones", "connectionsDescription": "Conecta y gestiona tus fuentes de datos y dispositivos.", "channelsHeading": "Canales de entrega", - "channelsDescription": "Dónde se entregan los recordatorios y avisos." + "channelsDescription": "Dónde se entregan los recordatorios y avisos.", + "matrixLink": "Notificaciones por canal" }, "notifications": { "title": "Notificaciones", "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.", "subtitle": "Elige qué recordatorios recibes y dónde se entregan.", "remindersHeading": "Recordatorios", - "remindersDescription": "Lo que recibes." + "remindersDescription": "Lo que recibes.", + "matrixLink": "Notificaciones por canal", + "channelsLink": "Canales de entrega" }, "layout": { "title": "Apariencia", @@ -6268,7 +6271,7 @@ "eventColumn": "Evento", "subtitle": "Elige qué notificaciones recibes y por qué canal.", "loadError": "No se han podido cargar los ajustes de notificación.", - "noChannels": "Configura primero un canal de notificación en los ajustes.", + "noChannels": "Configura primero un canal de notificación en Ajustes → Integraciones.", "goToSettings": "Ir a los ajustes del canal", "channelDisabled": "Desactivado", "admin": { @@ -6379,7 +6382,10 @@ "newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.", "unknownLocation": "ubicación desconocida" }, - "loginRequired": "Inicia sesión para gestionar las notificaciones." + "loginRequired": "Inicia sesión para gestionar las notificaciones.", + "someChannelsGloballyDisabled": "Uno o más canales están desactivados en los ajustes y no recibirán notificaciones.", + "reminderSettingsLink": "Ajustes de recordatorios", + "channelsLink": "Canales" }, "i18n": { "maintainershipBanner": { diff --git a/messages/fr.json b/messages/fr.json index 5bd017305..3a211914e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4055,14 +4055,17 @@ "connectionsHeading": "Connexions", "connectionsDescription": "Connectez et gérez vos sources de données et appareils.", "channelsHeading": "Canaux de diffusion", - "channelsDescription": "Où sont envoyés les rappels et alertes." + "channelsDescription": "Où sont envoyés les rappels et alertes.", + "matrixLink": "Notifications par canal" }, "notifications": { "title": "Notifications", "description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.", "subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.", "remindersHeading": "Rappels", - "remindersDescription": "Ce que vous recevez." + "remindersDescription": "Ce que vous recevez.", + "matrixLink": "Notifications par canal", + "channelsLink": "Canaux de diffusion" }, "layout": { "title": "Apparence", @@ -6268,7 +6271,7 @@ "eventColumn": "Événement", "subtitle": "Choisis quelles notifications tu reçois et par quel canal.", "loadError": "Impossible de charger les paramètres de notification.", - "noChannels": "Configure d’abord un canal de notification dans les paramètres.", + "noChannels": "Configure d’abord un canal de notification dans Paramètres → Intégrations.", "goToSettings": "Aller aux paramètres du canal", "channelDisabled": "Désactivé", "admin": { @@ -6379,7 +6382,10 @@ "newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.", "unknownLocation": "lieu inconnu" }, - "loginRequired": "Connecte-toi pour gérer les notifications." + "loginRequired": "Connecte-toi pour gérer les notifications.", + "someChannelsGloballyDisabled": "Un ou plusieurs canaux sont désactivés dans les paramètres et ne recevront pas de notifications.", + "reminderSettingsLink": "Réglages des rappels", + "channelsLink": "Canaux" }, "i18n": { "maintainershipBanner": { diff --git a/messages/it.json b/messages/it.json index 933289bb6..51dee5cd3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4055,14 +4055,17 @@ "connectionsHeading": "Connessioni", "connectionsDescription": "Collega e gestisci le tue fonti di dati e i dispositivi.", "channelsHeading": "Canali di recapito", - "channelsDescription": "Dove vengono recapitati promemoria e avvisi." + "channelsDescription": "Dove vengono recapitati promemoria e avvisi.", + "matrixLink": "Notifiche per canale" }, "notifications": { "title": "Notifiche", "description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.", "subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.", "remindersHeading": "Promemoria", - "remindersDescription": "Ciò che ricevi." + "remindersDescription": "Ciò che ricevi.", + "matrixLink": "Notifiche per canale", + "channelsLink": "Canali di recapito" }, "layout": { "title": "Aspetto", @@ -6268,7 +6271,7 @@ "eventColumn": "Evento", "subtitle": "Scegli quali notifiche ricevere e su quale canale.", "loadError": "Impossibile caricare le impostazioni di notifica.", - "noChannels": "Configura prima un canale di notifica nelle impostazioni.", + "noChannels": "Configura prima un canale di notifica in Impostazioni → Integrazioni.", "goToSettings": "Vai alle impostazioni del canale", "channelDisabled": "Disabilitato", "admin": { @@ -6379,7 +6382,10 @@ "newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.", "unknownLocation": "posizione sconosciuta" }, - "loginRequired": "Accedi per gestire le notifiche." + "loginRequired": "Accedi per gestire le notifiche.", + "someChannelsGloballyDisabled": "Uno o più canali sono disattivati nelle impostazioni e non riceveranno notifiche.", + "reminderSettingsLink": "Impostazioni promemoria", + "channelsLink": "Canali" }, "i18n": { "maintainershipBanner": { diff --git a/messages/pl.json b/messages/pl.json index fc43f1599..50016525c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4055,14 +4055,17 @@ "connectionsHeading": "Połączenia", "connectionsDescription": "Połącz i zarządzaj źródłami danych oraz urządzeniami.", "channelsHeading": "Kanały dostarczania", - "channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia." + "channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia.", + "matrixLink": "Powiadomienia wg kanału" }, "notifications": { "title": "Powiadomienia", "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.", "subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.", "remindersHeading": "Przypomnienia", - "remindersDescription": "Co otrzymujesz." + "remindersDescription": "Co otrzymujesz.", + "matrixLink": "Powiadomienia wg kanału", + "channelsLink": "Kanały dostarczania" }, "layout": { "title": "Wygląd", @@ -6268,7 +6271,7 @@ "eventColumn": "Zdarzenie", "subtitle": "Wybierz, które powiadomienia chcesz otrzymywać i którym kanałem.", "loadError": "Nie udało się załadować ustawień powiadomień.", - "noChannels": "Najpierw skonfiguruj kanał powiadomień w ustawieniach.", + "noChannels": "Najpierw skonfiguruj kanał powiadomień w Ustawienia → Integracje.", "goToSettings": "Przejdź do ustawień kanału", "channelDisabled": "Wyłączone", "admin": { @@ -6379,7 +6382,10 @@ "newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.", "unknownLocation": "nieznana lokalizacja" }, - "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami." + "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami.", + "someChannelsGloballyDisabled": "Co najmniej jeden kanał jest wyłączony w ustawieniach i nie będzie otrzymywać powiadomień.", + "reminderSettingsLink": "Ustawienia przypomnień", + "channelsLink": "Kanały" }, "i18n": { "maintainershipBanner": { diff --git a/src/app/notifications/__tests__/page.test.tsx b/src/app/notifications/__tests__/page.test.tsx new file mode 100644 index 000000000..4306c4522 --- /dev/null +++ b/src/app/notifications/__tests__/page.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * Notification-matrix page contract (QoL/UX audit 2026-07-08): + * - H2: the "no channels" CTA points at Settings → Integrations#channels, + * the surface that can actually add a channel (not the reminder-types + * screen, which no longer configures channels since v1.25.7). + * - M3: a failed load renders the shared `QueryErrorCard` with a Retry, + * never a bare alert box; the loading frame reserves a skeleton. + * - L1: the globally-disabled hint reads as a full sentence, not the bare + * "(Disabled)" inline tag. + * - M7: the page cross-links the other two notification surfaces. + */ + +type AuthState = { isAuthenticated: boolean; isLoading: boolean }; +type QueryState = { + data: unknown; + isLoading: boolean; + isError: boolean; +}; + +let authState: AuthState = { isAuthenticated: true, isLoading: false }; +let queryState: QueryState = { data: undefined, isLoading: false, isError: false }; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => authState, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ ...queryState, refetch: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ + cancelQueries: vi.fn(), + getQueryData: vi.fn(), + setQueryData: vi.fn(), + invalidateQueries: vi.fn(), + }), +})); + +import NotificationsPage from "../page"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + authState = { isAuthenticated: true, isLoading: false }; + queryState = { data: undefined, isLoading: false, isError: false }; +}); + +describe("NotificationsPage", () => { + it("M3: renders a skeleton while loading, not a lone spinner", () => { + queryState.isLoading = true; + const html = render(); + expect(html).toContain('data-slot="notifications-loading"'); + expect(html).not.toContain("animate-spin"); + }); + + it("M3: renders the shared QueryErrorCard with a retry on load failure", () => { + queryState.isError = true; + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain("Notification preferences could not be loaded."); + }); + + it("H2 + L4: the no-channels CTA targets Settings → Integrations#channels", () => { + queryState.data = { channels: [], preferences: [], eventTypes: [] }; + const html = render(); + expect(html).toContain('href="/settings/integrations#channels"'); + // The reminder-types screen no longer configures channels — never point there. + expect(html).not.toContain('href="/settings/notifications"><'); + expect(html).toContain("Settings → Integrations"); + }); + + it("M7: the matrix cross-links the reminder-types + channels surfaces", () => { + queryState.data = { + channels: [ + { + id: "c1", + type: "telegram", + label: "Telegram", + enabled: true, + globallyEnabled: true, + }, + ], + preferences: [], + eventTypes: ["MEDICATION_REMINDER"], + }; + const html = render(); + expect(html).toContain('href="/settings/notifications"'); + expect(html).toContain('href="/settings/integrations#channels"'); + }); + + it("L1: the globally-disabled hint is a full sentence, not the bare tag", () => { + queryState.data = { + channels: [ + { + id: "c1", + type: "telegram", + label: "Telegram", + enabled: true, + globallyEnabled: true, + }, + { + id: "c2", + type: "ntfy", + label: "ntfy", + enabled: true, + globallyEnabled: false, + }, + ], + preferences: [], + eventTypes: ["MEDICATION_REMINDER"], + }; + const html = render(); + expect(html).toContain( + "won’t receive notifications", + ); + }); +}); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 4078419e7..f5c64067d 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -7,8 +7,10 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/ui/page-header"; +import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import Link from "next/link"; -import { Bell, Loader2, Settings, AlertCircle } from "lucide-react"; +import { Bell, Settings, AlertCircle } from "lucide-react"; import { EVENT_DEFAULT_ENABLED, type EventType, @@ -52,7 +54,7 @@ export default function NotificationsPage() { const { t } = useTranslations(); const queryClient = useQueryClient(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.notificationsPreferences(), queryFn: async () => { return apiGet("/api/notifications/preferences"); @@ -119,10 +121,50 @@ export default function NotificationsPage() { return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true; } + // Cross-links to the other two notification surfaces (reminder types + + // delivery channels) so the matrix never reads as the only place to manage + // notifications. Mirrored by back-links on those two settings screens. + const headerActions = ( + <> + + + + ); + if (authLoading || isLoading) { return ( -
- +
+ + {/* Skeleton mirrors the event-row matrix so the auth-resolving frame + reserves layout instead of flashing a lone spinner. */} +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+
+ + +
+ +
+ ))} +
); } @@ -149,12 +191,10 @@ export default function NotificationsPage() { title={t("notifications.title")} description={t("notifications.subtitle")} /> -
- {t("notifications.loadError")} -
+ refetch()} + />
); } @@ -166,6 +206,7 @@ export default function NotificationsPage() {
@@ -173,8 +214,10 @@ export default function NotificationsPage() {

{t("notifications.noChannels")}

+ {/* Channel setup lives in Settings → Integrations since v1.25.7; + point the CTA at that anchor, not the reminder-types screen. */}
- {/* Info hint about disabled channels */} + {/* Info hint about globally-disabled channels — a full sentence that + explains the state, not the bare "(Disabled)" inline tag. */} {channels.some((ch) => !ch.globallyEnabled) && (

- {t("notifications.channelDisabled")} + {t("notifications.someChannelsGloballyDisabled")}

)} diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 21a537a54..4d9a34839 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -21,12 +21,14 @@ * `connections-panel.tsx` so existing unit-test imports keep resolving. */ +import Link from "next/link"; import { Link2, Send } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel"; import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel"; +import { Button } from "@/components/ui/button"; export { parseOAuthOutcome, @@ -65,6 +67,15 @@ export function IntegrationsSection() { title={t("settings.sections.integrations.channelsHeading")} description={t("settings.sections.integrations.channelsDescription")} /> + {/* Names the per-event notification matrix so the three notification + surfaces (matrix, reminder types, channels) cross-reference. */} +
+ +
diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 8ba29af03..05389e73a 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { Bell } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; @@ -7,6 +8,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { LowStockCard } from "@/components/settings/low-stock-card"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; +import { Button } from "@/components/ui/button"; /** * `` — Settings → Notifications ("Benachrichtigungen"). @@ -44,6 +46,21 @@ export function NotificationsSection() { return (
+ {/* Cross-links to the other two notification surfaces so this screen + names them — the per-event matrix and the delivery channels. */} +
+ + +
+ {/* Group 1 — Reminders ("what you receive"). Rendered only when at least one reminder-type card is visible, so a fully-disabled account never shows an empty heading. */} From 329f78d2957aa6314eaed5fd51c55ff7e46050c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:43:11 +0200 Subject: [PATCH 05/21] perf(import): batch CSV measurement upserts instead of one round-trip per row The CSV importer ran a serial prisma.upsert/create per accepted row, so an N-row file cost N round-trips and a few-thousand-row import could approach the request timeout. Adopt the /measurements/batch shape: pre-fetch existing rows under the IMPORT source in one query per dedup-key shape (externalId and natural-key paths), partition into bulk createMany + grouped updateMany, and reconstruct the per-line inserted/updated/duplicate envelope from the partition. In-file repeats and re-import idempotency preserved; response contract unchanged. --- .../api/import/csv/__tests__/route.test.ts | 127 +++++++++-- src/app/api/import/csv/route.ts | 211 ++++++++++++------ 2 files changed, 251 insertions(+), 87 deletions(-) diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index b663f67c3..a1bb577a0 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -8,11 +8,23 @@ vi.mock("@/lib/api-handler", () => ({ })), })); -vi.mock("@/lib/db", () => ({ - prisma: { - measurement: { create: vi.fn(), upsert: vi.fn() }, - }, -})); +vi.mock("@/lib/db", () => { + const measurement = { + findMany: vi.fn(), + createMany: vi.fn(), + updateMany: vi.fn(), + }; + return { + prisma: { + measurement, + // Run the batched write callback against the same measurement mock so + // createMany / updateMany calls are observable. + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => + fn({ measurement }), + ), + }, + }; +}); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), @@ -83,6 +95,23 @@ beforeEach(() => { remaining: 4, resetAt: new Date(Date.now() + 1000), } as never); + // Default: nothing pre-exists; the transaction runner is re-stubbed by the + // db mock factory (resetAllMocks clears its implementation), so restore it. + const measurement = prisma.measurement as unknown as { + findMany: ReturnType; + createMany: ReturnType; + updateMany: ReturnType; + }; + measurement.findMany.mockResolvedValue([]); + measurement.createMany.mockResolvedValue({ count: 0 }); + measurement.updateMany.mockResolvedValue({ count: 0 }); + ( + prisma.$transaction as unknown as { + mockImplementation: (f: unknown) => void; + } + ).mockImplementation(async (fn: (tx: unknown) => unknown) => + fn({ measurement }), + ); }); describe("POST /api/import/csv — rate limit", () => { @@ -103,18 +132,23 @@ describe("POST /api/import/csv — rate limit", () => { }); }); +const mMeasurement = () => + prisma.measurement as unknown as { + findMany: ReturnType; + createMany: ReturnType; + updateMany: ReturnType; + }; + describe("POST /api/import/csv — fatal header error", () => { it("returns 422 when a required column is missing", async () => { const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg")); expect(res.status).toBe(422); - expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled(); + expect(mMeasurement().createMany).not.toHaveBeenCalled(); }); }); -describe("POST /api/import/csv — write loop + per-row envelope", () => { +describe("POST /api/import/csv — batched write + per-row envelope", () => { it("inserts valid rows, skips invalid ones, returns per-row status", async () => { - vi.mocked(prisma.measurement.create).mockResolvedValue({} as never); - const res = await POST( csvRequest( [ @@ -135,12 +169,29 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => { const reasons = body.data?.rows.map((r) => r.reason); expect(reasons).toContain("unknown_type"); expect(reasons).toContain("missing_timezone_offset"); - // One create call for the single valid row. - expect(vi.mocked(prisma.measurement.create)).toHaveBeenCalledTimes(1); + // One bulk createMany carrying the single valid survivor. + expect(mMeasurement().createMany).toHaveBeenCalledTimes(1); + const arg = mMeasurement().createMany.mock.calls[0][0]; + expect(arg.data).toHaveLength(1); // Rollup re-fold fired for the touched (type, day). expect(vi.mocked(recomputeBucketsForMeasurement)).toHaveBeenCalled(); }); + it("batches many rows into a single createMany (no per-row round-trip)", async () => { + const rows = [HEADER]; + for (let i = 0; i < 50; i++) { + const mm = String(i).padStart(2, "0"); // unique minute → unique measuredAt + rows.push(`WEIGHT,${80 + i * 0.1},kg,2026-05-01T08:${mm}:00Z,,,`); + } + const res = await POST(csvRequest(rows.join("\n"))); + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(50); + // 50 rows, one createMany call — the whole point of the batch. + expect(mMeasurement().createMany).toHaveBeenCalledTimes(1); + expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(50); + }); + it("dryRun previews without writing and reports projected inserts", async () => { const res = await POST( csvRequest( @@ -153,17 +204,14 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => { expect(body.data?.dryRun).toBe(true); expect(body.data?.inserted).toBe(1); expect(body.data?.rows[0].status).toBe("inserted"); - expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled(); + expect(mMeasurement().createMany).not.toHaveBeenCalled(); expect(vi.mocked(recomputeBucketsForMeasurement)).not.toHaveBeenCalled(); }); - it("upserts an externalId row and surfaces it as updated when it already existed", async () => { - const created = new Date("2026-05-01T08:00:00Z"); - const bumped = new Date("2026-05-02T08:00:00Z"); - vi.mocked(prisma.measurement.upsert).mockResolvedValue({ - createdAt: created, - updatedAt: bumped, - } as never); + it("surfaces an externalId row as updated when it already existed (updateMany)", async () => { + mMeasurement().findMany.mockResolvedValue([ + { type: "WEIGHT", externalId: "ext-1" }, + ]); const res = await POST( csvRequest( @@ -174,13 +222,28 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => { expect(body.data?.updated).toBe(1); expect(body.data?.inserted).toBe(0); expect(body.data?.rows[0].status).toBe("updated"); - expect(vi.mocked(prisma.measurement.upsert)).toHaveBeenCalledTimes(1); + expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1); + expect(mMeasurement().createMany).not.toHaveBeenCalled(); }); - it("counts a unique-constraint duplicate as skipped/duplicate", async () => { - vi.mocked(prisma.measurement.create).mockRejectedValue( - new Error("Unique constraint failed"), + it("inserts an externalId row when it does not exist yet", async () => { + mMeasurement().findMany.mockResolvedValue([]); + const res = await POST( + csvRequest( + [HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,ext-2"].join("\n"), + ), ); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.updated).toBe(0); + expect(body.data?.rows[0].status).toBe("inserted"); + expect(mMeasurement().createMany).toHaveBeenCalledTimes(1); + }); + + it("counts a pre-existing natural-key row as skipped/duplicate", async () => { + mMeasurement().findMany.mockResolvedValue([ + { type: "WEIGHT", measuredAt: new Date("2026-05-01T08:00:00Z") }, + ]); const res = await POST( csvRequest([HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n")), @@ -192,5 +255,23 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => { status: "skipped", reason: "duplicate", }); + expect(mMeasurement().createMany).not.toHaveBeenCalled(); + }); + + it("collapses an in-file duplicate (same type+measuredAt) to one insert + one duplicate", async () => { + const res = await POST( + csvRequest( + [ + HEADER, + "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,", + "WEIGHT,80.6,kg,2026-05-01T08:00:00Z,,,", + ].join("\n"), + ), + ); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.skipped).toBe(1); + // Only the first survivor reaches the bulk insert. + expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(1); }); }); diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index ae6a53de9..7fdd868d7 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -19,6 +19,8 @@ import type { MeasurementType, GlucoseContext, } from "@/generated/prisma/client"; +import { Prisma } from "@/generated/prisma/client"; +import type { NormalisedMeasurementRow } from "@/lib/import/csv-measurements"; /** * CSV measurement import (v1.17.1). @@ -106,84 +108,165 @@ export const POST = apiHandler(async (request: NextRequest) => { const writeOutcome = new Map(); if (!dryRun) { - for (const result of okRows) { - const m = result.row; - try { - if (m.externalId) { - // Idempotent re-import keyed on the source-stable id — re-uploading - // the same file updates in place rather than minting duplicates. - const res = await prisma.measurement.upsert({ + // Batched write. The previous shape ran one serial `upsert` (or `create`) + // per accepted row — N round-trips for an N-row file, which on a + // few-thousand-row import approaches the request timeout. This adopts the + // fast shape `/measurements/batch` uses: pre-fetch the existing rows under + // the IMPORT source in one query per dedup-key shape, partition into bulk + // insert / update sets, then `createMany` + grouped `updateMany`. The + // per-line inserted/updated/duplicate envelope is reconstructed from the + // partition, so the response contract is unchanged. + + const buildCreateData = ( + m: NormalisedMeasurementRow, + ): Prisma.MeasurementCreateManyInput => ({ + userId, + type: m.type as MeasurementType, + value: m.value, + unit: m.unit, + source: "IMPORT", + ...(m.externalId ? { externalId: m.externalId } : {}), + measuredAt: m.measuredAt, + notes: null, + notesEncrypted: encryptNote(m.notes ?? null), + glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null, + }); + + const extRows = okRows.filter((r) => r.row.externalId); + const plainRows = okRows.filter((r) => !r.row.externalId); + + // --- externalId rows: idempotent upsert on + // (userId, type, source=IMPORT, externalId). One probe fetches every + // pre-existing key; a hit means the row updates in place. + const extExisting = + extRows.length > 0 + ? await prisma.measurement.findMany({ where: { - userId_type_source_externalId: { - userId, - type: m.type as MeasurementType, - source: "IMPORT", - externalId: m.externalId, - }, - }, - update: { - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt, - notes: null, - notesEncrypted: encryptNote(m.notes ?? null), - glucoseContext: - (m.glucoseContext as GlucoseContext | undefined) ?? null, - }, - create: { userId, - type: m.type as MeasurementType, - value: m.value, - unit: m.unit, source: "IMPORT", - externalId: m.externalId, - measuredAt: m.measuredAt, - notes: null, - notesEncrypted: encryptNote(m.notes ?? null), - glucoseContext: - (m.glucoseContext as GlucoseContext | undefined) ?? null, + OR: extRows.map((r) => ({ + type: r.row.type as MeasurementType, + externalId: r.row.externalId as string, + })), }, - select: { createdAt: true, updatedAt: true }, - }); - // A fresh create has createdAt === updatedAt; an in-place update - // bumps updatedAt past createdAt. - if (res.updatedAt.getTime() > res.createdAt.getTime()) { - updated++; - writeOutcome.set(result.line, "updated"); - } else { - inserted++; - writeOutcome.set(result.line, "inserted"); - } - } else { - await prisma.measurement.create({ - data: { + select: { type: true, externalId: true }, + }) + : []; + const extExistingSet = new Set( + extExisting.map((e) => `${e.type}::${e.externalId}`), + ); + + // Partition in file order, honouring in-file repeats: the first sighting + // of a brand-new key inserts, every later sighting (or any sighting of a + // key already in the DB) updates in place. The last value for a key wins, + // matching the old sequential-upsert overwrite. Insert/update maps are + // keyed by (type, externalId) so a repeated key collapses to one write. + const seenExt = new Set(); + const extInserts = new Map(); + const extUpdates = new Map(); + for (const result of extRows) { + const m = result.row; + const key = `${m.type}::${m.externalId}`; + if (extExistingSet.has(key)) { + extUpdates.set(key, m); + updated++; + writeOutcome.set(result.line, "updated"); + } else if (seenExt.has(key)) { + // New-to-DB key seen again in this file: still a single create with + // the latest value, but this line reads as an update just as the + // sequential upsert did. + extInserts.set(key, m); + updated++; + writeOutcome.set(result.line, "updated"); + } else { + seenExt.add(key); + extInserts.set(key, m); + inserted++; + writeOutcome.set(result.line, "inserted"); + } + touchedMeasurements.push({ + type: m.type as MeasurementType, + measuredAt: m.measuredAt, + }); + } + + // --- externalId-less rows: create-only, deduped on the natural unique key + // (userId, type, measuredAt, source=IMPORT, sleepStage=null). A collision + // — against the DB or an earlier row in this same file — is a duplicate, + // never an overwrite (each sample is a canonical reading). + const plainExisting = + plainRows.length > 0 + ? await prisma.measurement.findMany({ + where: { userId, - type: m.type as MeasurementType, - value: m.value, - unit: m.unit, source: "IMPORT", - measuredAt: m.measuredAt, - notes: null, - notesEncrypted: encryptNote(m.notes ?? null), - glucoseContext: - (m.glucoseContext as GlucoseContext | undefined) ?? null, + OR: plainRows.map((r) => ({ + type: r.row.type as MeasurementType, + measuredAt: r.row.measuredAt, + })), }, - }); - inserted++; - writeOutcome.set(result.line, "inserted"); - } + select: { type: true, measuredAt: true }, + }) + : []; + const plainExistingSet = new Set( + plainExisting.map((e) => `${e.type}::${e.measuredAt.getTime()}`), + ); + const seenPlain = new Set(); + const plainInserts: NormalisedMeasurementRow[] = []; + for (const result of plainRows) { + const m = result.row; + const key = `${m.type}::${m.measuredAt.getTime()}`; + if (plainExistingSet.has(key) || seenPlain.has(key)) { + skipped++; + writeOutcome.set(result.line, "duplicate"); + } else { + seenPlain.add(key); + plainInserts.push(m); + inserted++; + writeOutcome.set(result.line, "inserted"); touchedMeasurements.push({ type: m.type as MeasurementType, measuredAt: m.measuredAt, }); - } catch { - // Unique-constraint violation (a NULL-externalId duplicate against an - // existing row) — count as skipped rather than failing the file. - skipped++; - writeOutcome.set(result.line, "duplicate"); } } + // Bulk write: createMany the survivors (chunked under the PG parameter + // cap, skipDuplicates to absorb a concurrent double-submit race), then + // grouped updateMany for the pre-existing externalId rows. + const toCreate: Prisma.MeasurementCreateManyInput[] = [ + ...[...extInserts.values()].map(buildCreateData), + ...plainInserts.map(buildCreateData), + ]; + const CREATE_CHUNK = 200; + await prisma.$transaction(async (tx) => { + for (let i = 0; i < toCreate.length; i += CREATE_CHUNK) { + await tx.measurement.createMany({ + data: toCreate.slice(i, i + CREATE_CHUNK), + skipDuplicates: true, + }); + } + for (const m of extUpdates.values()) { + await tx.measurement.updateMany({ + where: { + userId, + source: "IMPORT", + type: m.type as MeasurementType, + externalId: m.externalId as string, + }, + data: { + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + notes: null, + notesEncrypted: encryptNote(m.notes ?? null), + glucoseContext: + (m.glucoseContext as GlucoseContext | undefined) ?? null, + }, + }); + } + }); + // One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV // pays at most ~N (type, day) recomputes, not 10 000 per-row hooks. // Best-effort: a populator hiccup never fails the importer. From dbc2b15b1b7af585ea66d4aa70395787e7414a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:44:10 +0200 Subject: [PATCH 06/21] fix(coach,insights,achievements): honest error states + Plans discoverability - Achievements: a failed load renders QueryErrorCard instead of falling through to zeroed summary tiles and a 'none unlocked yet' empty grid. - /coach/conversations: a load failure now shows a retry instead of a false 'no conversations yet' empty state. - Insights overview error uses the shared QueryErrorCard. - Coach Plans gains a header cross-link from /coach/conversations (and back), a skeleton loading state, and the shared EmptyState primitive. --- src/app/achievements/page.tsx | 18 ++++++++++- src/app/coach/conversations/page.tsx | 22 ++++++++++++-- src/app/coach/plans/page.tsx | 45 ++++++++++++++++++---------- src/app/insights/page.tsx | 25 +++++----------- 4 files changed, 73 insertions(+), 37 deletions(-) diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 6e4c2fae1..dc9724cae 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -33,6 +33,7 @@ import { Progress } from "@/components/ui/progress"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/format"; @@ -271,7 +272,7 @@ export default function AchievementsPage() { // alongside `` and // `` so the three consumers never trigger // more than one network call on a cold dashboard mount. - const { data, isLoading } = useAchievementsQuery({ + const { data, isLoading, isError, refetch } = useAchievementsQuery({ enabled: isAuthenticated && achievementsEnabled, }); @@ -302,6 +303,21 @@ export default function AchievementsPage() { ); } + // A failed load must never fall through to the zeroed summary tiles + the + // "none unlocked yet" empty grid (UI-STANDARDS §6). Surface an honest + // retry instead. + if (isError) { + return ( +
+ + refetch()} /> +
+ ); + } + const summary = data?.summary; const achievements = data?.achievements ?? []; const grouped = groupByCategory(achievements); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 6110786cb..3c55a37c3 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -1,12 +1,14 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; -import { MessagesSquare, Search, Trash2 } from "lucide-react"; +import { MessagesSquare, Search, Target, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread"; import { useCoachConversations, @@ -92,7 +94,8 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] { function CoachConversationsBody() { const { t } = useTranslations(); const router = useRouter(); - const { conversations, isLoading } = useCoachConversations(); + const { conversations, isLoading, isError, refetch } = + useCoachConversations(); const deleteMutation = useDeleteCoachConversation(); const [filter, setFilter] = useState(""); const [confirmId, setConfirmId] = useState(null); @@ -129,6 +132,16 @@ function CoachConversationsBody() { {t("insights.coach.historyTitle")} } + actions={ + // Surface the sibling Plans ledger — otherwise it is reachable + // only from the composer's `+` menu. + + } />
{t("common.loading")}

+ ) : isError && conversations.length === 0 ? ( + // A load failure must not masquerade as "no conversations yet" — + // that reads as a data-loss scare and offers no way back. Mirror + // /coach/plans and surface a retry. + refetch()} /> ) : groups.length === 0 ? (
{t("coach.plans.title")} } description={t("coach.plans.pageDescription")} + actions={ + + } />
query.refetch()} /> ) : query.isLoading ? ( -

- {t("common.loading")} -

- ) : groups.length === 0 ? ( + ) : groups.length === 0 ? ( +
+ } + title={t("coach.plans.empty")} + />
) : ( groups.map((group) => ( diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 66ef264ed..8340bd4b5 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react"; +import { SlidersHorizontal, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -20,6 +20,7 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { HeroStrip } from "@/components/insights/hero-strip"; @@ -340,23 +341,11 @@ export default function InsightsPage() { // mirroring the error pattern. if (isError) { return ( -
- {t("insights.loadError")} - +
+ refetch()} + />
); } From 3fcae204f738fbd14444a21a4dfc9a3c2150200b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:45:04 +0200 Subject: [PATCH 07/21] perf(backup): keyset-page the intake/mood/cycle reads in the off-host DR dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measurements were already paged, but the medication-intake, mood, and cycle tables in the same per-user payload were still unbounded full-table findMany reads — peak heap tracked the largest tenant's history. Give them the same id-keyset page walk (full row projection, so payload bytes are unchanged) so at most one page is resident per round-trip. --- src/lib/jobs/__tests__/offhost-backup.test.ts | 51 ++++++++++++++ src/lib/jobs/offhost-backup.ts | 68 +++++++++++++++++-- 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index b2ffc0398..307617c02 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -6,6 +6,7 @@ import { runOffhostBackup, runOffhostRoundtripTest, } from "../offhost-backup"; +import { MEASUREMENT_BACKUP_PAGE_SIZE } from "../backup/measurement-page"; const ENC_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -138,6 +139,56 @@ describe("runOffhostBackup", () => { expect(parsed.userId).toBe("u1"); }); + it("keyset-pages the intake/mood/cycle tables across multiple pages", async () => { + const s3 = makeS3Mock(); + // A full first page (=== page size) followed by a short page proves the + // reader walks the id cursor instead of one unbounded findMany. + const fullPage = Array.from( + { length: MEASUREMENT_BACKUP_PAGE_SIZE }, + (_, i) => ({ + id: `e${String(i).padStart(5, "0")}`, + userId: "u1", + takenAt: new Date("2026-05-01T00:00:00Z"), + }), + ); + const tailPage = [ + { id: "z9999", userId: "u1", takenAt: new Date("2026-05-02T00:00:00Z") }, + ]; + const intakeFindMany = vi.fn( + async (args: { where?: { id?: { gt?: string } } }) => + args?.where?.id?.gt ? tailPage : fullPage, + ); + + const prisma = { + user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) }, + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: intakeFindMany }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + }; + + const report = await runOffhostBackup( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + s3, + new Date("2026-05-08T00:00:00Z"), + ); + expect(report.uploaded).toBe(1); + // Two round-trips: the full page, then the short tail that ends the walk. + expect(intakeFindMany).toHaveBeenCalledTimes(2); + expect(intakeFindMany.mock.calls[0][0]).toMatchObject({ + orderBy: { id: "asc" }, + take: MEASUREMENT_BACKUP_PAGE_SIZE, + }); + // Every paged row lands in the decrypted payload — no rows dropped. + const ct = s3.store.get("2026-05-08/user-u1.json.enc")!; + const parsed = JSON.parse(decryptBackup(ct, Buffer.from(ENC_KEY, "hex"))); + expect(parsed.intakeEvents).toHaveLength(MEASUREMENT_BACKUP_PAGE_SIZE + 1); + }); + it("counts per-user failures without aborting the whole run", async () => { const s3 = makeS3Mock(); const prisma = { diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 2a6118c1a..d0c2ee672 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -279,16 +279,70 @@ export async function runOffhostBackup( where: { userId: user.id }, include: { schedules: true }, }), - prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }), - prisma.moodEntry.findMany({ where: { userId: user.id } }), + // The intake / mood / cycle tables scale with history depth the same + // way measurements do (multiple intakes + a mood + a cycle-day-log per + // day over years), so they get the same keyset-paged read: at most one + // page is resident per round-trip instead of the entire table landing + // in one array alongside the giant JSON.stringify below. The projection + // keeps the full row (no select), so payload bytes are identical to the + // prior default findMany — only peak heap during the read is bounded. + collectPagedMeasurements({ + fetchPage: (afterId, take) => + prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + ...(afterId ? { id: { gt: afterId } } : {}), + }, + orderBy: { id: "asc" }, + take, + }), + project: (row) => row, + pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, + }), + collectPagedMeasurements({ + fetchPage: (afterId, take) => + prisma.moodEntry.findMany({ + where: { + userId: user.id, + ...(afterId ? { id: { gt: afterId } } : {}), + }, + orderBy: { id: "asc" }, + take, + }), + project: (row) => row, + pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, + }), // v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a // disaster-recovery dump, not the canonical user-facing backup); - // `notesEncrypted` stays ciphertext. + // `notesEncrypted` stays ciphertext. The profile is a single row, so it + // stays a direct read; the cycle + day-log tables are paged. prisma.cycleProfile.findUnique({ where: { userId: user.id } }), - prisma.menstrualCycle.findMany({ where: { userId: user.id } }), - prisma.cycleDayLog.findMany({ - where: { userId: user.id }, - include: { symptomLinks: true }, + collectPagedMeasurements({ + fetchPage: (afterId, take) => + prisma.menstrualCycle.findMany({ + where: { + userId: user.id, + ...(afterId ? { id: { gt: afterId } } : {}), + }, + orderBy: { id: "asc" }, + take, + }), + project: (row) => row, + pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, + }), + collectPagedMeasurements({ + fetchPage: (afterId, take) => + prisma.cycleDayLog.findMany({ + where: { + userId: user.id, + ...(afterId ? { id: { gt: afterId } } : {}), + }, + include: { symptomLinks: true }, + orderBy: { id: "asc" }, + take, + }), + project: (row) => row, + pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, }), ]); From 9f4c7e48fe5efe316cdfcabef8c4dbba2acd3fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:47:46 +0200 Subject: [PATCH 08/21] fix(ui): bake password-manager autofill guard into PasswordInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config/secret fields (BYOK API keys, webhook secrets, ntfy tokens) pass no autocomplete token, so PasswordInput now defaults to autoComplete=off plus data-bwignore / data-1p-ignore / data-lpignore for them — Bitwarden / 1Password / LastPass no longer offer to fill or save a config secret. Credential fields (login, register, password change) pass a real token and keep manager integration untouched. One change covers every secret call site. --- .../ui/__tests__/password-input.test.tsx | 44 +++++++++++++++++++ src/components/ui/password-input.tsx | 14 ++++++ 2 files changed, 58 insertions(+) create mode 100644 src/components/ui/__tests__/password-input.test.tsx diff --git a/src/components/ui/__tests__/password-input.test.tsx b/src/components/ui/__tests__/password-input.test.tsx new file mode 100644 index 000000000..dadb1389d --- /dev/null +++ b/src/components/ui/__tests__/password-input.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { PasswordInput } from "../password-input"; + +/** + * PasswordInput bakes the password-manager autofill guard in by default so + * BYOK API keys / webhook secrets / ntfy tokens (config secrets, not + * credentials) are never offered up to Bitwarden / 1Password / LastPass. + * Credential fields that pass a real autocomplete token keep manager + * integration. + */ +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("PasswordInput", () => { + it("suppresses password managers by default for config secrets", () => { + const html = render(); + expect(html).toMatch(/autocomplete="off"/i); + expect(html).toContain("data-bwignore"); + expect(html).toContain("data-1p-ignore"); + expect(html).toContain('data-lpignore="true"'); + }); + + it("treats an explicit autoComplete=off as a config secret too", () => { + const html = render(); + expect(html).toContain("data-bwignore"); + expect(html).toContain("data-1p-ignore"); + }); + + it("keeps manager integration for credential fields", () => { + const html = render( + , + ); + expect(html).toMatch(/autocomplete="current-password"/i); + expect(html).not.toContain("data-bwignore"); + expect(html).not.toContain("data-1p-ignore"); + expect(html).not.toContain("data-lpignore"); + }); +}); diff --git a/src/components/ui/password-input.tsx b/src/components/ui/password-input.tsx index 7e1ac7625..e9149c3ce 100644 --- a/src/components/ui/password-input.tsx +++ b/src/components/ui/password-input.tsx @@ -22,14 +22,28 @@ import { useTranslations } from "@/lib/i18n/context"; */ export function PasswordInput({ className, + autoComplete, ...props }: React.ComponentProps) { const [visible, setVisible] = useState(false); const { t } = useTranslations(); + + // Config/secret fields (BYOK API keys, webhook secrets, ntfy tokens) pass no + // `autoComplete` — they are not credentials, so a password manager offering + // to fill or *save* them is noise and risks clobbering the field. Suppress + // Bitwarden / 1Password / LastPass by default for those. Credential fields + // (login, register, password change) pass a real token + // (`current-password` / `new-password` / `username`) and keep manager + // integration; only an explicit `"off"` opts back into suppression. + const suppressManagers = autoComplete == null || autoComplete === "off"; return (
From 80bbf2ba3b435a6c2baba6333cf3da87acf49b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:48:24 +0200 Subject: [PATCH 09/21] perf(ui): harden theme-provider hydration seam + cache per-row Intl formatters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theme provider now initializes to the SSR-stable default and reconciles the stored preference in a mount effect instead of reading localStorage in the useState initializer — closes the same React #418 hydration seam the sidebar already fixed (the inline script still handles the pre-paint class, so no FOUC). Add a process-wide Intl formatter cache and route the per-row workout/medication list helpers through it so a formatter is constructed once per (locale, options) rather than once per row per render. --- .../dashboard/recent-workouts-tile.tsx | 3 +- src/components/insights/workout-detail.tsx | 9 ++-- src/components/insights/workout-list.tsx | 7 +-- .../medications/medication-card.tsx | 3 +- src/components/providers.tsx | 47 ++++++++++-------- .../intl/__tests__/formatter-cache.test.ts | 40 +++++++++++++++ src/lib/intl/formatter-cache.ts | Bin 0 -> 1614 bytes 7 files changed, 80 insertions(+), 29 deletions(-) create mode 100644 src/lib/intl/__tests__/formatter-cache.test.ts create mode 100644 src/lib/intl/formatter-cache.ts diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index a4557b5c0..2368e1962 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -15,6 +15,7 @@ import { import { useTranslations } from "@/lib/i18n/context"; import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; import { cn } from "@/lib/utils"; +import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; /** * v1.4.32 — ``. @@ -70,7 +71,7 @@ function formatDuration(seconds: number): string { function formatDayLabel(iso: string, locale: string): string { const d = new Date(iso); - return new Intl.DateTimeFormat(locale, { + return getDateTimeFormat(locale, { weekday: "short", day: "2-digit", month: "short", diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx index 4087e4bd3..6ca8783df 100644 --- a/src/components/insights/workout-detail.tsx +++ b/src/components/insights/workout-detail.tsx @@ -17,6 +17,7 @@ import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { cn } from "@/lib/utils"; +import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; import { hourCycleOptions, @@ -73,7 +74,7 @@ function formatDuration(seconds: number): string { function formatDistanceKm(meters: number, locale: string): string { const km = meters / 1000; - return new Intl.NumberFormat(locale, { + return getNumberFormat(locale, { maximumFractionDigits: 2, minimumFractionDigits: km < 10 ? 2 : 1, }).format(km); @@ -84,7 +85,7 @@ function formatNumber( locale: string, fractionDigits = 0, ): string { - return new Intl.NumberFormat(locale, { + return getNumberFormat(locale, { maximumFractionDigits: fractionDigits, minimumFractionDigits: fractionDigits, }).format(value); @@ -111,13 +112,13 @@ function formatDateRange( ): string { const start = new Date(startedAt); const end = new Date(endedAt); - const dateFmt = new Intl.DateTimeFormat(locale, { + const dateFmt = getDateTimeFormat(locale, { weekday: "long", day: "2-digit", month: "long", year: "numeric", }); - const timeFmt = new Intl.DateTimeFormat(locale, { + const timeFmt = getDateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", ...hourCycleOptions(timeFormat), diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 3f17d9fb5..712aaf77b 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; +import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; import { hourCycleOptions, @@ -68,14 +69,14 @@ function formatDuration(seconds: number): string { function formatDistanceKm(meters: number, locale: string): string { const km = meters / 1000; - return new Intl.NumberFormat(locale, { + return getNumberFormat(locale, { maximumFractionDigits: 2, minimumFractionDigits: km < 10 ? 2 : 1, }).format(km); } function formatEnergy(kcal: number, locale: string): string { - return new Intl.NumberFormat(locale, { + return getNumberFormat(locale, { maximumFractionDigits: 0, }).format(Math.round(kcal)); } @@ -86,7 +87,7 @@ function formatDate( timeFormat: TimeFormatPreference, ): string { const d = new Date(iso); - return new Intl.DateTimeFormat(locale, { + return getDateTimeFormat(locale, { weekday: "short", day: "2-digit", month: "short", diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 7f0084300..8c52a5091 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -10,6 +10,7 @@ import { MedicationCardBody } from "@/components/medications/card-parts/medicati import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { formatDateTime, formatTime } from "@/lib/format"; +import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { formatDose } from "@/lib/medications/format-dose"; import { @@ -335,7 +336,7 @@ export function MedicationCard({ // Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and // string-comparable for the today / yesterday / older bucketing below. // The actual user-facing display goes through formatTime / formatDateTime. - const dayFormatter = new Intl.DateTimeFormat("en-CA", { + const dayFormatter = getDateTimeFormat("en-CA", { timeZone: userTz, year: "numeric", month: "2-digit", diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 10ec7fae9..db1f37cd7 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -61,29 +61,36 @@ function applyTheme(resolved: "dark" | "light") { document.documentElement.classList.add(resolved); } +function readStoredTheme(): Theme { + if (typeof window === "undefined") return "dark"; + const saved = localStorage.getItem("healthlog-theme") as Theme | null; + // A stored "light"/"dark"/"system" choice wins. With no stored + // preference the app defaults to dark rather than tracking the OS. + if (saved === "light" || saved === "dark" || saved === "system") return saved; + return "dark"; +} + function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setThemeState] = useState(() => { - if (typeof window === "undefined") return "dark"; - const saved = localStorage.getItem("healthlog-theme") as Theme | null; - // A stored "light"/"dark"/"system" choice wins. With no stored - // preference the app defaults to dark rather than tracking the OS. - if (saved === "light" || saved === "dark") return saved; - if (saved === "system") return "system"; - return "dark"; - }); + // Initialize to the SSR-stable default on BOTH the server and the client's + // first render so the hydrated React tree matches; the persisted preference + // is reconciled in a mount effect below. Reading localStorage in the + // `useState` initializer instead diverges server ("dark") from a client that + // stored "light"/"system", which is the exact React #418 hydration seam this + // repo has hit before (the sidebar matchMedia/localStorage-in-initializer). + // The nonce-bound inline script in layout.tsx has already stamped the correct + // class on pre-hydration, so there is no visual FOUC from deferring + // the reconcile to an effect. + const [theme, setThemeState] = useState("dark"); + const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">("dark"); - const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() => { - if (typeof window === "undefined") return "dark"; - const saved = localStorage.getItem("healthlog-theme") as Theme | null; - if (saved === "light" || saved === "dark") return saved; - if (saved === "system") return getSystemTheme(); - return "dark"; - }); - - // Apply theme on mount (inline script handles FOUC, this ensures classes are in sync) + // Reconcile the stored preference after mount + keep the class in sync (the + // inline script handles the pre-paint class; this keeps React state honest). useEffect(() => { - applyTheme(resolvedTheme); - // eslint-disable-next-line react-hooks/exhaustive-deps + const stored = readStoredTheme(); + const resolved = stored === "system" ? getSystemTheme() : stored; + setThemeState(stored); + setResolvedTheme(resolved); + applyTheme(resolved); }, []); // Listen for OS preference changes when in system mode diff --git a/src/lib/intl/__tests__/formatter-cache.test.ts b/src/lib/intl/__tests__/formatter-cache.test.ts new file mode 100644 index 000000000..e55e3b643 --- /dev/null +++ b/src/lib/intl/__tests__/formatter-cache.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; + +import { getNumberFormat, getDateTimeFormat } from "../formatter-cache"; + +describe("formatter-cache", () => { + it("returns the same NumberFormat instance for identical (locale, options)", () => { + const a = getNumberFormat("en", { maximumFractionDigits: 2 }); + const b = getNumberFormat("en", { maximumFractionDigits: 2 }); + expect(a).toBe(b); + }); + + it("returns distinct NumberFormat instances for different options", () => { + const a = getNumberFormat("en", { maximumFractionDigits: 2 }); + const b = getNumberFormat("en", { maximumFractionDigits: 0 }); + expect(a).not.toBe(b); + }); + + it("returns distinct instances per locale", () => { + const a = getNumberFormat("en", { maximumFractionDigits: 1 }); + const b = getNumberFormat("de", { maximumFractionDigits: 1 }); + expect(a).not.toBe(b); + }); + + it("caches DateTimeFormat and still formats correctly", () => { + const a = getDateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const b = getDateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + expect(a).toBe(b); + expect(a.format(new Date("2026-05-08T12:00:00Z"))).toBe("2026-05-08"); + }); +}); diff --git a/src/lib/intl/formatter-cache.ts b/src/lib/intl/formatter-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc624f4584445c39ac68b00c497a9de95ffe3767 GIT binary patch literal 1614 zcmb`G!EVz)5Qcm9Qw&uPc5u>rL%D#E0MR1R2k>S+ZkF1+*6z9?sv_|;JQz>HziT@r zxJ5DFoX=(n&FHf=lAW7xRLLZTTr*Y1Qt@8hx4GKwDUqkD5pE#a!IQ#)kcb%=_Q%gE2G^47sK5*c=xuGUg=vBdJvrzAusZH zwxk?M)3m}0ukN|t5`Sx1JG?K+@jY8n2c=p$KUba`T65j9b+qbK?a62+e3CVr{>i$; zXIgBGy)&Jkhrl8a5`|^S+RukSZthjhMNUP-rIJW*^jr#C;^^o1AK8L#*6f&WIn?RM zvhS=W(UU8K;)sHrArG2z->%X4a6WX`3RR*^+1FX39HphFv}cx&$g&{l)}rMo%K|~w z?>O+S7u4Gj%MlhAo2K((70JUC&o%7LQO%%bBX)9l;Lg5UA+2%14oMd(bfV(Xv5%vr z#op7?lT$eR;mh^a0%k%>RoiqhIxFDPco?z)Lr9*Y$!!kTP^+l3u}LzkuZ-q{VRk*jpudsz#k8*LemnCdd*mlQAhz!rdrg;AoIUh2 zusb<>c*6c&u?#i9fU5C``IFWH6{nB`4_(3}vQY)6QvsXVL?@_EpT uId-@=Y$}eH$u4;e;xX&zU_6ePfIR;FUr?5l$@v$U#|1rs^N|$K0Qw8m$t#Bd literal 0 HcmV?d00001 From 451946b4fa61b6e77fc95bf76d42fd7e8ccaf62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:51:00 +0200 Subject: [PATCH 10/21] feat(admin): quiet reminder while public registration is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration stays open after the first admin so a household can be invited, but on an internet-exposed instance that means anyone who can reach the URL can sign up — and nothing prompted the admin to close it. Surface a muted hint under the registration toggle whenever it is open (no behaviour change), and note the reality in getting-started's first-user section. --- docs/self-hosting/getting-started.md | 4 ++-- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/components/admin/general-settings-section.tsx | 13 +++++++++++++ 8 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index c5d72fdef..c24150fd4 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -101,8 +101,8 @@ After the first user lands, you can: > host that means strangers can still sign up. Once everyone who needs > an account has one, turn it off in **`/admin` → registration toggle** > (or keep it open and hand out invite links instead). The admin -> dashboard shows a reminder while registration is open and you are the -> only user. +> settings show a reminder underneath the toggle while registration is +> open. > **No email is configured out of the box** — a deliberate first-boot > simplicity win, but it also means password reset has no self-service diff --git a/messages/de.json b/messages/de.json index 5073c6e7b..d0d92b54f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5494,6 +5494,7 @@ "appSettingsDescription": "Globale Standardwerte für Registrierung, Sprache und Zeitzone neuer Konten.", "registrationEnabled": "Registrierung erlauben", "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Nutzer registrieren.", + "registrationOpenHint": "Die Registrierung ist offen — jeder, der diese Instanz erreichen kann, kann ein Konto anlegen. Schließe sie hier, sobald alle ein Konto haben.", "defaultLanguage": "Standard-Sprache", "defaultLanguageDescription": "Wird für neue Nutzer und als Fallback verwendet.", "defaultUserTimezone": "Standard-Zeitzone für neue Konten", diff --git a/messages/en.json b/messages/en.json index e85e5c5fc..915e697e4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5494,6 +5494,7 @@ "appSettingsDescription": "Global defaults for registration, language, and the timezone of new accounts.", "registrationEnabled": "Allow registration", "registrationEnabledDescription": "When disabled, new users cannot register.", + "registrationOpenHint": "Registration is open — anyone who can reach this instance can create an account. Close it here once everyone who needs an account has one.", "defaultLanguage": "Default language", "defaultLanguageDescription": "Used for new users and as fallback.", "defaultUserTimezone": "Default timezone for new accounts", diff --git a/messages/es.json b/messages/es.json index 80d3c7b3c..1bf3aa9f0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5494,6 +5494,7 @@ "appSettingsDescription": "Valores globales por defecto para registro, idioma y zona horaria de cuentas nuevas.", "registrationEnabled": "Permitir el registro", "registrationEnabledDescription": "Cuando está desactivado, los nuevos usuarios no pueden registrarse.", + "registrationOpenHint": "El registro está abierto: cualquiera que pueda acceder a esta instancia puede crear una cuenta. Ciérralo aquí cuando todos tengan una cuenta.", "defaultLanguage": "Idioma predeterminado", "defaultLanguageDescription": "Se usa para los nuevos usuarios y como reserva.", "defaultUserTimezone": "Zona horaria predeterminada para cuentas nuevas", diff --git a/messages/fr.json b/messages/fr.json index 5bd017305..568bafed0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5494,6 +5494,7 @@ "appSettingsDescription": "Valeurs globales par défaut pour l’inscription, la langue et le fuseau horaire des nouveaux comptes.", "registrationEnabled": "Autoriser l'inscription", "registrationEnabledDescription": "Lorsque cette option est désactivée, les nouveaux utilisateurs ne peuvent pas s'inscrire.", + "registrationOpenHint": "L'inscription est ouverte : toute personne pouvant accéder à cette instance peut créer un compte. Fermez-la ici une fois que tout le monde a un compte.", "defaultLanguage": "Langue par défaut", "defaultLanguageDescription": "Utilisé pour les nouveaux utilisateurs et comme solution de repli.", "defaultUserTimezone": "Fuseau horaire par défaut pour les nouveaux comptes", diff --git a/messages/it.json b/messages/it.json index 933289bb6..a0f0258b4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5494,6 +5494,7 @@ "appSettingsDescription": "Impostazioni globali predefinite per registrazione, lingua e fuso orario dei nuovi account.", "registrationEnabled": "Consentire la registrazione", "registrationEnabledDescription": "Se disattivato, i nuovi utenti non possono registrarsi.", + "registrationOpenHint": "La registrazione è aperta: chiunque possa raggiungere questa istanza può creare un account. Chiudila qui quando tutti hanno un account.", "defaultLanguage": "Lingua predefinita", "defaultLanguageDescription": "Usata per i nuovi utenti e come ripiego.", "defaultUserTimezone": "Fuso orario predefinito per i nuovi account", diff --git a/messages/pl.json b/messages/pl.json index fc43f1599..660068095 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5494,6 +5494,7 @@ "appSettingsDescription": "Globalne ustawienia domyślne rejestracji, języka i strefy czasowej nowych kont.", "registrationEnabled": "Zezwól na rejestrację", "registrationEnabledDescription": "Gdy wyłączone, nowi użytkownicy nie mogą się zarejestrować.", + "registrationOpenHint": "Rejestracja jest otwarta — każdy, kto ma dostęp do tej instancji, może założyć konto. Zamknij ją tutaj, gdy wszyscy będą mieć konto.", "defaultLanguage": "Domyślny język", "defaultLanguageDescription": "Używany dla nowych użytkowników i jako rozwiązanie zapasowe.", "defaultUserTimezone": "Domyślna strefa czasowa dla nowych kont", diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 84bda6238..ff6a671cf 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -96,6 +96,19 @@ export function GeneralSettingsSection() { disabled={updateSettings.isPending} /> + {/* Honest nudge: registration stays open after the first admin so a + household can be invited, but on an internet-exposed instance that + means anyone who can reach the URL can sign up. Surface a quiet + reminder while it is open — no behaviour change, just a signal. */} + {(settings?.registrationEnabled ?? true) && ( +

+ {t("admin.registrationOpenHint")} +

+ )} + {/* v1.23 — instance-wide "require a second factor" policy. Every account without an active factor is sent to forced enrollment after sign-in. This is a web-session nudge, not an API wall: direct API / From 930fa8112450ee8b53fca9e7caa7db28b2324d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:51:22 +0200 Subject: [PATCH 11/21] fix(a11y): switch names, contrast floors, and chart-toggle pressed state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin SettingsToggle: name the Radix switch via aria-label so it no longer announces as a bare 'switch, on/off' (one shared component, six call sites). - Replace the phantom text-destructive-foreground / text-warning-foreground utilities with the vetted destructive pattern (bg-destructive text-white dark:bg-destructive/60) and a readable foreground on the warning wash — no more dead classes riding on cascade order. - Drop the sub-AA opacity tiers: lab sparkline + titration marker off muted/70, state icons (query-error, chart empty/error, med sort) off muted/60 to the full token so they clear the light-theme floor. - Chart range toggles expose aria-pressed (lab, custom-metric, health, mood, compliance) so the active window is conveyed to assistive tech. --- src/components/admin/_shared.tsx | 5 +++++ src/components/charts/chart-empty-state.tsx | 2 +- src/components/charts/chart-error-state.tsx | 2 +- src/components/charts/compliance-line-chart.tsx | 1 + src/components/charts/health-chart.tsx | 1 + src/components/charts/mood-chart.tsx | 1 + src/components/custom-metrics/custom-metric-chart.tsx | 1 + src/components/labs/lab-biomarker-chart.tsx | 1 + src/components/labs/lab-trend-sparkline.tsx | 2 +- src/components/medications/dose-history-ledger.tsx | 2 +- src/components/medications/intake-history-editable.tsx | 4 ++-- src/components/medications/medication-table.tsx | 2 +- src/components/medications/titration-timeline.tsx | 2 +- src/components/settings/integrations/withings-card.tsx | 2 +- src/components/settings/sharing-section.tsx | 2 +- src/components/ui/query-error-card.tsx | 2 +- 16 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index b9c8ff1aa..38d907201 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -192,6 +192,11 @@ export function SettingsToggle({
` with no association to the + // Radix switch button, so a screen reader would announce a bare + // "switch, on/off". Name it explicitly — one fix covers every admin + // toggle that mounts this shared component. + aria-label={label} checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} diff --git a/src/components/charts/chart-empty-state.tsx b/src/components/charts/chart-empty-state.tsx index fe71164ad..790521df9 100644 --- a/src/components/charts/chart-empty-state.tsx +++ b/src/components/charts/chart-empty-state.tsx @@ -56,7 +56,7 @@ export function ChartEmptyState({ style={height === undefined ? undefined : { height }} >
) : isError ? ( -
-
-

- {t("medications.loadFailed")} -

- -
-
+ void refetchMedications()} + /> ) : !medications?.length ? ( // v1.4.15 phase-C5: refactor the inline icon+text+button block // to the shared EmptyState primitive so the empty path matches diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index 1f170814c..b809e8412 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -15,6 +15,7 @@ import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { EmptyState } from "@/components/ui/empty-state"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; @@ -178,23 +179,13 @@ export function CustomMetricDetail({ {description}

- - -

- {t("customMetrics.loadError")} -

- -
-
+ { + if (metricError) void refetchMetric(); + if (listError) void refetchList(); + }} + />
); } diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index e9492c6df..b7669cc0c 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -2,11 +2,13 @@ import { useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { ChevronRight, Gauge, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { apiGet } from "@/lib/api/api-fetch"; @@ -27,10 +29,11 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types"; */ export function CustomMetricList() { const { t } = useTranslations(); + const router = useRouter(); const [addOpen, setAddOpen] = useState(false); const [addFooterEl, setAddFooterEl] = useState(null); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.customMetrics(), queryFn: () => apiGet("/api/custom-metrics"), }); @@ -40,10 +43,8 @@ export function CustomMetricList() { function afterAdd(saved: CustomMetricDto) { setAddOpen(false); // Land the user on the new metric's detail page so the next step (logging - // a value) is one tap away. - if (typeof window !== "undefined") { - window.location.assign(`/custom-metrics/${saved.id}`); - } + // a value) is one tap away — client nav, not a full-page reload. + router.push(`/custom-metrics/${saved.id}`); } return ( @@ -80,9 +81,10 @@ export function CustomMetricList() { ) : isError ? ( -

- {t("customMetrics.loadError")} -

+ refetch()} + /> ) : metrics.length === 0 ? ( diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 834437e05..f28e71e0a 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -8,6 +8,16 @@ import { Loader2, Plus, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { @@ -113,6 +123,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { // into the `` footer slot. The submit button keeps // its `
` association via the HTML `form` attribute. const [footerEl, setFooterEl] = useState(null); + const [pendingDeleteId, setPendingDeleteId] = useState(null); const formId = useId(); const listKey = queryKeys.medicationSideEffects(medicationId); @@ -330,14 +341,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { size="icon" className="text-muted-foreground hover:text-destructive size-11 shrink-0" aria-label={t("medications.sideEffects.deleteCta")} - onClick={() => { - if ( - typeof window === "undefined" || - window.confirm(t("medications.sideEffects.deleteConfirm")) - ) { - deleteMutation.mutate(row.id); - } - }} + onClick={() => setPendingDeleteId(row.id)} disabled={deleteMutation.isPending} > @@ -348,6 +352,43 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
)} + {/* Themed confirm — replaces the native window.confirm so the delete + gate matches every other destructive action in the app. */} + { + if (!o) setPendingDeleteId(null); + }} + > + + + + {t("medications.sideEffects.deleteConfirmTitle")} + + + {t("medications.sideEffects.deleteConfirmBody")} + + + + + {t("common.cancel")} + + { + e.preventDefault(); + if (pendingDeleteId) deleteMutation.mutate(pendingDeleteId); + setPendingDeleteId(null); + }} + disabled={deleteMutation.isPending} + aria-busy={deleteMutation.isPending || undefined} + className="bg-destructive dark:bg-destructive/60 text-white hover:bg-destructive/90" + > + {t("medications.sideEffects.deleteCta")} + + + + + Date: Wed, 8 Jul 2026 23:55:33 +0200 Subject: [PATCH 14/21] feat(boot): print a first-boot readiness summary before fail-closed loaders throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fail-closed crypto loader throws a stack trace on a missing/short ENCRYPTION_KEY rather than a sentence an operator can act on, and check-env is a dev/CI tool the image operator never runs. Add a boot-time readiness summary (instrumentation register) that prints a clear line per core secret — with the exact openssl fix on a bad one — before anything throws, plus a one-glance status of the optional channels and the Secure-cookie transport hint. Never logs secret values; never throws. --- src/instrumentation.ts | 12 ++ .../boot/__tests__/readiness-summary.test.ts | 131 ++++++++++++ src/lib/boot/readiness-summary.ts | 197 ++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 src/lib/boot/__tests__/readiness-summary.test.ts create mode 100644 src/lib/boot/readiness-summary.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 77ab68ae8..512e1afbf 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -3,6 +3,18 @@ import type { Instrumentation } from "next"; export async function register() { // Only start the worker on the Node.js server runtime (not Edge, not build) if (process.env.NEXT_RUNTIME === "nodejs") { + // First-boot readiness summary: a human-readable block on the core secrets + // (with the exact fix on a bad one) before any fail-closed loader throws, + // plus a one-glance status of the optional channels. Runs for every process + // type (web + worker). Never throws — the real gate stays the loaders. + try { + const { logReadinessSummary } = + await import("@/lib/boot/readiness-summary"); + logReadinessSummary(); + } catch { + // A readiness summary must never break boot. + } + // Every Node process watches its own event loop — web-only // containers included; a stall report from the process serving // requests is the whole point. diff --git a/src/lib/boot/__tests__/readiness-summary.test.ts b/src/lib/boot/__tests__/readiness-summary.test.ts new file mode 100644 index 000000000..4d6211ba2 --- /dev/null +++ b/src/lib/boot/__tests__/readiness-summary.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; + +import { + collectReadiness, + formatReadiness, + logReadinessSummary, +} from "../readiness-summary"; + +const HEX32 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +function baseEnv(): Record { + return { + ENCRYPTION_KEY: HEX32, + API_TOKEN_HMAC_KEY: HEX32, + DATABASE_URL: "postgresql://healthlog:pw@db:5432/healthlog", + NODE_ENV: "production", + }; +} + +function lineFor(env: Record, label: string) { + return collectReadiness(env).lines.find((l) => l.label === label); +} + +describe("collectReadiness — core secrets", () => { + it("reports no blocker when the three core secrets are valid", () => { + const report = collectReadiness(baseEnv()); + expect(report.hasBlocker).toBe(false); + expect(lineFor(baseEnv(), "ENCRYPTION_KEY")?.status).toBe("ok"); + expect(lineFor(baseEnv(), "API_TOKEN_HMAC_KEY")?.status).toBe("ok"); + expect(lineFor(baseEnv(), "DATABASE_URL")?.status).toBe("ok"); + }); + + it("flags a missing ENCRYPTION_KEY as a blocker with the openssl fix", () => { + const env = baseEnv(); + delete env.ENCRYPTION_KEY; + const report = collectReadiness(env); + expect(report.hasBlocker).toBe(true); + const line = report.lines.find((l) => l.label === "ENCRYPTION_KEY"); + expect(line?.status).toBe("error"); + expect(line?.detail).toContain("openssl rand -hex 32"); + }); + + it("flags a too-short ENCRYPTION_KEY as a blocker", () => { + const env = baseEnv(); + env.ENCRYPTION_KEY = "deadbeef"; + expect(collectReadiness(env).hasBlocker).toBe(true); + }); + + it("accepts the ENCRYPTION_KEYS rotation map instead of the single key", () => { + const env = baseEnv(); + delete env.ENCRYPTION_KEY; + env.ENCRYPTION_KEYS = JSON.stringify({ v1: HEX32, v2: HEX32 }); + const report = collectReadiness(env); + expect(report.hasBlocker).toBe(false); + expect( + report.lines.find((l) => l.label === "ENCRYPTION_KEYS")?.status, + ).toBe("ok"); + }); + + it("flags a malformed ENCRYPTION_KEYS map", () => { + const env = baseEnv(); + delete env.ENCRYPTION_KEY; + env.ENCRYPTION_KEYS = '{"v1":"nothex"}'; + expect(collectReadiness(env).hasBlocker).toBe(true); + }); + + it("flags a missing DATABASE_URL", () => { + const env = baseEnv(); + delete env.DATABASE_URL; + expect(collectReadiness(env).hasBlocker).toBe(true); + }); +}); + +describe("collectReadiness — transport + optional", () => { + it("warns Secure-on when NODE_ENV=production and no override", () => { + const line = lineFor(baseEnv(), "SESSION_COOKIE_SECURE"); + expect(line?.detail).toContain("Secure cookie flag ON"); + }); + + it("notes Secure-off when SESSION_COOKIE_SECURE=false", () => { + const env = { ...baseEnv(), SESSION_COOKIE_SECURE: "false" }; + expect(lineFor(env, "SESSION_COOKIE_SECURE")?.detail).toContain( + "Secure cookie flag OFF", + ); + }); + + it("marks optional subsystems as warn when unconfigured", () => { + const smtp = lineFor(baseEnv(), "SMTP (email)"); + expect(smtp?.status).toBe("warn"); + }); + + it("marks off-host backup ok only when every required var is set", () => { + const env = { + ...baseEnv(), + BACKUP_S3_ENDPOINT: "https://r2", + BACKUP_S3_BUCKET: "b", + BACKUP_S3_ACCESS_KEY: "a", + BACKUP_S3_SECRET_KEY: "s", + BACKUP_ENCRYPTION_KEY: HEX32, + }; + expect(lineFor(env, "Off-host backup")?.status).toBe("ok"); + }); +}); + +describe("formatReadiness + logReadinessSummary", () => { + it("renders a header that flags a blocker", () => { + const env = baseEnv(); + delete env.API_TOKEN_HMAC_KEY; + const text = formatReadiness(collectReadiness(env)); + expect(text).toContain("CORE SECRET MISSING/INVALID"); + expect(text).toContain("✗ API_TOKEN_HMAC_KEY"); + }); + + it("logs blockers to error and healthy summaries to log; never throws", () => { + const errors: string[] = []; + const logs: string[] = []; + const sink = { + error: (m: string) => errors.push(m), + log: (m: string) => logs.push(m), + }; + logReadinessSummary(baseEnv(), sink); + expect(logs).toHaveLength(1); + expect(errors).toHaveLength(0); + + const bad = baseEnv(); + delete bad.ENCRYPTION_KEY; + logReadinessSummary(bad, sink); + expect(errors).toHaveLength(1); + }); +}); diff --git a/src/lib/boot/readiness-summary.ts b/src/lib/boot/readiness-summary.ts new file mode 100644 index 000000000..6f1e79f15 --- /dev/null +++ b/src/lib/boot/readiness-summary.ts @@ -0,0 +1,197 @@ +/** + * First-boot readiness summary. + * + * The bundled image promises "single `docker compose up`", but the reality is + * three hand-generated secrets plus a handful of optional subsystems — and the + * crypto loader is fail-closed, so a missing/short `ENCRYPTION_KEY` throws a + * stack trace on first use rather than a sentence an operator can act on. This + * module inspects the environment ONCE at boot and prints a compact, + * human-readable readiness block: a clear line per core secret (with the exact + * `openssl` fix on a bad one) before anything throws, plus a one-glance status + * of the optional channels. + * + * It NEVER logs a secret value — only presence + shape validity. It never + * throws: the fail-closed loaders remain the real gate; this is a friendlier + * signpost in front of them. + */ + +export interface ReadinessLine { + /** Short label, e.g. "ENCRYPTION_KEY". */ + label: string; + /** ok = green, warn = optional-not-configured, error = missing/invalid core. */ + status: "ok" | "warn" | "error"; + /** One-line human detail (no secret material). */ + detail: string; +} + +export interface ReadinessReport { + lines: ReadinessLine[]; + /** True when at least one core secret is missing or malformed. */ + hasBlocker: boolean; +} + +type Env = Record; + +/** A 64-char hex string (= 32 bytes) or a base64 string that decodes to 32 bytes. */ +function isValid32ByteKey(raw: string | undefined): boolean { + if (!raw) return false; + const v = raw.trim(); + if (/^[0-9a-fA-F]{64}$/.test(v)) return true; + if (/^[A-Za-z0-9+/=]+$/.test(v)) { + try { + return Buffer.from(v, "base64").length === 32; + } catch { + return false; + } + } + return false; +} + +function allSet(env: Env, keys: string[]): boolean { + return keys.every((k) => (env[k] ?? "").trim().length > 0); +} + +function anySet(env: Env, keys: string[]): boolean { + return keys.some((k) => (env[k] ?? "").trim().length > 0); +} + +/** Inspect the environment and build the readiness report. Pure + side-effect free. */ +export function collectReadiness(env: Env = process.env): ReadinessReport { + const lines: ReadinessLine[] = []; + + // --- Core secrets (boot blockers) --------------------------------------- + // Encryption: the map form (ENCRYPTION_KEYS) supersedes the single key. + const keysMap = (env.ENCRYPTION_KEYS ?? "").trim(); + let encryptionOk = false; + if (keysMap) { + try { + const parsed = JSON.parse(keysMap) as Record; + const entries = Object.values(parsed ?? {}); + encryptionOk = + entries.length > 0 && entries.every((v) => isValid32ByteKey(v)); + } catch { + encryptionOk = false; + } + lines.push({ + label: "ENCRYPTION_KEYS", + status: encryptionOk ? "ok" : "error", + detail: encryptionOk + ? "key-rotation map present and valid" + : "ENCRYPTION_KEYS is not a JSON map of valid 32-byte keys", + }); + } else { + encryptionOk = isValid32ByteKey(env.ENCRYPTION_KEY); + lines.push({ + label: "ENCRYPTION_KEY", + status: encryptionOk ? "ok" : "error", + detail: encryptionOk + ? "present (64 hex chars)" + : "missing or not 64 hex chars — generate with: openssl rand -hex 32", + }); + } + + const hmacOk = isValid32ByteKey(env.API_TOKEN_HMAC_KEY); + lines.push({ + label: "API_TOKEN_HMAC_KEY", + status: hmacOk ? "ok" : "error", + detail: hmacOk + ? "present (64 hex chars)" + : "missing or not 64 hex chars — generate with: openssl rand -hex 32", + }); + + const dbOk = (env.DATABASE_URL ?? "").trim().length > 0; + lines.push({ + label: "DATABASE_URL", + status: dbOk ? "ok" : "error", + detail: dbOk + ? "present" + : "missing — the bundled compose derives it from POSTGRES_PASSWORD", + }); + + // --- Transport hint (a warning, never a blocker) ------------------------ + const secureOverride = (env.SESSION_COOKIE_SECURE ?? "").trim().toLowerCase(); + const secureFlagOn = + secureOverride === "true" || + (secureOverride !== "false" && env.NODE_ENV === "production"); + lines.push({ + label: "SESSION_COOKIE_SECURE", + status: "ok", + detail: secureFlagOn + ? "Secure cookie flag ON — serve HTTPS, or set SESSION_COOKIE_SECURE=false for a plain-HTTP LAN host" + : "Secure cookie flag OFF — fine for plain-HTTP LAN; do NOT expose plain HTTP to the internet", + }); + + // --- Optional subsystems (informational) -------------------------------- + const optional: Array<[string, boolean]> = [ + ["SMTP (email)", allSet(env, ["SMTP_HOST", "SMTP_PORT", "SMTP_FROM"])], + [ + "Off-host backup", + allSet(env, [ + "BACKUP_S3_ENDPOINT", + "BACKUP_S3_BUCKET", + "BACKUP_S3_ACCESS_KEY", + "BACKUP_S3_SECRET_KEY", + "BACKUP_ENCRYPTION_KEY", + ]), + ], + [ + "APNs (native iOS push)", + allSet(env, ["APNS_KEY_ID", "APNS_TEAM_ID", "APNS_BUNDLE_ID"]) && + anySet(env, ["APNS_KEY_B64", "APNS_KEY", "APNS_KEY_FILE"]), + ], + [ + "Web Push (VAPID env)", + allSet(env, ["VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", "VAPID_SUBJECT"]), + ], + ]; + for (const [label, configured] of optional) { + lines.push({ + label, + status: configured ? "ok" : "warn", + detail: configured ? "configured" : "not configured (optional)", + }); + } + + const hasBlocker = lines.some((l) => l.status === "error"); + return { lines, hasBlocker }; +} + +const GLYPH: Record = { + ok: "✓", + warn: "·", + error: "✗", +}; + +/** Render the report as a compact multi-line block. */ +export function formatReadiness(report: ReadinessReport): string { + const body = report.lines + .map((l) => ` ${GLYPH[l.status]} ${l.label}: ${l.detail}`) + .join("\n"); + const header = report.hasBlocker + ? "HealthLog boot readiness — CORE SECRET MISSING/INVALID (see ✗ below):" + : "HealthLog boot readiness:"; + return `${header}\n${body}`; +} + +/** + * Collect + print the readiness summary. Blockers go to stderr (they precede a + * fail-closed throw), the healthy summary to stdout. Never throws. + */ +export function logReadinessSummary( + env: Env = process.env, + log: Pick = console, +): ReadinessReport { + try { + const report = collectReadiness(env); + const text = formatReadiness(report); + if (report.hasBlocker) { + log.error(text); + } else { + log.log(text); + } + return report; + } catch { + // A readiness summary must never be the thing that breaks boot. + return { lines: [], hasBlocker: false }; + } +} From e5ca125dfb337100a9a341dcf824a1e677b660e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:59:10 +0200 Subject: [PATCH 15/21] fix(onboarding,integrations,cycle): deep-link source chips + cycle settings wrench - Onboarding 'more sources' chips anchor to their provider card (/settings/integrations#oura etc.); connection cards gain scroll anchors. Apple Health (no web card) stays on the page root. - Cycle module gains a header settings wrench for parity with labs / medications / illness; the tab strip is now controlled so the wrench jumps to the settings tab and ?tab= deep-links keep working. --- src/components/cycle/cycle-view.tsx | 47 +++++++++++++++---- .../onboarding/source-card-grid.tsx | 10 +++- .../integrations/connections-panel.tsx | 30 +++++++++--- 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 8665fe1e4..0930fda56 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -1,9 +1,15 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; -import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react"; +import { + Loader2, + Plus, + RefreshCw, + SlidersHorizontal, + Sparkles, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -100,6 +106,14 @@ export function CycleView() { : "calendar"; }, [searchParams]); + // Controlled so the header "cycle settings" wrench (parity with the + // labs/medications/illness modules, which expose a customize glyph) can jump + // to the settings tab; a `?tab=` deep-link change still switches too. + const [tab, setTab] = useState(initialTab); + useEffect(() => { + setTab(initialTab); + }, [initialTab]); + const today = useMemo(() => shiftToday(0), []); const from = useMemo(() => shiftToday(-90), []); const to = useMemo(() => shiftToday(180), []); @@ -158,13 +172,26 @@ export function CycleView() { title={{t("cycle.title")}} description={t("cycle.subtitle")} actions={ - + <> + + + } /> @@ -266,7 +293,7 @@ export function CycleView() { ) : null}
- + setTab(v as CycleTab)}> {/* v1.15.10 — the four German labels (Kalender/Prognosen/ Erkenntnisse/Einstellungen) overflowed the `flex-1` equal-width cells at 375 px and forced a horizontal scroll. Below `sm` the diff --git a/src/components/onboarding/source-card-grid.tsx b/src/components/onboarding/source-card-grid.tsx index de0f4375c..6ba4305d9 100644 --- a/src/components/onboarding/source-card-grid.tsx +++ b/src/components/onboarding/source-card-grid.tsx @@ -170,7 +170,15 @@ export function SourceCardGrid() {
  • @@ -319,6 +325,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { values={group.readings.map((r) => r.value)} />
    + {/* Un-linked group: the header renders no link, so name the + reason rather than leaving a card that silently does + nothing on tap. */} + {!group.biomarkerId ? ( +

    + {t("labs.notLinkedYet")} +

    + ) : null} From 96c99f4dfab272919252b2a399e2047fdf56d098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 00:13:08 +0200 Subject: [PATCH 18/21] fix(medications): render intake dialogs as bottom sheets on mobile Port the six medication dialogs (log-intake, intake-edit, dose-history-add, take-all-due, log-injection-site, intake-import) from the centred Dialog to ResponsiveSheet with the sticky-footer slot, matching every other module's add/edit form. Form-backed dialogs associate their footer submit button via a useId form id so Enter-to-submit and the footer button share one form; the nested slot-nudge confirm rides the same sheet. Primary actions now sit within thumb reach on phones. --- .../medications/dose-history-add-dialog.tsx | 113 ++++++++--------- .../medications/intake-edit-dialog.tsx | 66 +++++----- .../medications/intake-import-dialog.tsx | 114 +++++++++--------- .../medications/log-injection-site-dialog.tsx | 67 +++++----- .../medications/log-intake-dialog.tsx | 75 ++++++------ .../medications/take-all-due-dialog.tsx | 85 ++++++------- 6 files changed, 234 insertions(+), 286 deletions(-) diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx index 49587bea0..c2b332f24 100644 --- a/src/components/medications/dose-history-add-dialog.tsx +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -17,20 +17,13 @@ * ad-hoc (no `scheduledFor`); an exact slot match pins directly with no nudge. */ -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Label } from "@/components/ui/label"; import { DateTimeField } from "@/components/ui/date-time-field"; import { Switch } from "@/components/ui/switch"; @@ -135,6 +128,7 @@ export function LedgerAddDialog({ }: LedgerAddDialogProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); + const formId = useId(); const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(new Date())); const [skipped, setSkipped] = useState(false); @@ -200,22 +194,40 @@ export function LedgerAddDialog({ } return ( - !busy && onOpenChange(o)}> - - - - {t("medications.detail.verlauf.addDialog.title")} - - - {t("medications.detail.verlauf.addDialog.description")} - - - + !busy && onOpenChange(o)} + title={t("medications.detail.verlauf.addDialog.title")} + description={t("medications.detail.verlauf.addDialog.description")} + footer={ + <> + + + + } + > {/* v1.16.4 — a real form so Enter in the datetime field submits; - the buttons carry explicit types so cancel never submits. The - nested nudge dialog renders through a portal, so its buttons - never land inside this form. */} + the buttons carry explicit types so cancel never submits. */} { e.preventDefault(); @@ -263,49 +275,17 @@ export function LedgerAddDialog({

    )}
    - - - - - {/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the take as a standalone ad-hoc entry. */} {nudgeOpen && nearest && ( - !busy && setNudgeOpen(o)} - > - - - - {t("medications.detail.verlauf.nudge.title")} - - - {t("medications.detail.verlauf.nudge.body", { - slot: nearest.slotHm, - })} - - - + title={t("medications.detail.verlauf.nudge.title")} + footer={ + <> - - - + + } + > +

    + {t("medications.detail.verlauf.nudge.body", { + slot: nearest.slotHm, + })} +

    + )} - - + ); } diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index e01ab2feb..59489a485 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -21,19 +21,13 @@ * persisted it (audit LOW-10), so the dialog no longer pretends. */ -import { useState } from "react"; +import { useId, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { DateTimeField } from "@/components/ui/date-time-field"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -107,6 +101,7 @@ function IntakeEditDialogBody({ const { t } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); + const formId = useId(); const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(event?.takenAt ?? null), ); @@ -145,16 +140,38 @@ function IntakeEditDialogBody({ } return ( - !open && onClose()}> - - - - {t("medications.detail.intake.edit.dialogTitle")} - - + !open && onClose()} + title={t("medications.detail.intake.edit.dialogTitle")} + footer={ + <> + + + + } + > {/* v1.16.4 — a real form so Enter in the datetime field submits; the buttons carry explicit types so cancel never submits. */}
    { e.preventDefault(); @@ -208,24 +225,7 @@ function IntakeEditDialogBody({ /> - - - -
    -
    -
    + ); } diff --git a/src/components/medications/intake-import-dialog.tsx b/src/components/medications/intake-import-dialog.tsx index 591bf85eb..62589547f 100644 --- a/src/components/medications/intake-import-dialog.tsx +++ b/src/components/medications/intake-import-dialog.tsx @@ -22,12 +22,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { DropdownMenu, DropdownMenuContent, @@ -143,12 +138,58 @@ export function IntakeImportDialog({ } return ( - handleClose()}> - - - {t("medications.importIntakes")} - -
    + handleClose()} + title={t("medications.importIntakes")} + contentWidth="lg" + footer={ + <> + + + + + + + + {t("common.reset")} + + + + + + + } + > +

    {t("medications.importDescription")}

    @@ -194,54 +235,7 @@ export function IntakeImportDialog({ {result}

    )} -
    - - - - - - - - {t("common.reset")} - - - - -
    - - -
    -
    - -
    + ); } diff --git a/src/components/medications/log-injection-site-dialog.tsx b/src/components/medications/log-injection-site-dialog.tsx index 5ea4eeed1..a1f1fb09d 100644 --- a/src/components/medications/log-injection-site-dialog.tsx +++ b/src/components/medications/log-injection-site-dialog.tsx @@ -4,14 +4,7 @@ import { useState } from "react"; import { Loader2 } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Button } from "@/components/ui/button"; import { InjectionSitePicker } from "@/components/medications/injection-site-picker"; import { @@ -85,7 +78,7 @@ export function LogInjectionSiteDialog({ } return ( - { // Hold the dialog while the PATCH is in flight so a backdrop tap / @@ -93,18 +86,31 @@ export function LogInjectionSiteDialog({ if (submitting) return; if (!next) onSkip(); }} + title={t("medications.logInjectionSiteTitle")} + description={t("medications.logInjectionSiteDescription", { + name: medicationName, + })} + footer={ + <> + + + + } > - - - {t("medications.logInjectionSiteTitle")} - - {t("medications.logInjectionSiteDescription", { - name: medicationName, - })} - - - - {allowed.length === 0 ? ( + {allowed.length === 0 ? (

    {t("medications.logInjectionSiteNoneAvailable")}

    @@ -161,25 +167,6 @@ export function LogInjectionSiteDialog({ )} - - - - - -
    -
    + ); } diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index 73ef6af5e..88e83f3c2 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -19,20 +19,13 @@ * the form state + the medication/slot pickers. */ -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { DateTimeField } from "@/components/ui/date-time-field"; @@ -119,6 +112,7 @@ export function LogIntakeDialog({ }: LogIntakeDialogProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); + const formId = useId(); const [medicationId, setMedicationId] = useState( () => medications[0]?.id ?? "", @@ -208,18 +202,39 @@ export function LogIntakeDialog({ } return ( - !busy && onOpenChange(o)}> - - - {t("medications.logIntake.dialogTitle")} - - {t("medications.logIntake.dialogDescription")} - - - + !busy && onOpenChange(o)} + title={t("medications.logIntake.dialogTitle")} + description={t("medications.logIntake.dialogDescription")} + footer={ + <> + + + + } + > {/* v1.16.4 — a real form so Enter in the dose / datetime fields submits; mirrors the intake-edit and dose-history-add dialogs. */}
    { e.preventDefault(); if (busy || medications.length === 0 || !medicationId) return; @@ -326,29 +341,7 @@ export function LogIntakeDialog({ )} - - - - -
    -
    -
    + ); } diff --git a/src/components/medications/take-all-due-dialog.tsx b/src/components/medications/take-all-due-dialog.tsx index 7a2913d31..3954d2760 100644 --- a/src/components/medications/take-all-due-dialog.tsx +++ b/src/components/medications/take-all-due-dialog.tsx @@ -19,14 +19,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { CheckCheck, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useTranslations } from "@/lib/i18n/context"; import { formatDose } from "@/lib/medications/format-dose"; import { formatTimeWindowRange } from "@/lib/time-window-format"; @@ -65,49 +58,17 @@ export function TakeAllDueDialog({ } return ( - { // Refuse to close while the take loop is in flight. if (submitting && !next) return; onOpenChange(next); }} - > - - - {t("medications.takeAllDue.dialogTitle")} - - {t("medications.takeAllDue.dialogDescription")} - - -
      - {dueMedications.map((med) => ( -
    • - - {med.name} - {med.dose ? ( - - {" "} - — {formatDose(med.dose, t)} - - ) : null} - - {med.window ? ( - - {formatTimeWindowRange( - med.window.start, - med.window.end, - locale, - )} - - ) : null} -
    • - ))} -
    - + title={t("medications.takeAllDue.dialogTitle")} + description={t("medications.takeAllDue.dialogDescription")} + footer={ + <>
    + + } + > +
      + {dueMedications.map((med) => ( +
    • + + {med.name} + {med.dose ? ( + + {" "} + — {formatDose(med.dose, t)} + + ) : null} + + {med.window ? ( + + {formatTimeWindowRange( + med.window.start, + med.window.end, + locale, + )} + + ) : null} +
    • + ))} +
    + ); } From 23ded20306c0e7cb8bd5812605ae8f5aacd1b311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 00:23:13 +0200 Subject: [PATCH 19/21] fix(pages): replace bare auth-gate spinners with a layout-reserving skeleton Introduce a shared PageAuthGate (header + tile skeleton) and adopt it on the top-level pages that flashed a lone centered spinner while auth resolved: mood, measurements, achievements, illness, mental-wellbeing, checkups, cycle, and the labs / medications / custom-metrics / illness detail routes. The auth-resolving frame now reserves the page geometry instead of snapping in after a spinner. --- src/app/achievements/page.tsx | 8 +- src/app/checkups/page.tsx | 8 +- src/app/coach/conversations/page.tsx | 5 +- src/app/coach/plans/page.tsx | 9 +- src/app/custom-metrics/[id]/page.tsx | 8 +- src/app/cycle/page.tsx | 16 +- src/app/illness/[id]/page.tsx | 8 +- src/app/illness/page.tsx | 8 +- src/app/labs/[biomarkerId]/page.tsx | 8 +- src/app/measurements/page.tsx | 9 +- src/app/medications/[id]/page.tsx | 16 +- src/app/mental-wellbeing/page.tsx | 8 +- src/app/mood/page.tsx | 9 +- src/app/notifications/__tests__/page.test.tsx | 10 +- src/app/page-client.tsx | 2 +- src/components/cycle/cycle-view.tsx | 9 +- .../medications/dose-history-add-dialog.tsx | 168 +++++++-------- .../medications/dose-history-ledger.tsx | 2 +- .../medications/intake-edit-dialog.tsx | 110 +++++----- .../medications/intake-history-editable.tsx | 4 +- .../medications/intake-import-dialog.tsx | 84 ++++---- .../medications/log-injection-site-dialog.tsx | 108 +++++----- .../medications/log-intake-dialog.tsx | 200 +++++++++--------- .../medications/side-effects-section.tsx | 2 +- .../medications/take-all-due-dialog.tsx | 48 ++--- .../settings/notifications-section.tsx | 5 +- src/components/settings/sharing-section.tsx | 2 +- src/components/ui/page-auth-gate.tsx | 49 +++++ 28 files changed, 465 insertions(+), 458 deletions(-) create mode 100644 src/components/ui/page-auth-gate.tsx diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index dc9724cae..603726acd 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -13,7 +13,6 @@ import { Heart, KeyRound, Languages, - Loader2, Lock, LogIn, Moon, @@ -33,6 +32,7 @@ import { Progress } from "@/components/ui/progress"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; +import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; @@ -277,11 +277,7 @@ export default function AchievementsPage() { }); if (authLoading || (achievementsEnabled && isLoading)) { - return ( -
    - -
    - ); + return ; } // v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx index 162843a3c..2105fb54b 100644 --- a/src/app/checkups/page.tsx +++ b/src/app/checkups/page.tsx @@ -1,13 +1,13 @@ "use client"; import { useCallback } from "react"; -import { Loader2 } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; +import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; import { queryKeys } from "@/lib/query-keys"; @@ -35,11 +35,7 @@ export default function CheckupsPage() { const pull = usePullToRefresh({ onRefresh: refresh }); if (!mounted || isLoading) { - return ( -
    - -
    - ); + return ; } return ( diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 3c55a37c3..e5df84226 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -136,7 +136,10 @@ function CoachConversationsBody() { // Surface the sibling Plans ledger — otherwise it is reachable // only from the composer's `+` menu. - - - } +