Merge branch 'feat/v144-housekeeping' into release/v1.44.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 20:51:29 +02:00
commit ccd84e6d01
182 files changed
+4892 -906

No files matched your search

+5 -3
View File
@@ -533,9 +533,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# TRUST_CF_CONNECTING_IP="1"
# Memory cap of the app container (read by docker-compose.yml, not by the app).
# Node sizes its heap from it: 1536m (the default) gives 792 MB, 1g only 524 MB,
# which a long-used account fills in normal operation. Lower it on a host with
# 2 GB or less, raise it on a busy instance.
# Node sizes its heap from it: 1536m (the default) gives 792 MB. 1536m is the
# supported floor: below it a long-used account fills the heap in normal
# operation, and the boot log, the support report and Admin > System status
# say the app runs below the supported floor. The host needs 3 GB of RAM,
# 4 GB recommended. Raise it on a busy instance; do not lower it.
# APP_MEMORY_LIMIT="1536m"
# -----------------------------------------------------------------------------
+9 -4
View File
@@ -503,9 +503,10 @@ services:
# workload can grow large enough to starve Postgres on a shared host.
# Node sizes its heap from this limit: 1g gives a 524 MB heap, which a
# long-used account fills in normal operation, so garbage collection
# stalls requests for seconds. 1536m gives 792 MB. The support report and
# Admin > System status flag a heap below 700 MB. On a host with 2 GB or less, lower it with
# APP_MEMORY_LIMIT in .env; on a busy instance, raise it.
# stalls requests for seconds. 1536m gives 792 MB and is the supported
# floor (host: 3 GB RAM, 4 GB recommended). Below it the boot log, the
# support report and Admin > System status say so. On a busy instance,
# raise it with APP_MEMORY_LIMIT in .env; do not lower it.
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
healthcheck:
test:
@@ -521,7 +522,11 @@ services:
# minutes (docs/ops/deploy.md), and failed probes in that time must not
# read as a dead container. The first probe that answers still ends
# the start period, so a normal boot is healthy within 30 seconds of
# listening.
# listening. After it listens, the pg-boss worker starts in the
# background and /api/health answers 503 with reason "starting" until it
# is up (10–60 s under load, reported as "starting" for at most five
# minutes); inside the start period those probes do not count, so a slow
# worker start cannot mark the container unhealthy.
start_period: 10m
# Optional dedicated worker container — uncomment + set
+5 -1
View File
@@ -56,7 +56,11 @@ notes, and machine-readable specs that ship alongside the source:
— the iOS submission gate.
- [`codex-protocol-spec.md`](./codex-protocol-spec.md) — Codex / ChatGPT
OAuth protocol reverse-engineering notes.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style.
- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the
binding UI standards: card anatomy, spacing, text colour, tokens and
which primitive to use where. Read before touching any surface.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where
it disagrees with the UI standards, the standards win.
If you're a self-hoster looking to spin up an instance, start at
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
+194 -10
View File
@@ -4315,6 +4315,15 @@ paths:
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
- EXERCISE_MINUTES
- STAND_MINUTES
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- AFIB_BURDEN
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- in: query
name: from
schema:
@@ -9455,6 +9464,52 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/auth/me/workout-routes:
get:
tags:
- Auth
summary: Read the workout-route opt-in
description: Whether the GPS track of a workout is kept. Off by default. While it is off, `POST /api/workouts/batch` and
the Apple Health export import keep every workout but drop its `route`; the per-entry status is the same as with
the route stored. No AI path ever reads a stored track.
responses:
"200":
description: The current flag.
content:
application/json:
schema:
$ref: "#/components/schemas/GetWorkoutRoutesEnvelope"
"401": *a1
"422": *a4
"429": *a5
patch:
tags:
- Auth
summary: Set the workout-route opt-in
description: Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB. Turning it off
stops new tracks; tracks already stored stay until their workout is deleted.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/WorkoutRoutesPatchRequest"
responses:
"200":
description: The resolved next flag.
content:
application/json:
schema:
$ref: "#/components/schemas/PatchWorkoutRoutesEnvelope"
"401": *a1
"413":
description: Request body exceeds 1024 bytes. Nothing was written.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/auth/me/use-central-codex:
get:
tags:
@@ -22797,8 +22852,9 @@ paths:
- $ref: "#/components/schemas/HealthAdminResponse"
- $ref: "#/components/schemas/HealthPublicResponse"
"503":
description: "Degraded — the database did not answer, or a role this process runs is not up. Same body shape as the 200,
with `status: \"degraded\"`. A restart loop reports this rather than failing to answer, so a monitor can
description: "Degraded — the database did not answer within two seconds, or a role this process runs is not up. Same
body shape as the 200, with `status: \"degraded\"`, and `reason: \"starting\"` while the background role is
still coming up after a start. A restart loop reports this rather than failing to answer, so a monitor can
tell 'starting' from 'gone'."
content:
application/json:
@@ -25948,7 +26004,7 @@ components:
type: number
const: 2
leaves:
maxItems: 95
maxItems: 104
type: array
items:
type: string
@@ -26008,7 +26064,7 @@ components:
type: number
const: 2
leaves:
maxItems: 95
maxItems: 104
type: array
items:
type: string
@@ -26843,6 +26899,15 @@ components:
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
- EXERCISE_MINUTES
- STAND_MINUTES
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- AFIB_BURDEN
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
value:
type: number
measuredAt:
@@ -28524,6 +28589,15 @@ components:
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
- EXERCISE_MINUTES
- STAND_MINUTES
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- AFIB_BURDEN
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
biomarkerId:
type: string
minLength: 1
@@ -29023,7 +29097,7 @@ components:
type: number
const: 2
leaves:
maxItems: 95
maxItems: 104
type: array
items:
type: string
@@ -29358,6 +29432,14 @@ components:
- labsLocalOcrEnabled
description: Turn browser-side OCR on or off for lab-report scanning. When on, a text-only provider can still read a
paper report because the image is transcribed on the device and only the TEXT is sent.
WorkoutRoutesPatchRequest:
type: object
properties:
storeWorkoutRoutes:
type: boolean
required:
- storeWorkoutRoutes
description: Keep or stop keeping the GPS track of new workouts. Off by default.
UseCentralCodexPatchRequest:
type: object
properties:
@@ -30186,6 +30268,15 @@ components:
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
- EXERCISE_MINUTES
- STAND_MINUTES
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- AFIB_BURDEN
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy,
flights, walking/running distance, VO2 max, body temperature).
InsightsSettingsPutRequest:
@@ -38851,6 +38942,8 @@ components:
type: boolean
labsLocalOcrEnabled:
type: boolean
storeWorkoutRoutes:
type: boolean
onboardingCompletedAt:
anyOf:
- type: string
@@ -38961,6 +39054,15 @@ components:
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
- EXERCISE_MINUTES
- STAND_MINUTES
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
value:
type: number
valueMin:
@@ -39701,6 +39803,15 @@ components:
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
- EXERCISE_MINUTES
- STAND_MINUTES
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
- type: "null"
biomarkerName:
anyOf:
@@ -42176,6 +42287,15 @@ components:
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
- EXERCISE_MINUTES
- STAND_MINUTES
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
dateKey:
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
@@ -43354,6 +43474,15 @@ components:
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
- EXERCISE_MINUTES
- STAND_MINUTES
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
- type: "null"
intervalDays:
anyOf:
@@ -43566,6 +43695,15 @@ components:
- WAIST_TO_HEIGHT
- WHO5_SCORE
- SCI_SCORE
- EXERCISE_MINUTES
- STAND_MINUTES
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
metricSlot:
type:
- string
@@ -55934,6 +56072,48 @@ components:
- data
- error
additionalProperties: false
GetWorkoutRoutesEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/WorkoutRoutesResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
WorkoutRoutesResponse:
type: object
properties:
storeWorkoutRoutes:
type: boolean
required:
- storeWorkoutRoutes
additionalProperties: false
PatchWorkoutRoutesEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/WorkoutRoutesResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
GetUseCentralCodexEnvelope:
type: object
properties:
@@ -84031,14 +84211,18 @@ components:
when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any
of those failing."
HealthReason:
description: "Present only when the process refuses to serve for a reason a person has to fix.
`encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at
boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs
are not started. Fix: restore the original key, point the server at the matching database, or start with an
empty one; then restart."
description: "Why a `degraded` process is degraded, when it is something other than a component failure.
`encryption_key_mismatch`: the process refuses to serve for a reason a person has to fix; the configured
encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with
`meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key,
point the server at the matching database, or start with an empty one; then restart. `starting`: the database
answers but the background role (the pg-boss worker, or the producer of a web-only process) is still coming up
after a start. Transient; nothing to fix, poll again. The probe answers at once while it lasts rather than
waiting for the start to finish."
type: string
enum:
- encryption_key_mismatch
- starting
HealthPublicResponse:
type: object
properties:
+184
View File
@@ -0,0 +1,184 @@
# UI standards
The binding reference for building or touching any surface of the web app. The rules are derived from the primitives' own contracts (`src/components/ui/card.tsx`, `tile-header.tsx`, `page-header.tsx`, `settings/_card-header.tsx`, `layout/auth-shell.tsx`) and from a review of every surface against them. When a rule here conflicts with an existing file, the file is drift: fix it when you touch it, don't copy it. Where an older code comment or [`docs/ui-guidelines.md`](../ui-guidelines.md) says otherwise, this document wins. A deviation needs a written reason in the pull request.
---
## 1. Card anatomy
**The Card is gap-based, not padding-based** (`src/components/ui/card.tsx:24`): `flex flex-col gap-4 rounded-xl border py-4 md:gap-6 md:py-6`; header/content/footer carry `px-4 md:px-6`. The header→body distance comes from the **parent flex gap** — never from slot padding.
- FORBIDDEN: `pb-*` on `CardHeader`, `pt-*` on `CardContent`. Under the gap model these add on top of the gap (or are no-ops) and invert their intent. Want a tighter card? Override the gap on the Card itself: `<Card className="gap-2">`.
- Two densities, nothing else:
- `default` — Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`.
- `compact` — `<Card density="compact">` (stat strips, assessment blocks, small tiles); resolves to `gap-2 py-3 md:gap-2 md:py-4`. Never spell it as a class list: the hand-written `gap-2 py-3 md:py-4` kept the base `md:gap-6` and opened 24 px on desktop (v1.43.8; `card-density.test.tsx` refuses the class list on a Card).
- No `md:py-5` half-steps.
- Radius: cards are `rounded-xl` (tracks `--radius`). `rounded-md` = controls, `rounded-lg` = inner tiles, `rounded-full` = pills. `rounded-2xl` only as a deliberately marked identity surface (Coach hero/input).
- Hand-rolled `bg-card … border … p-N` shells are legacy. New surfaces compose `<Card>` (or `SettingsCard` in Settings). If a hand-rolled shell must exist, its padding is exactly `p-4 md:p-6` or `p-3` (dense tile) — never `p-5`, never a static `p-6`, never a `sm:` step (`SettingsCard`'s docblock explains why the breakpoint is `md:`).
- **Insights overview sections use `InsightSectionCard`** (`src/components/insights/insight-section-card.tsx`). It composes `<Card>`, so the frame is the card contract, and pins the section's inner rhythm at one step (`gap-3`) — the six hand-rolled copies it replaced had drifted to three different rhythms in the same column. A section whose body is an edge-to-edge divided list (rows owning their own inset) passes `flush`; everything else takes the padded body. Do not hand-roll a section shell beside it.
- **The dashboard/insights hero band is a sanctioned identity surface.** Its translucent `bg-card/65 … backdrop-blur-sm` tiles are deliberate, not drift: the band reads as one glass surface behind the page's opening statement. It stays out of the shell sweep. Everything below the band is a plain card.
### Header patterns — exactly three, pick by surface
| Surface | Primitive | Contract |
| ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page title | `PageHeader` (`ui/page-header.tsx`) | H1 `text-2xl font-bold tracking-tight`, description `text-muted-foreground text-sm`, `space-y-1.5`, **no icon beside the H1**, optional backLink + actions slot |
| Settings card | `SettingsCard` + `SettingsCardHeader` (`settings/_card-header.tsx`) | icon `text-muted-foreground h-5 w-5`, title `h2 text-lg font-semibold`, status slot top-right, description muted below |
| Insights / dashboard / module tile | `Card` + `TileHeader` (`insights/tile-header.tsx`) | icon **`text-foreground` `h-5 w-5 shrink-0`**, title `CardTitle text-base`, `flex items-center gap-2`, optional `right` slot |
**Icon colour rule (both header contracts):** header icons are `text-foreground` (tiles) or `text-muted-foreground` (settings). Never `text-primary`, `text-info`, or any accent — primary/purple stays reserved for actions and highlight states. Pass the Lucide component (`icon={Target}`), never a pre-sized node — the primitive owns size and colour.
The gaps this section used to list are closed: `TileHeader` carries a `size="sm"` variant for compact tiles and an optional `icon` for icon-less headers, and `InsightSectionCard` is the overview container mirroring `SettingsCard`. Use them — do not invent a fourth header language.
## 2. Spacing scale
> **Scroll height is shell-owned (over-scroll class, #154 lineage).** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + `<md` bottom-nav clearance on `<main>`). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>` — nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication-card rhythm uses them docblock-sanctioned across ~78 sites and the in-between steps earn their keep in dense card interiors — reality wins over a sweep that would only churn settled surfaces. `5` (20 px) and `py-5`, `p-5` are banned in new code, swept on touch. `7` (28 px) is banned with them — it entered as the Settings/Admin `pl-7` body gutter and is gone (§13).
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`.
- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding.
- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`.
- **Anchors:** `scroll-mt-28`, single token (31-to-1 in the tree already).
## 3. Text colour rules
The base tokens are AA-tuned in both themes (`--muted-foreground`: 5.0–6.4:1, documented in `globals.css`). The system is two tiers:
- **`text-foreground` = content.** Everything the user came to read: values, chart labels the reading depends on, AI narrative bodies, consent/safety copy, and **all user-entered data** (notes, journal text, free-text fields). User data is never muted.
- **`text-muted-foreground` = meta.** Timestamps, units, provenance, counts, placeholders, one-sentence descriptions under a heading. Heading-in-foreground + short-description-in-muted is the app-wide convention (CardDescription, PageHeader, SettingsCardHeader) — keep it; do not "fix" it to foreground.
- **Description slots hold exactly one meta sentence, guideline ≤ 120 characters.** One sentence, one full stop, no paragraph stack. 120 characters is the guideline, not a hard cut: it is the p75 of the descriptions that already read well, and a description that needs 130 characters to stay true is fine while a two-sentence one never is. Multi-sentence explainer prose goes in the body as `<p className="text-sm">` (foreground), not into a muted description slot; if the body would not carry it either, the sentence is not needed on the surface at all and belongs in the docs. The description slot renders a single text run — it deliberately owns no vertical rhythm of its own, so a call site cannot stack paragraphs into it.
- **One statement, one string.** A section's page subtitle and its card description must not say the same thing twice on one screen. When a section owns a single card, the subtitle carries the statement and the card header drops its description. Same rule for a field label and its field description: the label names the field, the description adds what the label cannot.
- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1 — below AA; they silently undo the token tuning. Full `text-muted-foreground` IS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha. `text-foreground/80` is safe (≥6.7:1) and allowed. `/60` is icon-only (3:1 graphics floor).
- **Disabled look:** `opacity-50` on the container + `aria-disabled`, not colour dimming on the text node.
- **Size floor:** muted text is never smaller than `text-xs`. Below that, the one legal step is the `text-2xs` token (0.6875rem, minted in `globals.css`) and it is for chips, badges, and tabular-nums micro-labels with verified contrast — never for muted body or meta text. Arbitrary sizes (`text-[0.65rem]`, `text-[11px]`) are drift; chart-axis `text-[10px]`/`text-[11px]` stays tolerated as documented.
- **Status colours** carry meaning, not decoration: `text-success` / `text-warning` / `text-info` / `text-destructive` (+ `bg-*/10` washes). All four are AA-retuned per theme.
## 4. Colour token rules
- **Semantic tokens only** in components: `--foreground/--muted-foreground/--primary/--success/--warning/--info/--destructive/--chart-1..5/--border/--card`. The light theme remaps them wholesale; anything outside the token layer is invisible to a retune.
- **No raw `dracula-*` utilities in new code.** Only `--dracula-yellow` has a light override; `text-dracula-green` on a light card is 1.4:1. Status meaning → semantic token; brand identity (Coach gradient) → a named `--brand-*` token with per-theme values.
- **No hex in JS props.** Chart colours are passed as `var(--token)` strings, never `color="#22c55e"`. Data series use the `--chart-1..5` tokens (`color="var(--chart-2)"`); status tokens (`--success`, `--warning`, `--destructive`, `--info`) only where the colour carries meaning, such as a target band, time in range or a mood scale (v1.42). Recharts `stroke`/`fill` likewise. This is the known lint blind spot — review for it explicitly until the rule covers it.
- **No arbitrary-value colours** (`bg-[#…]`, `text-[oklch(…)]`).
- Exemptions (documented at the definition site): `global-error.tsx` (renders without the stylesheet), the `themeColor` meta, the QR quiet-zone `bg-white`.
## 5. Typography scale
| Tier | Classes | Where |
| ------------------------ | ------------------------------------------------------------------- | ---------------------------------------------------------------- |
| App H1 | `text-2xl font-bold tracking-tight` | every module page, via `PageHeader` — weight is **bold**, always |
| Settings card title | `text-lg font-semibold` | via `SettingsCardHeader` |
| Tile title | `text-base` on `CardTitle` (`font-semibold` from the primitive) | via `TileHeader` |
| Chip / badge micro-label | `text-2xs` (11 px) | `TagChip`, `Badge`, tabular-nums counters — never muted prose |
| Section label | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | list group headings |
| Body | `text-sm` (foreground) | default content |
| Meta | `text-xs text-muted-foreground` | timestamps, units, hints — the floor |
| Prose-page H1 | `text-3xl md:text-4xl font-bold` | about/privacy/landing tier only |
No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring step. Icon sizes come from the five-step vocabulary `size-3 / 3.5 / 4 / 5 / 6`; prefer `size-N` over `h-N w-N` (identical CSS, one spelling).
## 6. Component mapping — when to use which primitive
| Situation | Use | Never |
| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page title + description + actions | `PageHeader` — `headingRef` + `focusable` for a title that takes focus after navigation, `titleAccessory` for a glyph or badge beside the H1 (outside its name) | hand-rolled `text-2xl font-bold` H1 blocks (`page-header-h1-guard.test.ts` lists where another H1 is the point: heroes, sign-in, error and prose pages, first-run steps) |
| A second copy of the page title in the same shell (mobile above the nav strip, desktop inside the grid) | `PageHeader headingAs="div"` — one real `<h1>`, one `role="heading" aria-level={1}` | a hand-rolled `role="heading"` div beside a hand-rolled `<h1>` |
| Settings card | `SettingsCard` + `SettingsCardHeader` + `FieldGroup` | raw `Card` in Settings sections |
| Heading above a group of cards (Insights overview, Settings groups) | `SectionHeading` (`ui/section-heading.tsx`) | a `SettingsCardHeader` with no card under it, or a hand-rolled `h2` |
| Action row in a Settings / Admin card | `SettingsCardActions` | a hand-rolled `flex justify-end gap-2` div |
| Admin mini stat tile | `StatTile` (`admin/_stat-tile.tsx`) | a hand-rolled `bg-muted/NN rounded-lg p-3` box |
| Insights/dashboard tile | `Card` + `TileHeader` | hand-rolled icon+title rows, `bg-card rounded-xl border p-4` divs |
| Insights overview section | `InsightSectionCard` | a hand-rolled section shell beside it |
| Neutral label chip on a card header | `TagChip` | a hand-copied `bg-muted … rounded-full` literal, or a `Badge` (that means status) |
| Read-query failed (`isError` on a primary / list surface) | `QueryErrorCard` with `error={query.error}` and `onRetry={refetch}` | bare `text-destructive` paragraphs without retry; a card without `error` |
| Read-query failed inside a dense metric strip, tile or supplementary block, where the tall card would dominate the geometry | `QueryErrorRow` with `error={query.error}` and `onRetry={refetch}` | a hand-rolled row; a third read-failure primitive (`ReadFailureRow` was one and is gone) |
| Read-query failed inside a card that already has its frame and header (a tile body, a settings card section) | `QueryErrorRow inset` — keeps the card and its title, no second border | `QueryErrorCard` inside a card (card in card); replacing the card and its header with the error |
| Read-query failed on a chart body | `ChartErrorState` with `error={query.error}` — same decision as the other two | either of the above |
| A list the server capped (`meta.truncated`) | `ListCapNotice` under the list, read through `apiGetList` / `apiPostList` | a hand-written "showing the latest N" line; reading `data.truncated` |
| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) |
| Honest no-data state | `EmptyState` | reusing error copy — a failed query must never fall through to "no data yet" |
| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) |
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`) — tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`) — no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`) — tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`) — caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
| A whole card that points to another page | `CrossLinkCard` (`insights/cross-link-card.tsx`) | a `Link` styled as a card |
| Picking documents from the vault | `DocumentPickerSheet` (`documents/document-picker-sheet.tsx`); the caller decides what a toggle means | a copy of the search + list sheet |
| A field / chart / list with nothing to show | `EmptyState variant="plain" size="compact"` (with `action` for the next step), `ChartEmptyState` on a chart body | a hand-written muted paragraph (`empty-state-ratchet.test.ts` freezes each remaining site as `path: key`, and a new one fails; the frozen ones are field-level "not recorded" lines and editor hints) |
| A number shown to a person | `useFormatters()` (`fmt.number` / `integer` / `percent`), `formatBytes(bytes, locale)` from `lib/format/bytes` | `toFixed()`, a locale-less `toLocaleString()`, `String(a / b)` (`healthlog/no-unlocalised-decimal`) |
| Numeric range entry (0–10 self-rating, any bounded scale) | `Slider` / `SliderField` (`ui/slider.tsx`) — `SliderField` when the scale has anchors, which is nearly always | a hand-rolled `input type="range"`, or an 11-button radiogroup (eleven 32 px targets overflow a 390 px viewport) |
| Outbound fetch | `safeFetch` | raw `fetch(` (lint-enforced) |
| TanStack query keys | `src/lib/query-keys/` factory | bare arrays (lint-enforced) |
**What a failed read offers is decided by the primitive, never the call site.** `QueryErrorCard`, `QueryErrorRow` and `ChartErrorState` take the query's `error` (required) and branch on `readFailureKind` (`src/lib/api/read-failure.ts`). It reads `meta.errorCode` first and the status second, because every error envelope carries a code (`http.*` when the route names none): a step-up or re-proof code on a 401 is a failure with retry, not an ended session; an unknown code falls back to the status.
- **Session ended** (a session code, or a 401 without a more specific one) → one sentence and a Sign in button (back to the current page). Never a silent blank.
- **403, refused** → the surface's `refused` state if it has one, otherwise nothing. A refusal is an answer, not a failure; no retry.
- **429, throttled** → one sentence that the read budget is spent, no retry (an immediate retry would only be refused again).
- **anything else** → the surface's message with Retry.
The `queryFn` hands the `ApiError` through untouched: no `try { … } catch { throw new Error(t(…)) }` around the fetch, the surface's sentence goes in through `message`. A call site does not inspect `error.status` around a read-failure primitive, and does not pass `error={null}`. `query-error-error-prop-guard.test.ts` enforces the prop and the untouched error at every site, `read-failure-markup-guard.test.ts` refuses hand-rolled failure markup next to a read hook. Error codes become sentences through `src/lib/i18n/translate-code.ts`, never through a `t(key) === key` probe at the call site (`translate-probe-guard.test.ts`).
## 7. Enforcement
Live, all at `error`: `healthlog/queryKey-factory`, `healthlog/safe-fetch-required`, `healthlog/api-fetch-required`, `healthlog/no-raw-palette-color` (palette + colour-shaped JS props + arbitrary values), `healthlog/no-dracula-utility`, `healthlog/spacing-scale` (`pt-`/`pb-` on `CardHeader`/`CardContent`, and the banned `5` step on a `bg-card` + `border` shell), `healthlog/no-raw-card-shell` (since v1.43.8: `bg-card` + `border` + `rounded-xl` on one literal under `src/components` / `src/app` outside `ui/`; the files where a shell is the point — shell primitives, the hero, popovers, the chart frame shared by chart/skeleton/error state, table frames, sign-in and first-run screens — are the rule's `allow` list in `eslint.config.mjs`, each with its reason, no inline escape), `healthlog/no-unlocalised-decimal` (since v1.43.8; reasoned `eslint-disable-next-line` only, for SVG coordinates and number-input values).
Structural tests (since v1.43.8): `inline-action-guard`, `score-pill-guard`, `empty-state-ratchet`, `page-header-h1-guard`, `card-density`, `segmented` (one definition), `client-route-zod-guard` (documents and Coach ship no zod in their static client graph).
Planned (until it lands, this is a review-checklist item):
1. A tile-header icon-colour check (`text-muted-foreground` / `text-primary` on a glyph inside a `CardTitle` or tile-header row).
**PR checklist for any UI change:** both themes checked visually · no new `dracula-*`/hex/opacity-muted · headers via a primitive · card gap untouched by slot padding · `isError` → `QueryErrorCard` or `QueryErrorRow` by surface, with `error` · mobile modal = `ResponsiveSheet` · `pnpm format` + `pnpm lint` + `pnpm typecheck` green.
---
## 8. Insights left reading edge — two edges, one gutter
Every text run on an Insights surface starts on one of exactly two vertical lines, and card headings offset from the card line by exactly one gutter:
- **Edge A — reading edge (x = 0):** the AuthShell container inner edge (`px-4 md:px-6`). All **borderless page-level blocks** start here with **no extra horizontal padding**: the sub-page `<h1>` + explainer (`SubPageShell`), and every `SectionHeading`. If a sub-page heading must track the card body instead, it uses the responsive card inset `px-4 md:px-6` — **never** a flat `px-6` (that sat 8 px right of the card body on mobile and reopened the heading-vs-body step).
- **Edge B — card edge (x = card `px`):** every `<Card>` insets header **and** body to `px-4 md:px-6`. No card inset with a static `p-5` (banned) or a `p-4`-only/`sm:` shell — header and body share one inset so the body left edge is identical on every tile.
- **Heading gutter (Edge B + gutter):** every card heading routes through `TileHeader`. The icon's left edge sits on Edge B; the heading **text** sits at Edge B + gutter (icon box + `gap-2` = 28 px at `size="default"`, 24 px at `size="sm"`). Compact tiles use `TileHeader size="sm"`, never a bespoke `h-4` icon row. Tile header icons are `text-foreground` (never muted, never an accent) at both sizes.
Forbidden on any Insights card: a hand-rolled icon+title row, a card title centred by default, an icon box outside `TileHeader`'s h-5/h-4 vocabulary, and a horizontal inset other than `px-4 md:px-6` (or the sanctioned `p-3` dense inner tile). Verify at 390 px: every card body shares one left x; every card heading text shares a second x one gutter to the right; the `<h1>` + any `SectionHeading` sit on the container edge.
## 9. One scroll floor — sub-shells never re-declare bottom-space
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>` — a nested reserve stacks on the shell budget and scrolls past the last card.
A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source** — `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`) — applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget.
## 10. Navigation — utilities live only in the user menu
Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …) — never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen.
## 11. Inline action row — the PageHeader pattern
A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape — reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`).
## 12. The action row — one place, one shape
A card's actions live in **one** row, and that row is the **last thing in the card**. Reference implementations: `src/components/settings/score-section.tsx`, `src/components/settings/coach-prefs-section.tsx`.
- **Use `SettingsCardActions`** (`settings/_card-actions.tsx`). It owns the row: `flex flex-wrap items-center gap-2`, `justify-end` by default. Never hand-roll a `flex justify-end gap-2` div in a Settings or Admin card.
- **Primary action:** last child of the row (rightmost), `variant` default, `size="sm"` + `min-h-11`. One per card.
- **Secondary action:** `outline`, before the primary in the same row. Cancel/reset are secondary, never `ghost` in the same row as a primary.
- **Destructive card-wide action:** the same row, `variant="destructive"`. Never hand-tinted (`text-destructive border-destructive/30` on an `outline` is drift).
- **Nothing follows the action row.** Status lines, result lists, and confirmations belong **above** it; a result set large enough to need its own frame becomes its own card or a collapsible.
- **The header `status` slot carries status, not actions** — a badge, a pill, a switch. Not a primary, not a destructive, not a filter group. Filters get a toolbar row above the content.
- **`outline` has exactly one meaning: secondary.** A card whose only actions are deep links out (privacy, about, export) has no primary — those links stay `outline size="sm"` and the row uses `align="start"`, because a navigation row reads with the text, not against it. That is the one documented exception, and `SettingsCardActions` spells it (`align="start"`).
## 13. Settings + Admin card rhythm — owned by the container
- **`SettingsCard` is gap-based**, like `Card`: `flex flex-col gap-4 p-4 md:p-6`. The header→body distance comes from the container's gap. A call site never adds `mt-4` to its first body child, never `mb-4` to the header, never `space-y-4` to the card. A denser card overrides the gap on the card itself (`className="gap-2"`), exactly as `Card` does.
- **The body starts at the card edge.** The old `pl-7` gutter (icon box + `gap-2`) is abolished: it is off-scale (§2 allows `1/1.5/2/3/4/6/8/10`), it broke the moment two neighbouring cards disagreed, and it silently mis-aligned action rows against their own forms. Header text is offset from the card edge by the icon gutter; body content is not. This is the deliberate difference from §8's Insights gutter, where the tiles carry no body form.
- **Group headings above a group of cards use `SectionHeading`** (`ui/section-heading.tsx` — the Insights overview's heading, which is not an Insights component and moved out of that folder), not a bare `SettingsCardHeader`. `text-base` on the group, `text-lg` on the card title: that step is what says which one labels which.
- **Admin uses the Settings anatomy verbatim.** No admin-only card shell, no admin-only header, no admin-only mini tile — `SettingsCard`, `SettingsCardHeader`, `SettingsCardActions`, `StatTile` (`admin/_stat-tile.tsx`) are the whole vocabulary.
- **A loading state paints the card and its header.** A section that renders a bare spinner while loading and a card afterwards moves the page under the cursor. Reference: `admin/coach-feedback-section.tsx`.
### Enforcement follow-up
The `p-5` / `py-5` / `px-5` / `space-y-5` family is **zero** app-wide, and `healthlog/spacing-scale` runs at `error` over `src/components/**` + `src/app/**`, so the banned step cannot come back silently. `no-raw-card-shell` is live since v1.43.8 (see §7); what remains planned is the tile-header icon-colour check.
@@ -0,0 +1,93 @@
# Push a room's CO2, temperature and humidity to HealthLog every few minutes.
#
# Needs the `healthlog_room_climate` rest_command from
# docs/integrations/home-assistant.md ("Room climate") and a measurement
# ingest token. Every sensor is optional; a sensor that reports `unknown`,
# `unavailable` or a non-number is left out of that push, and a push with
# nothing left is skipped. Temperatures in °F are converted to °C.
blueprint:
name: HealthLog room climate
description: >-
Sends indoor CO2 (ppm), temperature (°C) and relative humidity (%) to
HealthLog through a measurement ingest token. HealthLog keeps each
reading and shows the day's mean, low and high.
domain: automation
source_url: https://github.com/MBombeck/HealthLog/blob/main/docs/integrations/blueprints/healthlog-room-climate.yaml
input:
co2_sensor:
name: CO2 sensor
description: A sensor reporting carbon dioxide in ppm.
default: ""
selector:
entity:
domain: sensor
device_class: carbon_dioxide
temperature_sensor:
name: Temperature sensor
description: A sensor reporting the room temperature (°C or °F).
default: ""
selector:
entity:
domain: sensor
device_class: temperature
humidity_sensor:
name: Humidity sensor
description: A sensor reporting relative humidity in %.
default: ""
selector:
entity:
domain: sensor
device_class: humidity
interval_minutes:
name: Interval
description: Minutes between pushes. 15 is plenty for a daily picture.
default: 15
selector:
number:
min: 5
max: 60
step: 5
unit_of_measurement: min
mode: single
variables:
co2_sensor: !input co2_sensor
temperature_sensor: !input temperature_sensor
humidity_sensor: !input humidity_sensor
interval_minutes: !input interval_minutes
# A list of readings (Home Assistant keeps a rendered list a list).
readings: >-
{% set at = now().replace(second=0, microsecond=0).isoformat() %}
{% set ns = namespace(items=[]) %}
{% for type, entity in [
('INDOOR_CO2', co2_sensor),
('INDOOR_TEMPERATURE', temperature_sensor),
('INDOOR_HUMIDITY', humidity_sensor)
] %}
{% if entity and is_number(states(entity)) %}
{% set v = states(entity) | float %}
{% if type == 'INDOOR_TEMPERATURE'
and state_attr(entity, 'unit_of_measurement') == '°F' %}
{% set v = ((v - 32) * 5 / 9) | round(2) %}
{% endif %}
{% set ns.items = ns.items
+ [{'type': type, 'value': v, 'measuredAt': at}] %}
{% endif %}
{% endfor %}
{{ ns.items }}
trigger:
- platform: time_pattern
minutes: "/5"
condition:
- condition: template
value_template: >-
{{ now().minute % (interval_minutes | int(15)) == 0
and readings | count > 0 }}
action:
- service: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
+80
View File
@@ -149,6 +149,86 @@ Units are derived server-side from the type, so send the number in the unit
above and do not send a `unit` field. The full enum is in
`docs/api/openapi.yaml` under `MeasurementType`.
## Room climate
Indoor CO₂, temperature and humidity are their own types, kept apart from the
body temperatures and from the outdoor weather the environment module pulls:
| What the sensor reports | `type` | Unit expected |
| ----------------------- | -------------------- | ------------- |
| Carbon dioxide | `INDOOR_CO2` | ppm |
| Room temperature | `INDOOR_TEMPERATURE` | °C |
| Relative humidity | `INDOOR_HUMIDITY` | % |
Each push is kept as a reading, and the day's mean, low and high are what the
charts and the daily views read, so a sensor sampled every 15 minutes gives a
clear picture without anything further on your side. Pushing every few seconds
adds rows, not detail.
Add a second `rest_command` that takes a ready-made list of readings, so one
request carries all three:
```yaml
rest_command:
healthlog_room_climate:
url: "https://healthlog.example.com/api/measurements"
method: POST
headers:
Authorization: !secret healthlog_token
Content-Type: "application/json"
payload: "{{ readings | to_json }}"
```
Then import the blueprint, either from
[`docs/integrations/blueprints/healthlog-room-climate.yaml`](./blueprints/healthlog-room-climate.yaml)
or by its raw GitHub URL under Settings → Automations & scenes → Blueprints →
Import blueprint, and create one automation per room. It asks for the three
sensors (each optional) and an interval. Every push:
- leaves out a sensor that reports `unknown`, `unavailable` or anything that is
not a number, and skips the push when nothing is left;
- converts a temperature reported in °F to °C;
- stamps all readings with the current minute, so a retry of the same push is
refused with a 409 instead of written twice.
If you would rather write the automation yourself, this is the same thing for a
single room without the blueprint:
```yaml
automation:
- alias: "Push bedroom climate to HealthLog"
trigger:
- platform: time_pattern
minutes: "/15"
variables:
readings: >-
{% set at = now().replace(second=0, microsecond=0).isoformat() %}
{% set ns = namespace(items=[]) %}
{% for type, entity in [
('INDOOR_CO2', 'sensor.bedroom_co2'),
('INDOOR_TEMPERATURE', 'sensor.bedroom_temperature'),
('INDOOR_HUMIDITY', 'sensor.bedroom_humidity')
] %}
{% if is_number(states(entity)) %}
{% set ns.items = ns.items + [{'type': type,
'value': states(entity) | float, 'measuredAt': at}] %}
{% endif %}
{% endfor %}
{{ ns.items }}
condition:
- condition: template
value_template: "{{ readings | count > 0 }}"
action:
- service: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
```
One request takes up to five readings, so a room is one call. Several rooms
are several automations: the readings carry no room name, and two rooms pushed
in the same minute collide on the 409 above. If you track more than one room,
pick the one you sleep in; that is the one worth reading next to your sleep.
## Sending several readings at once
A bare array writes up to five readings in one request — the shape to use for
+10 -8
View File
@@ -11,14 +11,16 @@ already installed, longer if a TLS-fronted public hostname is in scope
pulls a multi-arch image from GitHub Container Registry (linux/amd64
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
unchanged.
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
measurement history; the Apple Health import worker can briefly
hold a 1.5 GB upload in `/tmp` while it parses. The app container is
capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node then sizes its
heap at about 792 MB). On a 2 GB host that leaves too little for
Postgres and the system, so set `APP_MEMORY_LIMIT=1g` in `.env`
(a heap of about 524 MB); a long-used account then runs closer to
that heap, and the support report and Admin > System status say so.
- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data
volume grows with measurement history; the Apple Health import worker
can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app
container is capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node
then sizes its heap at about 792 MB), and Postgres and the system need
the rest. 1536 MB is the supported floor for the app container: do not
lower it to fit a smaller host. Below it, a long-used account or a large
import can fill the heap and restart the app; the app still starts, but
the boot log, the support report and Admin > System status say it runs
below the supported floor. On a busy instance, raise it.
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
integration endpoints you plan to enable (Withings, OpenAI, etc.).
The opt-in environmental-context module additionally reaches
+5 -4
View File
@@ -1,9 +1,10 @@
# HealthLog UI Guidelines
> Single source of truth for visual & interaction patterns in the HealthLog
> PWA. Consult this document before adding a new component, page, or
> setting. If a pattern below doesn't fit your case, propose an addition
> here in the same PR.
> Visual & interaction patterns in the HealthLog PWA. The binding rules
> for card anatomy, spacing, text colour, tokens and primitives live in
> [`contributing/ui-standards.md`](./contributing/ui-standards.md); where
> this document disagrees with them, the standards win. If a pattern
> below doesn't fit your case, propose an addition in the same PR.
Last updated: 2026-05-08 (v1.4 cycle)
+1 -1
View File
@@ -135,7 +135,7 @@ test.describe("settings + admin vertical over-scroll guard", () => {
expect(dims, "main-content / wrapper present").not.toBeNull();
if (!dims) return;
// One-scroll-floor (UI-STANDARDS §9), structural half. `<main>` may
// One-scroll-floor (ui-standards.md §9), structural half. `<main>` may
// only be the ONLY vertical scroll surface if it also OWNS the
// containing block of its absolutely-positioned descendants: an
// abspos box with no positioned ancestor resolves against the
+7 -3
View File
@@ -54,7 +54,7 @@ export async function resetAiOptionalAccount(): Promise<void> {
`UPDATE users SET
ai_provider = NULL,
ai_base_url = NULL,
insights_cached_text = NULL,
insights_cached_text_encrypted = NULL,
insights_cached_at = NULL,
insights_cached_locale = NULL,
disable_coach = false
@@ -80,16 +80,20 @@ export async function seedStoredBriefing(marker: string): Promise<void> {
// Generated now: only text written today is served as today's read.
briefingGeneratedAt: new Date().toISOString(),
};
// Sealed the way the app writes it; there is no readable column to seed.
const { sealInsightsCachedText } =
await import("@/lib/insights/cached-text-cipher");
const sealed = Buffer.from(sealInsightsCachedText(JSON.stringify(insights)));
await withAccount(async (pool, userId) => {
await pool.query(
`UPDATE users SET
ai_provider = 'LOCAL',
ai_base_url = 'http://127.0.0.1:9/v1',
insights_cached_text = $2,
insights_cached_text_encrypted = $2,
insights_cached_at = now(),
insights_cached_locale = 'en'
WHERE id = $1`,
[userId, JSON.stringify(insights)],
[userId, sealed],
);
await pool.query(
`INSERT INTO consent_receipts (id, user_id, kind, artefact, signed_at)
+1 -1
View File
@@ -802,7 +802,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
codex_connected_at = NULL,
codex_connection_status = 'disconnected',
insights_cached_at = NULL,
insights_cached_text = NULL,
insights_cached_text_encrypted = NULL,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json,
@@ -124,7 +124,7 @@ async function expectAnnounced(page: Page, selector: string) {
* Copy somebody has to act on is content, not meta.
*
* Two halves, and both are needed. The class check is the design standard —
* UI-STANDARDS §3 reserves `text-muted-foreground` for meta, and consent or
* ui-standards.md §3 reserves `text-muted-foreground` for meta, and consent or
* safety text rendered as meta is text people skip. The ratio check is the
* measurable one: it walks up for an opaque background and asserts the AA
* floor for body text, so a future token change that keeps the class and
+2 -2
View File
@@ -50,7 +50,7 @@
* `eslint-disable-next-line` with the reason — visible in review — rather
* than a silent registry entry here.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2 + §7.
* @see docs/contributing/ui-standards.md §1 + §2 + §7.
*/
"use strict";
@@ -181,7 +181,7 @@ const spacingScaleRule = {
slotPadding:
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
cardShellOffScale:
'Off-scale "{{match}}" on a bg-card+border shell — the scale is 1/1.5/2/3/4/6/8/10, so the `5`/`7` steps and half-steps other than 1.5 are banned (UI-STANDARDS §2) and read off next to every sibling <Card> (p-4 md:p-6). Sweep the shell to an on-scale step (`p-4 md:p-6`, or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
'Off-scale "{{match}}" on a bg-card+border shell — the scale is 1/1.5/2/3/4/6/8/10, so the `5`/`7` steps and half-steps other than 1.5 are banned (ui-standards.md §2) and read off next to every sibling <Card> (p-4 md:p-6). Sweep the shell to an on-scale step (`p-4 md:p-6`, or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
},
},
create(context) {
+17
View File
@@ -911,6 +911,15 @@
"unitRating": "Stufe",
"typeWho5Score": "WHO-5-Wert",
"typeSciScore": "SCI-Wert",
"typeExerciseMinutes": "Trainingsminuten",
"typeStandMinutes": "Stehminuten",
"typeStandHours": "Stehstunden",
"typeMindfulMinutes": "Achtsamkeitsminuten",
"typeStateOfMindValence": "Gemütszustand",
"typeAfibBurden": "VHF-Last",
"typeIndoorCo2": "CO₂ (innen)",
"typeIndoorTemperature": "Raumtemperatur",
"typeIndoorHumidity": "Luftfeuchtigkeit (innen)",
"durationReadAs": "Gelesen als {duration}",
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
"durationHoursOnly": "{hours} Std.",
@@ -3565,6 +3574,7 @@
"falls": "Stürze",
"years": "Jahre",
"breathingEvents": "Ereignisse",
"standHours": "Stunden",
"respiratoryRate": "Atemzüge/min"
},
"sleep": {
@@ -7155,6 +7165,12 @@
"profile": "Dein Profil, deine Anamnese und die Angaben für die Gesundheitsakte",
"modules": "Einträge der Module: {list}"
},
"workoutRoutes": {
"title": "Trainingsrouten",
"description": "Den GPS-Verlauf neuer Trainings aus der iPhone-App und dem Apple-Health-Export speichern.",
"detail": "Standardmäßig aus. Eine Route zeigt, wo du wohnst, arbeitest und trainierst, deshalb wird sie erst gespeichert, wenn du das einschaltest. Die Trainings selbst werden immer gespeichert, und keine KI-Funktion liest jemals eine Route. Ausschalten stoppt neue Routen; schon gespeicherte bleiben, bis du das Training löschst.",
"loadError": "Die Routen-Einstellung konnte nicht geladen werden."
},
"retention": {
"title": "Aufbewahrung",
"description": "Wie lange zeitgebundene Datensätze vor der automatischen Bereinigung gespeichert bleiben.",
@@ -7600,6 +7616,7 @@
"heapLimitLabel": "Heap-Grenze",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, zu knapp. APP_MEMORY_LIMIT=1536m in .env setzen",
"memoryBelowFloor": "Container {mb} MB, unter der unterstützten Untergrenze von {floor} MB. APP_MEMORY_LIMIT={floor}m in .env setzen",
"offlineGeoOff": "Aus",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "API-Endpunkte und API-Tokens erlauben.",
+17
View File
@@ -911,6 +911,15 @@
"unitRating": "rating",
"typeWho5Score": "WHO-5 score",
"typeSciScore": "SCI score",
"typeExerciseMinutes": "Exercise minutes",
"typeStandMinutes": "Standing minutes",
"typeStandHours": "Stand hours",
"typeMindfulMinutes": "Mindful minutes",
"typeStateOfMindValence": "State of mind",
"typeAfibBurden": "AFib burden",
"typeIndoorCo2": "Indoor CO₂",
"typeIndoorTemperature": "Indoor temperature",
"typeIndoorHumidity": "Indoor humidity",
"durationReadAs": "Read as {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
@@ -3565,6 +3574,7 @@
"falls": "falls",
"years": "years",
"breathingEvents": "events",
"standHours": "hours",
"respiratoryRate": "breaths/min"
},
"sleep": {
@@ -7155,6 +7165,12 @@
"profile": "Your profile, medical history and health-record details",
"modules": "Records of each module: {list}"
},
"workoutRoutes": {
"title": "Workout routes",
"description": "Keep the GPS track of new workouts from the iPhone app and the Apple Health export.",
"detail": "Off by default. A route shows where you live, work and train, so it is only kept once you turn this on. Workouts are kept either way, and no AI feature ever reads a route. Turning it off stops new routes; routes already kept stay until you delete the workout.",
"loadError": "The route setting could not be loaded."
},
"retention": {
"title": "Retention",
"description": "How long time-bound records are kept before automatic cleanup.",
@@ -7600,6 +7616,7 @@
"heapLimitLabel": "Heap limit",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, low. Set APP_MEMORY_LIMIT=1536m in .env",
"memoryBelowFloor": "Container {mb} MB, below the supported floor of {floor} MB. Set APP_MEMORY_LIMIT={floor}m in .env",
"offlineGeoOff": "Off",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "Allow API endpoints and API tokens.",
+17
View File
@@ -911,6 +911,15 @@
"unitRating": "nivel",
"typeWho5Score": "Puntuación WHO-5",
"typeSciScore": "Puntuación SCI",
"typeExerciseMinutes": "Minutos de ejercicio",
"typeStandMinutes": "Minutos de pie",
"typeStandHours": "Horas de pie",
"typeMindfulMinutes": "Minutos de atención plena",
"typeStateOfMindValence": "Estado de ánimo",
"typeAfibBurden": "Carga de FA",
"typeIndoorCo2": "CO₂ interior",
"typeIndoorTemperature": "Temperatura interior",
"typeIndoorHumidity": "Humedad interior",
"durationReadAs": "Se lee como {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
@@ -3565,6 +3574,7 @@
"falls": "caídas",
"years": "años",
"breathingEvents": "eventos",
"standHours": "horas",
"respiratoryRate": "respiraciones/min"
},
"sleep": {
@@ -7155,6 +7165,12 @@
"profile": "Tu perfil, tus antecedentes médicos y los datos del historial de salud",
"modules": "Registros de cada módulo: {list}"
},
"workoutRoutes": {
"title": "Rutas de entrenamiento",
"description": "Guardar el recorrido GPS de los entrenamientos nuevos de la app de iPhone y de la exportación de Apple Salud.",
"detail": "Desactivado por defecto. Una ruta muestra dónde vives, trabajas y entrenas, así que solo se guarda cuando lo activas. Los entrenamientos se guardan siempre y ninguna función de IA lee nunca una ruta. Al desactivarlo dejan de guardarse rutas nuevas; las ya guardadas se quedan hasta que borres el entrenamiento.",
"loadError": "No se pudo cargar el ajuste de rutas."
},
"retention": {
"title": "Retención",
"description": "Cuánto tiempo se conservan los registros con límite temporal antes de su limpieza automática.",
@@ -7600,6 +7616,7 @@
"heapLimitLabel": "Límite de heap",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, escaso: define APP_MEMORY_LIMIT=1536m en .env",
"memoryBelowFloor": "Contenedor {mb} MB, por debajo del mínimo admitido de {floor} MB: define APP_MEMORY_LIMIT={floor}m en .env",
"offlineGeoOff": "Desactivado",
"offlineGeoChosen": "En línea ({host})",
"apiGlobal": "Permitir endpoints de API y tokens de API.",
+17
View File
@@ -911,6 +911,15 @@
"unitRating": "niveau",
"typeWho5Score": "Score WHO-5",
"typeSciScore": "Score SCI",
"typeExerciseMinutes": "Minutes d’exercice",
"typeStandMinutes": "Minutes debout",
"typeStandHours": "Heures debout",
"typeMindfulMinutes": "Minutes de pleine conscience",
"typeStateOfMindValence": "État d’esprit",
"typeAfibBurden": "Charge de FA",
"typeIndoorCo2": "CO₂ intérieur",
"typeIndoorTemperature": "Température intérieure",
"typeIndoorHumidity": "Humidité intérieure",
"durationReadAs": "Compris comme {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
@@ -3565,6 +3574,7 @@
"falls": "chutes",
"years": "ans",
"breathingEvents": "événements",
"standHours": "heures",
"respiratoryRate": "respirations/min"
},
"sleep": {
@@ -7155,6 +7165,12 @@
"profile": "Votre profil, vos antécédents médicaux et les informations du dossier de santé",
"modules": "Données de chaque module : {list}"
},
"workoutRoutes": {
"title": "Parcours d’entraînement",
"description": "Conserver le tracé GPS des nouveaux entraînements de l’app iPhone et de l’export Apple Santé.",
"detail": "Désactivé par défaut. Un parcours montre où vous habitez, travaillez et vous entraînez : il n’est donc conservé qu’une fois l’option activée. Les entraînements sont conservés dans tous les cas, et aucune fonction d’IA ne lit jamais un parcours. Désactiver l’option arrête les nouveaux parcours ; ceux déjà conservés restent jusqu’à la suppression de l’entraînement.",
"loadError": "Impossible de charger le réglage des parcours."
},
"retention": {
"title": "Conservation",
"description": "Durée de conservation des enregistrements limités dans le temps avant le nettoyage automatique.",
@@ -7600,6 +7616,7 @@
"heapLimitLabel": "Limite du tas",
"heapLimitValue": "{mb} Mo",
"heapLimitLow": "{mb} Mo, trop juste : définissez APP_MEMORY_LIMIT=1536m dans .env",
"memoryBelowFloor": "Conteneur {mb} Mo, sous le minimum pris en charge de {floor} Mo : définissez APP_MEMORY_LIMIT={floor}m dans .env",
"offlineGeoOff": "Désactivé",
"offlineGeoChosen": "En ligne ({host})",
"apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.",
+17
View File
@@ -911,6 +911,15 @@
"unitRating": "livello",
"typeWho5Score": "Punteggio WHO-5",
"typeSciScore": "Punteggio SCI",
"typeExerciseMinutes": "Minuti di esercizio",
"typeStandMinutes": "Minuti in piedi",
"typeStandHours": "Ore in piedi",
"typeMindfulMinutes": "Minuti di consapevolezza",
"typeStateOfMindValence": "Stato d’animo",
"typeAfibBurden": "Carico di FA",
"typeIndoorCo2": "CO₂ interna",
"typeIndoorTemperature": "Temperatura interna",
"typeIndoorHumidity": "Umidità interna",
"durationReadAs": "Letto come {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
@@ -3565,6 +3574,7 @@
"falls": "cadute",
"years": "anni",
"breathingEvents": "eventi",
"standHours": "ore",
"respiratoryRate": "respiri/min"
},
"sleep": {
@@ -7155,6 +7165,12 @@
"profile": "Il tuo profilo, l'anamnesi e i dati della cartella sanitaria",
"modules": "Dati di ogni modulo: {list}"
},
"workoutRoutes": {
"title": "Percorsi degli allenamenti",
"description": "Conserva il tracciato GPS dei nuovi allenamenti dall’app per iPhone e dall’esportazione di Apple Salute.",
"detail": "Disattivato di default. Un percorso mostra dove vivi, lavori e ti alleni, quindi viene conservato solo dopo averlo attivato. Gli allenamenti vengono conservati comunque e nessuna funzione IA legge mai un percorso. Disattivandolo si fermano i nuovi percorsi; quelli già conservati restano finché non elimini l’allenamento.",
"loadError": "Impossibile caricare l’impostazione dei percorsi."
},
"retention": {
"title": "Conservazione",
"description": "Per quanto tempo i record a tempo vengono conservati prima della pulizia automatica.",
@@ -7600,6 +7616,7 @@
"heapLimitLabel": "Limite heap",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, poco: imposta APP_MEMORY_LIMIT=1536m in .env",
"memoryBelowFloor": "Container {mb} MB, sotto il minimo supportato di {floor} MB: imposta APP_MEMORY_LIMIT={floor}m in .env",
"offlineGeoOff": "Disattivato",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "Consentire endpoint API e token API.",
+17
View File
@@ -911,6 +911,15 @@
"unitRating": "단계",
"typeWho5Score": "WHO-5 점수",
"typeSciScore": "SCI 점수",
"typeExerciseMinutes": "운동 시간(분)",
"typeStandMinutes": "서 있기 시간(분)",
"typeStandHours": "일어서기 시간",
"typeMindfulMinutes": "마음 챙기기 시간(분)",
"typeStateOfMindValence": "마음 상태",
"typeAfibBurden": "심방세동 부담",
"typeIndoorCo2": "실내 CO₂",
"typeIndoorTemperature": "실내 온도",
"typeIndoorHumidity": "실내 습도",
"durationReadAs": "{duration}(으)로 읽었어요",
"durationHoursMinutes": "{hours}시간 {minutes}분",
"durationHoursOnly": "{hours}시간",
@@ -3565,6 +3574,7 @@
"falls": "회",
"years": "세",
"breathingEvents": "회",
"standHours": "시간",
"respiratoryRate": "회/분"
},
"sleep": {
@@ -7155,6 +7165,12 @@
"profile": "프로필, 병력, 건강 기록 정보",
"modules": "모듈별 기록: {list}"
},
"workoutRoutes": {
"title": "운동 경로",
"description": "iPhone 앱과 Apple 건강 내보내기에서 새 운동의 GPS 경로를 저장합니다.",
"detail": "기본값은 꺼짐입니다. 경로는 사는 곳, 일하는 곳, 운동하는 곳을 보여 주므로 이 설정을 켠 뒤에만 저장됩니다. 운동 기록은 항상 저장되며, 어떤 AI 기능도 경로를 읽지 않습니다. 끄면 새 경로 저장이 멈추고, 이미 저장된 경로는 운동을 삭제할 때까지 남습니다.",
"loadError": "경로 설정을 불러오지 못했습니다."
},
"retention": {
"title": "보관 기간",
"description": "자동 정리 전까지 기간이 정해진 기록을 얼마나 보관하는지예요.",
@@ -7575,6 +7591,7 @@
"heapLimitLabel": "힙 한도",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, 부족합니다. .env에 APP_MEMORY_LIMIT=1536m을 설정하세요",
"memoryBelowFloor": "컨테이너 {mb} MB, 지원 최소치 {floor} MB 미만입니다. .env에 APP_MEMORY_LIMIT={floor}m을 설정하세요",
"offlineGeoOff": "꺼짐",
"offlineGeoChosen": "온라인 ({host})",
"apiGlobal": "API 엔드포인트와 API 토큰을 허용해요.",
+17
View File
@@ -911,6 +911,15 @@
"unitRating": "beoordeling",
"typeWho5Score": "WHO-5-score",
"typeSciScore": "SCI-score",
"typeExerciseMinutes": "Trainingsminuten",
"typeStandMinutes": "Staminuten",
"typeStandHours": "Stauren",
"typeMindfulMinutes": "Mindfulnessminuten",
"typeStateOfMindValence": "Gemoedstoestand",
"typeAfibBurden": "AF-belasting",
"typeIndoorCo2": "CO₂ binnen",
"typeIndoorTemperature": "Binnentemperatuur",
"typeIndoorHumidity": "Luchtvochtigheid binnen",
"durationReadAs": "Gelezen als {duration}",
"durationHoursMinutes": "{hours} u {minutes} min",
"durationHoursOnly": "{hours} u",
@@ -3565,6 +3574,7 @@
"falls": "valpartijen",
"years": "jaar",
"breathingEvents": "gebeurtenissen",
"standHours": "uur",
"respiratoryRate": "ademhalingen/min"
},
"sleep": {
@@ -7155,6 +7165,12 @@
"profile": "Je profiel, medische voorgeschiedenis en dossiergegevens",
"modules": "Gegevens van elke module: {list}"
},
"workoutRoutes": {
"title": "Trainingsroutes",
"description": "Het GPS-spoor van nieuwe trainingen uit de iPhone-app en de Apple Gezondheid-export bewaren.",
"detail": "Standaard uit. Een route laat zien waar je woont, werkt en traint, dus hij wordt pas bewaard als je dit aanzet. Trainingen worden altijd bewaard en geen enkele AI-functie leest ooit een route. Uitzetten stopt nieuwe routes; al bewaarde routes blijven tot je de training verwijdert.",
"loadError": "De route-instelling kon niet worden geladen."
},
"retention": {
"title": "Bewaartermijn",
"description": "Hoe lang tijdgebonden gegevens worden bewaard voordat ze automatisch worden opgeschoond.",
@@ -7600,6 +7616,7 @@
"heapLimitLabel": "Heaplimiet",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, laag. Stel APP_MEMORY_LIMIT=1536m in via .env",
"memoryBelowFloor": "Container {mb} MB, onder het ondersteunde minimum van {floor} MB. Stel APP_MEMORY_LIMIT={floor}m in via .env",
"offlineGeoOff": "Uit",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "API-endpoints en API-tokens toestaan.",
+17
View File
@@ -911,6 +911,15 @@
"unitRating": "poziom",
"typeWho5Score": "Wynik WHO-5",
"typeSciScore": "Wynik SCI",
"typeExerciseMinutes": "Minuty ćwiczeń",
"typeStandMinutes": "Minuty stania",
"typeStandHours": "Godziny stania",
"typeMindfulMinutes": "Minuty uważności",
"typeStateOfMindValence": "Stan umysłu",
"typeAfibBurden": "Obciążenie AF",
"typeIndoorCo2": "CO₂ w pomieszczeniu",
"typeIndoorTemperature": "Temperatura w pomieszczeniu",
"typeIndoorHumidity": "Wilgotność w pomieszczeniu",
"durationReadAs": "Odczytano jako {duration}",
"durationHoursMinutes": "{hours} godz. {minutes} min",
"durationHoursOnly": "{hours} godz.",
@@ -3565,6 +3574,7 @@
"falls": "upadki",
"years": "lata",
"breathingEvents": "zdarzenia",
"standHours": "godz.",
"respiratoryRate": "oddechów/min"
},
"sleep": {
@@ -7155,6 +7165,12 @@
"profile": "Twój profil, wywiad medyczny i dane dokumentacji zdrowotnej",
"modules": "Wpisy poszczególnych modułów: {list}"
},
"workoutRoutes": {
"title": "Trasy treningów",
"description": "Zapisuj ślad GPS nowych treningów z aplikacji na iPhone’a i z eksportu Apple Zdrowie.",
"detail": "Domyślnie wyłączone. Trasa pokazuje, gdzie mieszkasz, pracujesz i trenujesz, więc zapisujemy ją dopiero po włączeniu. Treningi są zapisywane zawsze, a żadna funkcja AI nigdy nie odczytuje trasy. Wyłączenie zatrzymuje nowe trasy; już zapisane zostają do usunięcia treningu.",
"loadError": "Nie udało się wczytać ustawienia tras."
},
"retention": {
"title": "Retencja",
"description": "Jak długo rekordy ograniczone czasowo są przechowywane przed automatycznym czyszczeniem.",
@@ -7600,6 +7616,7 @@
"heapLimitLabel": "Limit sterty",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, za mało: ustaw APP_MEMORY_LIMIT=1536m w .env",
"memoryBelowFloor": "Kontener {mb} MB, poniżej obsługiwanego minimum {floor} MB: ustaw APP_MEMORY_LIMIT={floor}m w .env",
"offlineGeoOff": "Wyłączone",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "Zezwól na punkty końcowe API i tokeny API.",
@@ -0,0 +1,15 @@
-- Drop the readable copy of the cached AI insights payload.
--
-- Since 0411 every write goes to `insights_cached_text_encrypted` and leaves
-- `insights_cached_text` NULL, and the boot-time free-text encryption backfill
-- sealed the rows written before. Nothing reads the readable column any more.
--
-- A host that skipped the releases between 0411 and this one may still hold a
-- readable payload the backfill never reached. It is a cache of model output,
-- not a record: the next nightly or on-demand warm writes a fresh, sealed one.
-- Dropping it is the safe direction; keeping it would leave health text in the
-- clear on exactly the hosts that upgraded the longest way round.
DROP INDEX IF EXISTS "users_insights_cached_text_backfill_idx";
ALTER TABLE "users" DROP COLUMN IF EXISTS "insights_cached_text";
@@ -0,0 +1,21 @@
-- Extend `measurement_type` with six Apple Health signals that sat in the
-- deferred list: the exercise and stand rings, mindful minutes, the State of
-- Mind valence, and AFib burden.
--
-- EXERCISE_MINUTES <- HKQuantityTypeIdentifierAppleExerciseTime
-- STAND_MINUTES <- HKQuantityTypeIdentifierAppleStandTime
-- STAND_HOURS <- HKCategoryTypeIdentifierAppleStandHour
-- MINDFUL_MINUTES <- HKCategoryTypeIdentifierMindfulSession
-- STATE_OF_MIND_VALENCE <- HKDataTypeIdentifierStateOfMind (valence)
-- AFIB_BURDEN <- HKQuantityTypeIdentifierAtrialFibrillationBurden
--
-- Purely additive: no existing row carries any of these values. IF NOT
-- EXISTS keeps a replay on a partially applied database harmless. Enum
-- values cannot be dropped in PostgreSQL, so there is no down path.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'EXERCISE_MINUTES';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAND_MINUTES';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAND_HOURS';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'MINDFUL_MINUTES';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STATE_OF_MIND_VALENCE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AFIB_BURDEN';
@@ -0,0 +1,6 @@
-- A per-account opt-in for keeping workout GPS tracks. Off by default: a
-- route shows where a person lives, works and trains, so new tracks are
-- stored only once the person turns this on. Tracks already stored stay
-- where they are; the setting decides what new syncs and imports keep.
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "store_workout_routes" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,11 @@
-- Extend `measurement_type` with room-climate readings pushed through the
-- ingest token (source EXTERNAL), for example by a Home Assistant
-- automation: indoor CO2 (ppm), indoor temperature (celsius) and indoor
-- relative humidity (percent).
--
-- Purely additive. IF NOT EXISTS keeps a replay harmless; enum values cannot
-- be dropped in PostgreSQL, so there is no down path.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_CO2';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_TEMPERATURE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_HUMIDITY';
@@ -0,0 +1,21 @@
-- Nobody who already keeps workout tracks loses them with 0432's opt-in.
--
-- 0432 added `users.store_workout_routes`, off by default. An account that
-- already holds at least one stored track has been syncing routes all along;
-- for it the setting starts ON, so its tracks keep arriving after the
-- upgrade. Only accounts without any stored track start OFF. Runs once, at
-- the upgrade; a person who turns it off afterwards stays off.
UPDATE "users" AS u
SET "store_workout_routes" = true
WHERE EXISTS (
SELECT 1
FROM "workout_routes" AS r
JOIN "workouts" AS w ON w."id" = r."workout_id"
WHERE w."user_id" = u."id"
);
-- Cursor of the one-time climb backfill (`src/lib/workouts/climb-backfill.ts`):
-- workouts stored before this release with a track but no elevation get their
-- metres climbed from the track, in id order, resumable across restarts.
-- NULL = not started, 'done' = finished.
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "workout_climb_backfill_cursor" TEXT;
+29 -8
View File
@@ -136,13 +136,6 @@ model User {
useCentralCodex Boolean @default(false) @map("use_central_codex")
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
insightsCachedAt DateTime? @map("insights_cached_at")
/// @internal: legacy plaintext cache of the AI insights payload, read only
/// as a fallback for rows the free-text encryption backfill has not reached
/// yet (v1.43.7). Every write since then goes to `insightsCachedTextEncrypted`
/// and leaves this NULL; the backfill seals and nulls it on the old rows.
/// DROP this column in a later release, once the backfill reports zero
/// remaining rows on every instance.
insightsCachedText String? @map("insights_cached_text")
/// v1.43.7 — the cached AI insights payload (briefing, per-domain readings,
/// recommendations; JSON), AES-256-GCM binary layout under
/// `INSIGHTS_CACHED_TEXT_AAD`. Read and written only through
@@ -381,6 +374,12 @@ model User {
// review screen remains the safety backstop. Default `false`; vision-capable
// users keep the higher-accuracy native path and never pay the WASM download.
labsLocalOcrEnabled Boolean @default(false) @map("labs_local_ocr_enabled")
/// v1.44 — keep the GPS track of a workout. OFF by default: a route is
/// where a person lives, works and runs, so it is stored only after they
/// opt in. Read by the workout batch route and the export.zip import, which
/// drop the track while it is off (the workout itself is always kept).
/// Never read by any AI path; the AI egress guard freezes the route readers.
storeWorkoutRoutes Boolean @default(false) @map("store_workout_routes")
// v1.7.0 unit pref — global metric/imperial display preference.
// Canonical storage stays SI; this only selects the display-time
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
@@ -1710,6 +1709,24 @@ enum MeasurementType {
// Note the inverted direction versus PHQ-9 / GAD-7: HIGHER is better for both.
WHO5_SCORE // score (0–100) — WHO-5 well-being percentage (raw 0–25 × 4). Opt-in. See MentalHealthAssessment.
SCI_SCORE // score (0–32) — Sleep Condition Indicator total; higher = better sleep. Opt-in. See MentalHealthAssessment.
// ── v1.44 — Apple Health activity rings, mindfulness, mood and AFib burden ──
// All five arrive over POST /api/measurements/batch and the export.zip import.
// The minute and hour types are cumulative (daily SUM); AFib burden and the
// State of Mind valence are spot readings.
EXERCISE_MINUTES // minutes — HealthKit `appleExerciseTime`. The activity ring's exercise minutes; cumulative, daily SUM.
STAND_MINUTES // minutes — HealthKit `appleStandTime`. Minutes spent standing; cumulative, daily SUM.
STAND_HOURS // count — HealthKit `HKCategoryTypeIdentifierAppleStandHour`. One row per hour in which the person stood (`stood` = 1; an `idle` hour is not stored); cumulative, daily SUM gives the stand-ring hours.
MINDFUL_MINUTES // minutes — HealthKit `HKCategoryTypeIdentifierMindfulSession`. Session length from the sample's own dates; cumulative, daily SUM.
STATE_OF_MIND_VALENCE // valence (−1..1) — HealthKit `HKStateOfMind.valence` (iOS 18+). How pleasant the logged emotion or mood felt, from very unpleasant (−1) to very pleasant (1). A self-report, not a measurement.
// ── v1.44 — room climate from a home automation system ──
// Pushed through the ingest token (`measurements:write`, source EXTERNAL),
// e.g. by a Home Assistant automation. Spot readings: the daily rollup
// carries mean, min and max. Indoor, so never mixed with the outdoor
// environment channels.
INDOOR_CO2 // ppm — indoor carbon dioxide concentration.
INDOOR_TEMPERATURE // celsius — indoor air temperature. Distinct from every body temperature type.
INDOOR_HUMIDITY // percent (0–100) — indoor relative humidity.
AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis.
@@map("measurement_type")
}
@@ -5404,6 +5421,10 @@ model AppSettings {
/// Last dry-run status and result (account ordinals only, no ids); the apply
/// step's precondition and the admin card's result.
pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json")
/// @internal: progress marker of the one-time workout climb backfill
/// (`src/lib/workouts/climb-backfill.ts`): the last workout id scanned,
/// `done` when finished, NULL before it starts. Operational, never shown.
workoutClimbBackfillCursor String? @map("workout_climb_backfill_cursor")
@@map("app_settings")
}
@@ -6701,7 +6722,7 @@ model HostMetric {
// (no prompt-tuning flow yet — that ratchet is v1.4.17).
//
// Privacy: server-side fills providerType + promptVersion from the
// user's most recent cached payload (User.insightsCachedText) so the
// user's most recent cached payload (User.insightsCachedTextEncrypted) so the
// client can't tamper with the attribution.
//
// `(userId, recommendationId, recommendationText)` is the dedup key.
+2 -2
View File
@@ -327,7 +327,7 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat
height_cm, date_of_birth, gender, timezone,
codex_access_token_encrypted, codex_refresh_token_encrypted, codex_token_expires_at,
codex_connected_at, codex_connection_status,
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted,
insights_privacy_mode, insights_cached_at, insights_cached_text_encrypted,
insights_exclude_metrics,
global_excluded_injection_sites,
telegram_bot_token, telegram_chat_id, telegram_enabled,
@@ -360,7 +360,7 @@ readonly USERS_SELECT="id, username, email, password_hash,
NULL::timestamptz AS codex_token_expires_at,
NULL::timestamptz AS codex_connected_at,
'disconnected' AS codex_connection_status,
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted,
insights_privacy_mode, insights_cached_at, insights_cached_text_encrypted,
insights_exclude_metrics,
global_excluded_injection_sites,
NULL::text AS telegram_bot_token,
+1 -3
View File
@@ -3555,15 +3555,13 @@ async function seed() {
await client.query(
`UPDATE users
SET insights_cached_text_encrypted = $2,
insights_cached_text = NULL,
insights_cached_at = NOW(),
insights_snapshot_hash = $3,
insights_briefing_reroll_date = $4
WHERE id = $1`,
[
userId,
// Sealed like every write since v1.43.7; the app never reads the
// readable column while a ciphertext is there.
// Sealed like every write since v1.43.7.
Buffer.from(
sealInsightsCachedText(JSON.stringify(comprehensiveInsight)),
),
@@ -288,3 +288,87 @@ describe("the status chokepoint's capability argument", () => {
expect(withCapability.capability).toBe("statusText");
});
});
/**
* Workout GPS tracks never reach a model.
*
* A route shows where a person lives, works and trains, and it is stored only
* after an opt-in. So the set of files that can open or write a track is
* frozen here, with the reason each needs it, and none of them may be a
* provider call site or sit in an AI module. The workout insight used to open
* the track to derive metres climbed; that figure is now filled into
* `Workout.elevationM` at write time (`route-climb.ts`), and the insight reads
* the column. A new reader fails the first check until someone decides, here,
* that it is not an AI path.
*
* Limit: the matcher looks for the cipher helpers, the sealed column and a
* `geometry: true` select. A read under some other spelling (a raw SQL
* `SELECT geometry_encrypted`, a backup dumping the model generically) would
* slip it. The generic backup dump and restore do carry the sealed column;
* they are not AI paths and hand it to no model.
*/
describe("workout route geometry never reaches an AI path", () => {
const ROUTE_READER =
/\breadRouteGeometry\s*\(|\bencryptRouteGeometry\s*\(|\bgeometryEncrypted\b|\bgeometry\s*:\s*true\b/;
const ROUTE_READERS: Record<string, string> = {
"app/api/workouts/[id]/route.ts":
"The workout detail read: serves the track to the person's own map.",
"app/api/workouts/batch/route.ts":
"Writes a kept track (opt-in) and derives its climb before storage.",
"lib/apple-health/route-import.ts":
"Writes a kept track from the Apple Health export (opt-in).",
"lib/crypto/encrypted-columns.ts":
"The encrypted-column registry the key rotation walks.",
"lib/jobs/free-text-encryption-backfill.ts":
"Seals legacy readable tracks in place.",
"lib/workouts/route-geometry-cipher.ts": "The cipher itself.",
"lib/workouts/climb-backfill.ts":
"One-time backfill: derives metres climbed into Workout.elevationM.",
};
/** Directories whose code feeds a model. */
const AI_AREAS = [
"lib/ai/",
"lib/insights/",
"lib/mcp/",
"app/mcp/",
"app/api/insights/",
"app/api/coach/",
];
const found = sourceFiles().filter((rel) => ROUTE_READER.test(code(rel)));
it("finds route readers at all (an empty matcher is a failure, not a pass)", () => {
expect(found.length).toBeGreaterThanOrEqual(4);
});
it("every file that opens or writes a track is named, with a reason", () => {
expect(found.filter((rel) => !(rel in ROUTE_READERS))).toEqual([]);
});
it("the list carries no file that no longer touches a track", () => {
expect(
Object.keys(ROUTE_READERS).filter((rel) => !found.includes(rel)),
).toEqual([]);
});
it("no route reader calls a provider or lives in an AI module", () => {
for (const rel of Object.keys(ROUTE_READERS)) {
expect(rel in EGRESS_SITES, `${rel} is an egress site`).toBe(false);
expect(EGRESS_CALL.test(code(rel)), `${rel} calls a provider`).toBe(
false,
);
expect(
AI_AREAS.some((area) => rel.startsWith(area)),
`${rel} sits in an AI module`,
).toBe(false);
}
});
it("the workout insight job does not read the track", () => {
const job = code("lib/jobs/workout-insight-generate.ts");
expect(job).not.toMatch(/\broute\s*:/);
expect(job).not.toMatch(ROUTE_READER);
});
});
@@ -74,8 +74,8 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(95);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(ALL_LEAF_IDS.length).toBe(104);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18);
});
@@ -1,18 +1,16 @@
/**
* The cached AI insights payload is read and written in one place.
*
* Since v1.43.7 the payload is sealed in `User.insightsCachedTextEncrypted`
* and the readable `insightsCachedText` survives only as a fallback for rows
* the backfill has not reached. Two columns that must move together are a
* standing invitation to move one: a clear written as
* `{ insightsCachedText: null }` — the shape every clear had until this
* release — would now leave the whole sealed briefing behind, after a consent
* withdrawal of all places. So the names are spelled in
* `src/lib/insights/cached-text-cipher.ts`, and every other file goes through
* its select, write, clear and open helpers.
* The payload is sealed in `User.insightsCachedTextEncrypted`; the readable
* `insightsCachedText` column it replaced was dropped in v1.44.0 (migration
* 0430). Every reader and writer goes through
* `src/lib/insights/cached-text-cipher.ts` (its select, write, clear and open
* helpers), so a clear cannot forget the column and a read cannot skip the
* decryption. The column name is spelled there and in the tables that have
* to name every column of the row.
*
* The allowlist below is the set of files with a reason to spell the legacy
* name anyway, each stated. A file that joins it has to say why here.
* The readable name survives only as the snapshot builder's input field, which
* carries the OPENED text, never the column.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -23,42 +21,46 @@ import { stripComments, walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const ALLOWED: Readonly<Record<string, string>> = {
"lib/insights/cached-text-cipher.ts": "the one module that owns the columns",
"lib/jobs/free-text-encryption-backfill.ts":
"the backfill that seals the legacy column",
const SEALED_ALLOWED: Readonly<Record<string, string>> = {
"lib/insights/cached-text-cipher.ts": "the one module that owns the column",
"lib/crypto/encrypted-columns.ts":
"the registry of every ciphertext-bearing column",
"lib/export/backup-plan.ts":
"the backup classification names every User column",
"lib/data-wipe/wipe-plan.ts":
"the wipe reset names every User column it resets, and resets both",
"lib/data-wipe/wipe-plan.ts": "the wipe reset names every User column",
};
const OPENED_TEXT_ALLOWED: Readonly<Record<string, string>> = {
"lib/dashboard/snapshot.ts":
"the snapshot builder's input field carries the OPENED text, not the column",
"the snapshot builder's input field carries the opened text",
"lib/dashboard/snapshot-read.ts":
"fills that input field from openInsightsCachedText",
};
/** The legacy name, not its sealed sibling, outside comments. */
const LEGACY_NAME = /\binsightsCachedText\b(?!Encrypted)/;
const SEALED_NAME = /\binsightsCachedTextEncrypted\b/;
/** The dropped readable name, not its sealed sibling. */
const READABLE_NAME = /\binsightsCachedText\b(?!Encrypted)/;
function filesSpellingTheLegacyName(): string[] {
function sourceFilesMatching(pattern: RegExp): string[] {
return walkSourceFiles(SRC, { floor: 2000 })
.filter((f) => !f.startsWith("generated/"))
.filter((f) => !f.includes("__tests__/") && !/\.test\.tsx?$/.test(f))
.filter((f) =>
LEGACY_NAME.test(stripComments(readFileSync(join(SRC, f), "utf8"))),
);
pattern.test(stripComments(readFileSync(join(SRC, f), "utf8"))),
)
.sort();
}
describe("insights cache column access", () => {
const found = filesSpellingTheLegacyName();
it("is confined to the cipher module and the stated exceptions", () => {
const strays = found.filter((f) => !(f in ALLOWED));
expect(strays).toEqual([]);
it("spells the sealed column only in the cipher module and the stated tables", () => {
const found = sourceFilesMatching(SEALED_NAME);
expect(found.length).toBe(4);
expect(found).toEqual(Object.keys(SEALED_ALLOWED).sort());
});
it("still finds every stated exception, so the matcher is live", () => {
expect(found.length).toBeGreaterThan(5);
expect([...found].sort()).toEqual(Object.keys(ALLOWED).sort());
it("uses the readable name only for the opened text the snapshot builder takes", () => {
const found = sourceFilesMatching(READABLE_NAME);
expect(found.length).toBe(2);
expect(found).toEqual(Object.keys(OPENED_TEXT_ALLOWED).sort());
});
});
@@ -73,6 +73,10 @@ const WRITERS: Record<string, Disposition> = {
validatedIn: "lib/validations/measurement.ts",
},
"app/api/import/route.ts": { kind: "validator" },
// v1.44 — the set-based insert writes rows that already passed the
// route's `validateMeasurementRange` per entry; everything else goes
// through the reconciler, which gates itself.
"app/api/measurements/batch/route.ts": { kind: "validator" },
"lib/mcp/writes.ts": { kind: "validator" },
"lib/measurements/create-from-telegram.ts": { kind: "validator" },
"lib/measurements/import-apple-health-export.ts": { kind: "validator" },
@@ -44,6 +44,7 @@ const WRITE_SITES: Record<string, string> = {
"app/api/measurements/bulk-delete/route.ts": TAIL,
"app/api/measurements/by-external-ids/route.ts": TAIL,
"app/api/measurements/restore/route.ts": TAIL,
"app/api/measurements/batch/route.ts": TAIL,
"app/api/measurements/route.ts": TAIL,
"app/api/mental-health/assessments/route.ts": TAIL,
"lib/insights/score-row.ts": TAIL,
@@ -127,7 +127,7 @@ describe("SESSION_USER_SELECT leaves out bytes and secrets", () => {
it("selects no credential, secret or large cached text", () => {
const secretShaped =
/Encrypted$|^passwordHash$|^totpSecret|^totpLastStep$|^telegramBotToken$|^insightsCachedText$|^environmentAqHistoryJson$/;
/Encrypted$|^passwordHash$|^totpSecret|^totpLastStep$|^telegramBotToken$|^environmentAqHistoryJson$/;
const secrets = columns
.map((c) => c.name)
.filter((name) => secretShaped.test(name));
@@ -457,7 +457,7 @@ describe("the refusal stays on screen, addressed to its own row", () => {
const alert = /<[^>]*data-slot="grant-action-error"[^>]*>/.exec(html)?.[0];
expect(alert).toBeDefined();
// A refusal somebody has to act on is content. UI-STANDARDS §3 reserves
// A refusal somebody has to act on is content. ui-standards.md §3 reserves
// muted for meta, and a sentence nobody reads is a sentence nobody acts
// on.
expect(alert).toContain("text-destructive");
+1 -1
View File
@@ -318,7 +318,7 @@ 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
// "none unlocked yet" empty grid (ui-standards.md §6). Surface an honest
// retry instead.
if (isError) {
return (
@@ -44,12 +44,19 @@ vi.mock("@/lib/jobs/worker-status", () => ({
})),
}));
const heapMock = vi.hoisted(() => ({ mb: 792 }));
const heapMock = vi.hoisted(() => ({
mb: 792,
container: 1536 as number | null,
}));
vi.mock("@/lib/support/heap-limit", async () => {
const actual = await vi.importActual<
typeof import("@/lib/support/heap-limit")
>("@/lib/support/heap-limit");
return { ...actual, heapLimitMb: () => heapMock.mb };
return {
...actual,
heapLimitMb: () => heapMock.mb,
containerMemoryLimitMb: () => heapMock.container,
};
});
const geoMock = vi.hoisted(() => ({
@@ -200,12 +207,40 @@ describe("GET /api/admin/status (integration health summary)", () => {
const low = (await (await GET()).json()) as {
data: { memory: { heapLimitMb: number; low: boolean } };
};
expect(low.data.memory).toEqual({ heapLimitMb: 524, low: true });
expect(low.data.memory).toMatchObject({ heapLimitMb: 524, low: true });
heapMock.mb = 792;
const enough = (await (await GET()).json()) as {
data: { memory: { heapLimitMb: number; low: boolean } };
};
expect(enough.data.memory).toEqual({ heapLimitMb: 792, low: false });
expect(enough.data.memory).toMatchObject({ heapLimitMb: 792, low: false });
});
it("flags a container memory limit below the supported floor", async () => {
type Memory = {
containerLimitMb: number | null;
floorMb: number;
belowFloor: boolean;
};
heapMock.container = 1024;
const below = (await (await GET()).json()) as { data: { memory: Memory } };
expect(below.data.memory).toMatchObject({
containerLimitMb: 1024,
floorMb: 1536,
belowFloor: true,
});
heapMock.container = 1536;
const atFloor = (await (await GET()).json()) as {
data: { memory: Memory };
};
expect(atFloor.data.memory.belowFloor).toBe(false);
// No limit, or none readable: nothing to flag.
heapMock.container = null;
const none = (await (await GET()).json()) as { data: { memory: Memory } };
expect(none.data.memory).toMatchObject({
containerLimitMb: null,
belowFloor: false,
});
heapMock.container = 1536;
});
it("reports named failing queues through failingJobs only", async () => {
+16 -2
View File
@@ -9,7 +9,13 @@ import {
readFailingQueues,
} from "@/lib/jobs/job-failures";
import { readFileSync } from "node:fs";
import { heapLimitMb, isHeapLimitLow } from "@/lib/support/heap-limit";
import {
containerMemoryLimitMb,
heapLimitMb,
isBelowMemoryFloor,
isHeapLimitLow,
MEMORY_FLOOR_MB,
} from "@/lib/support/heap-limit";
import {
geoLookupMode,
geoProviderChosen,
@@ -77,6 +83,8 @@ export const GET = apiHandler(async () => {
// The process's heap ceiling, and whether it is the 1 GB-container size
// that a long-used account can fill (`heap-limit.ts`).
const heapLimit = heapLimitMb();
// And the container's own limit against the supported floor.
const containerLimit = containerMemoryLimitMb();
return apiSuccess({
version: process.env.npm_package_version || "0.1.0",
@@ -85,7 +93,13 @@ export const GET = apiHandler(async () => {
buildTime: BUILD_TIME,
startTime: START_TIME,
database: "connected",
memory: { heapLimitMb: heapLimit, low: isHeapLimitLow(heapLimit) },
memory: {
heapLimitMb: heapLimit,
low: isHeapLimitLow(heapLimit),
containerLimitMb: containerLimit,
floorMb: MEMORY_FLOOR_MB,
belowFloor: isBelowMemoryFloor(containerLimit),
},
worker: {
running: workerStatus.running,
startedAt: workerStatus.startedAt,
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The workout-route opt-in: off by default, a hard-set PATCH, audit-logged,
* and a malformed body refused before anything is written.
*/
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/workout-routes", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
describe("/api/auth/me/workout-routes", () => {
it("reads off when the row carries no value", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
data: { storeWorkoutRoutes: false },
});
});
it("sets the flag field by field and audits the change", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
storeWorkoutRoutes: false,
} as never);
const res = await PATCH(mkPatch({ storeWorkoutRoutes: true }));
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
data: { storeWorkoutRoutes: true },
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { storeWorkoutRoutes: true },
});
expect(auditLog).toHaveBeenCalledWith(
"user.storeWorkoutRoutes.update",
expect.objectContaining({
details: { previous: false, next: true },
}),
);
});
it("refuses a malformed body with 422 and writes nothing", async () => {
const res = await PATCH(mkPatch({ storeWorkoutRoutes: "yes" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses an unauthenticated caller", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PATCH(mkPatch({ storeWorkoutRoutes: true }));
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Per-account opt-in for keeping workout GPS tracks.
*
* GET /api/auth/me/workout-routes — current flag.
* PATCH /api/auth/me/workout-routes — body `{ storeWorkoutRoutes: boolean }`.
*
* Off by default. While it is off, `POST /api/workouts/batch` and the
* Apple Health export import keep every workout but drop its track, so a
* route (where a person lives, works and trains) is stored only after the
* person asked for it. Turning it off later stops new tracks; tracks already
* stored stay until the workout or the account is deleted.
*
* Same per-user-scalar pattern as `labs-local-ocr`: 60/min rate limit, a
* bounded body, Zod safeParse → 422, field-by-field write, audit row, and
* the resolved next state in the response.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { workoutRoutesPatchSchema } from "@/lib/validations/user-prefs";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
type WorkoutRoutesResponse = {
storeWorkoutRoutes: boolean;
};
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.workoutRoutes.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { storeWorkoutRoutes: true },
});
const payload: WorkoutRoutesResponse = {
storeWorkoutRoutes: row?.storeWorkoutRoutes ?? false,
};
return apiSuccess(payload);
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`workout-routes:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = workoutRoutesPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.workoutRoutes.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
const next = parsed.data.storeWorkoutRoutes;
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: { storeWorkoutRoutes: true },
});
await prisma.user.update({
where: { id: user.id },
data: { storeWorkoutRoutes: next },
});
await auditLog("user.storeWorkoutRoutes.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: previous?.storeWorkoutRoutes ?? false,
next,
},
});
annotate({
action: { name: "auth.me.workoutRoutes.patch" },
meta: { storeWorkoutRoutes: next },
});
const payload: WorkoutRoutesResponse = { storeWorkoutRoutes: next };
return apiSuccess(payload);
});
@@ -226,7 +226,6 @@ describe("about-me module gate", () => {
where: { id: "u1" },
data: {
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedTextEncrypted: null,
insightsCachedLocale: null,
},
+1 -1
View File
@@ -4,7 +4,7 @@
* The read seam for the unified daily-value system (P3). Returns the
* `DailyDigest` DTO the Today surface, the daily push, and a future iOS widget
* all consume — assembled by `loadDailyDigest` from ALREADY-CACHED data (the
* nightly briefing lifted read-only from `User.insightsCachedText`, the
* nightly briefing lifted read-only from `User.insightsCachedTextEncrypted`, the
* dashboard-snapshot health score / meds-today / sleep freshness) plus two
* light deterministic reads (broken integrations, overdue Vorsorge). No
* provider call is reachable from this path, and nothing warms on mount.
+1 -1
View File
@@ -26,7 +26,7 @@
* so a regression is attributable without re-instrumenting.
*
* No LLM is reachable from the builder — the daily briefing is lifted
* read-only from `User.insightsCachedText`. The nightly
* read-only from `User.insightsCachedTextEncrypted`. The nightly
* `insight-pregenerate` cron keeps that cache warm.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
@@ -206,7 +206,7 @@ beforeEach(() => {
fullName: null,
insurerName: null,
insuranceNumberEncrypted: null,
insightsCachedText: null,
insightsCachedTextEncrypted: null,
} as never);
});
@@ -163,3 +163,69 @@ describe("GET /api/health under ENCRYPTION_KEY_CHECK=warn", () => {
}
});
});
describe("GET /api/health while the background role starts", () => {
it("says `starting` at once, and `ok` when the role is up", async () => {
const { markStartup } = await import("@/lib/boot/startup-state");
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
markStartup("starting");
try {
const starting = await health();
expect(starting.response.status).toBe(503);
expect(starting.body).toEqual({ status: "degraded", reason: "starting" });
getGlobalBoss.mockReturnValue({});
markStartup("ready");
const ready = await health();
expect(ready.response.status).toBe(200);
expect(ready.body).toEqual({ status: "ok" });
} finally {
markStartup("ready");
}
});
it("does not call a failed start `starting`", async () => {
const { markStartup } = await import("@/lib/boot/startup-state");
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
markStartup("failed");
try {
const { response, body } = await health();
expect(response.status).toBe(503);
expect(body).toEqual({ status: "degraded" });
} finally {
markStartup("ready");
}
});
it("does not wait on a database that does not answer", async () => {
vi.useFakeTimers();
try {
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
getGlobalBoss.mockReturnValue({});
queryRaw.mockImplementation(() => new Promise(() => undefined));
const pending = health();
await vi.advanceTimersByTimeAsync(2_000);
const { response, body } = await pending;
expect(response.status).toBe(503);
expect(body).toEqual({ status: "degraded" });
} finally {
vi.useRealTimers();
}
});
});
describe("GET /api/health — the `starting` window is bounded", () => {
it("drops the reason once a start outlasts any real boot", async () => {
const { markStartup, STARTING_MAX_MS } =
await import("@/lib/boot/startup-state");
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
markStartup("starting", Date.now() - STARTING_MAX_MS - 1_000);
try {
const { response, body } = await health();
expect(response.status).toBe(503);
expect(body).toEqual({ status: "degraded" });
} finally {
markStartup("ready");
}
});
});
+40 -15
View File
@@ -11,19 +11,41 @@ import {
isKeyMismatch,
KEY_MISMATCH_HEALTH_REASON,
} from "@/lib/boot/key-mismatch-state";
import { startupPhase } from "@/lib/boot/startup-state";
/**
* How long the probe waits for `SELECT 1`. During a boot storm the pool can
* be saturated by the worker's start-up work; a probe that waits its turn
* held a monitor for seconds. Past this the database counts as not answering
* and the probe says `degraded` at once.
*/
const DB_PROBE_TIMEOUT_MS = 2_000;
/** Reported while the background role is still coming up. */
const STARTING_HEALTH_REASON = "starting";
async function probeDatabase(): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
prisma.$queryRaw`SELECT 1`.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), DB_PROBE_TIMEOUT_MS);
}),
]);
} catch {
return false;
} finally {
if (timer) clearTimeout(timer);
}
}
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
annotate({ action: { name: "health.check" } });
let dbOk = true;
try {
await prisma.$queryRaw`SELECT 1`;
} catch {
dbOk = false;
}
const dbOk = await probeDatabase();
const worker = getWorkerStatus();
const producerReady = getGlobalBoss() !== null;
@@ -31,20 +53,23 @@ export const GET = apiHandler(async () => {
// other route; the probe says so in a word every caller may see, because
// it is the one place a catalog install's status page reads.
const keyMismatch = isKeyMismatch();
const reason = keyMismatch ? KEY_MISMATCH_HEALTH_REASON : undefined;
// Otherwise, while the background role is still starting, the 503 below
// says so: a transient state, not a fault, and not a hang.
const roleDown =
(shouldRunWeb() && !producerReady) ||
(shouldRunWorker() && !worker.running);
const reason = keyMismatch
? KEY_MISMATCH_HEALTH_REASON
: dbOk && roleDown && startupPhase() === "starting"
? STARTING_HEALTH_REASON
: undefined;
// Under ENCRYPTION_KEY_CHECK=warn the same finding does not refuse; it is
// named as a warning and leaves the status alone.
const warning =
!keyMismatch && getKeyMismatchWarning()
? KEY_MISMATCH_HEALTH_REASON
: undefined;
const status =
!keyMismatch &&
dbOk &&
(!shouldRunWeb() || producerReady) &&
(!shouldRunWorker() || worker.running)
? "ok"
: "degraded";
const status = !keyMismatch && dbOk && !roleDown ? "ok" : "degraded";
const statusCode = status === "ok" ? 200 : 503;
const cacheHeaders = {
@@ -44,7 +44,7 @@ vi.mock("@/lib/db", () => ({
findUnique: vi.fn(async () => ({
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedTextEncrypted: null,
locale: "en",
})),
updateMany: vi.fn(async () => ({ count: 1 })),
@@ -175,7 +175,10 @@ import {
import { annotate } from "@/lib/logging/context";
import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher";
import {
openInsightsCachedText,
sealInsightsCachedText,
} from "@/lib/insights/cached-text-cipher";
// The cached payload is sealed at rest, so its writes need a key.
vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64));
@@ -472,7 +475,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
const user = {
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedTextEncrypted: null,
locale: "en",
unitPreference: "imperial",
glucoseUnit: "mmol/L",
@@ -527,7 +530,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedTextEncrypted: null,
insightsExcludeMetrics: ["sleep"],
locale: "en",
dateOfBirth: null,
@@ -564,7 +567,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedTextEncrypted: null,
insightsExcludeMetrics: [],
locale: "en",
} as never);
@@ -596,11 +599,13 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({
changed: "still fresh",
dailyBriefing: { paragraph: "ok", keyFindings: [] },
briefingGeneratedAt: new Date().toISOString(),
}),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({
changed: "still fresh",
dailyBriefing: { paragraph: "ok", keyFindings: [] },
briefingGeneratedAt: new Date().toISOString(),
}),
),
locale: "en",
} as never);
@@ -636,11 +641,13 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({
changed: "still fresh",
dailyBriefing: { paragraph: "ok", keyFindings: [] },
briefingGeneratedAt: new Date().toISOString(),
}),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({
changed: "still fresh",
dailyBriefing: { paragraph: "ok", keyFindings: [] },
briefingGeneratedAt: new Date().toISOString(),
}),
),
locale: "en",
} as never);
@@ -673,10 +680,12 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({
changed: "fresh but no briefing",
dailyBriefing: null,
}),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({
changed: "fresh but no briefing",
dailyBriefing: null,
}),
),
insightsExcludeMetrics: [],
locale: "en",
} as never);
@@ -686,10 +695,12 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({
dailyBriefing: { paragraph: "today", keyFindings: [] },
briefingGeneratedAt: new Date().toISOString(),
}),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({
dailyBriefing: { paragraph: "today", keyFindings: [] },
briefingGeneratedAt: new Date().toISOString(),
}),
),
locale: "en",
} as never);
@@ -770,7 +781,7 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", ()
// wide shape; the retry asks for the aggregated shape.
insightsPrivacyMode: "raw",
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedTextEncrypted: null,
insightsExcludeMetrics: [],
locale: "en",
} as never);
@@ -817,7 +828,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
};
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify(cached),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify(cached),
),
locale: "en",
} as never);
@@ -842,7 +855,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: stale,
insightsCachedText: JSON.stringify({ dailyBriefing: null }),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({ dailyBriefing: null }),
),
locale: "en",
} as never);
@@ -867,7 +882,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
const cached = { dailyBriefing: { paragraph: "Deutsch", keyFindings: [] } };
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify(cached),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify(cached),
),
insightsCachedLocale: "de",
locale: "en",
} as never);
@@ -896,7 +913,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
};
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify(cached),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify(cached),
),
insightsCachedLocale: "en",
locale: "en",
} as never);
@@ -918,10 +937,12 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
const yesterday = new Date(Date.now() - 30 * 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: new Date(Date.now() - 60_000),
insightsCachedText: JSON.stringify({
dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] },
briefingGeneratedAt: yesterday.toISOString(),
}),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({
dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] },
briefingGeneratedAt: yesterday.toISOString(),
}),
),
insightsCachedLocale: "en",
locale: "en",
timezone: "UTC",
@@ -944,29 +965,31 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
it("serves today's signals with deltas at display precision", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({
dailyBriefing: {
paragraph: "ok",
keyFindings: [],
signalsOfDay: [
{
sourceMetric: "sleep",
tone: "info",
headline: "Shorter night",
nudge: "An earlier night.",
delta: null,
},
{
sourceMetric: "pulse",
tone: "watch",
headline: "Pulse up",
nudge: "Rest.",
delta: "+33.72 bpm vs your 30-day average",
},
],
},
briefingGeneratedAt: new Date().toISOString(),
}),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({
dailyBriefing: {
paragraph: "ok",
keyFindings: [],
signalsOfDay: [
{
sourceMetric: "sleep",
tone: "info",
headline: "Shorter night",
nudge: "An earlier night.",
delta: null,
},
{
sourceMetric: "pulse",
tone: "watch",
headline: "Pulse up",
nudge: "Rest.",
delta: "+33.72 bpm vs your 30-day average",
},
],
},
briefingGeneratedAt: new Date().toISOString(),
}),
),
insightsCachedLocale: "en",
locale: "en",
timezone: "UTC",
@@ -998,7 +1021,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: null,
insightsCachedText: null,
insightsCachedTextEncrypted: null,
locale: "en",
} as never);
@@ -1027,7 +1050,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
const cached = { dailyBriefing: { paragraph: "old", keyFindings: [] } };
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: stale,
insightsCachedText: JSON.stringify(cached),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify(cached),
),
locale: "en",
} as never);
@@ -1054,7 +1079,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
const cachedAt = new Date(Date.now() - 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: cachedAt,
insightsCachedText: JSON.stringify({ dailyBriefing: null }),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({ dailyBriefing: null }),
),
locale: "en",
} as never);
// Marker newer than the last successful generation (written right
@@ -1086,9 +1113,11 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
const cachedAt = new Date(Date.now() - 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: cachedAt,
insightsCachedText: JSON.stringify({
dailyBriefing: { paragraph: "held", keyFindings: [] },
}),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({
dailyBriefing: { paragraph: "held", keyFindings: [] },
}),
),
locale: "en",
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
@@ -1109,9 +1138,11 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
it("reports hasProvider: true on a normal cached read", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({
dailyBriefing: { paragraph: "ok", keyFindings: [] },
}),
insightsCachedTextEncrypted: sealInsightsCachedText(
JSON.stringify({
dailyBriefing: { paragraph: "ok", keyFindings: [] },
}),
),
locale: "en",
} as never);
+1 -1
View File
@@ -153,7 +153,7 @@ export function resolveInsightsRateLimit(): number {
* to mount against the POST handler, which generates inline on a cache miss
* and blocks the page-load path on the full provider chain (up to the
* client's 8 s abort). This read-only GET serves the cached payload from
* `User.insightsCachedText` immediately — NEVER calling the provider — and,
* `User.insightsCachedTextEncrypted` immediately — NEVER calling the provider — and,
* when the cache is stale / missing AND a provider is configured, enqueues
* an out-of-band warm (the same `insight-pregenerate` queue the nightly
* cron and the "prepare assessments" button use). The next read reflects
@@ -9,6 +9,11 @@
* prefilter's read answering nothing, which is the pre-v1.42 path where every
* row went through the reconciler. Statuses, reasons and counters must match
* exactly; only the transaction count may differ.
*
* v1.44 — the golden run is also blind to the set-based insert: its
* `INSERT … ON CONFLICT DO NOTHING` stand-in lands nothing, so every row
* reaches the reconciler one at a time, the sequential path. The shipped run
* takes the set insert, and the two must still leave the same table.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
@@ -29,6 +34,8 @@ const store = vi.hoisted(() => ({
rows: [] as StoredRow[],
/** Golden mode: the prefilter's read answers nothing. */
prefilterBlind: false,
/** Golden mode: the set-based insert lands nothing. */
setInsertBlind: false,
nextId: 1,
}));
@@ -81,12 +88,70 @@ vi.mock("@/lib/db", () => {
},
),
},
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn({})),
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
fn({
measurement: {
// `INSERT … ON CONFLICT DO NOTHING` over both unique identities,
// in VALUES order, returning the rows that landed.
createManyAndReturn: async (args: {
data: Array<{
userId: string;
type: string;
source: string;
externalId: string;
measuredAt: Date;
sleepStage: string | null;
value: number;
}>;
}) => {
if (store.setInsertBlind) return [];
const landed: Array<{
type: string;
source: string;
externalId: string;
}> = [];
for (const d of args.data) {
const scope = store.rows.filter(
(r) =>
r.userId === d.userId &&
r.type === d.type &&
r.source === d.source,
);
const conflict = scope.some(
(r) =>
r.externalId === d.externalId ||
(sameInstant(r.measuredAt, d.measuredAt) &&
r.sleepStage === (d.sleepStage ?? null)),
);
if (conflict) continue;
store.rows.push({
id: `m${store.nextId++}`,
userId: d.userId,
type: d.type,
source: d.source,
externalId: d.externalId,
measuredAt: d.measuredAt,
sleepStage: d.sleepStage ?? null,
value: d.value,
deletedAt: null,
});
landed.push({
type: d.type,
source: d.source,
externalId: d.externalId,
});
}
return landed;
},
},
}),
),
};
return { prisma };
});
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
lockMeasurementUser: async () => undefined,
reconcileExternalMeasurement: async (
_tx: unknown,
desired: {
@@ -283,6 +348,7 @@ async function golden(setup: () => void, entries: Entry[]) {
store.nextId = 1;
setup();
store.prefilterBlind = true;
store.setInsertBlind = true;
vi.mocked(prisma.$transaction).mockClear();
const old = await post(entries);
const oldTransactions = vi.mocked(prisma.$transaction).mock.calls.length;
@@ -292,6 +358,7 @@ async function golden(setup: () => void, entries: Entry[]) {
store.nextId = 1;
setup();
store.prefilterBlind = false;
store.setInsertBlind = false;
vi.mocked(prisma.$transaction).mockClear();
const next = await post(entries);
const transactions = vi.mocked(prisma.$transaction).mock.calls.length;
@@ -304,6 +371,7 @@ beforeEach(() => {
vi.clearAllMocks();
store.rows = [];
store.prefilterBlind = false;
store.setInsertBlind = false;
vi.mocked(getSession).mockResolvedValue({
session: { id: "s", expiresAt: new Date(NOW + HOUR) },
user: { id: USER, username: "u", role: "USER" as const },
@@ -334,7 +402,9 @@ describe("exact-duplicate prefilter (v1.42)", () => {
entries,
);
expect(next.duplicates).toBe(500);
expect(oldTransactions).toBe(500);
// Sequentially: two set-insert statements that land nothing, then one
// reconciler transaction per row.
expect(oldTransactions).toBe(502);
expect(transactions).toBe(0);
});
@@ -366,7 +436,8 @@ describe("exact-duplicate prefilter (v1.42)", () => {
weight("uuid-move", new Date(NOW - DAY)),
]);
expect(next.entries[0].status).toBe("updated");
expect(transactions).toBe(1);
// The set insert skips it on the external identity, the reconciler moves it.
expect(transactions).toBe(2);
});
it("4: the same id with another sleep stage still reconciles", async () => {
@@ -389,7 +460,7 @@ describe("exact-duplicate prefilter (v1.42)", () => {
sleepStage: 3,
};
const { transactions } = await golden(setup, [entry]);
expect(transactions).toBe(1);
expect(transactions).toBe(2);
});
it("5: `stats:` rows are never prefiltered; a second snapshot is superseded", async () => {
@@ -615,3 +686,88 @@ describe("folded_window guard (v1.42)", () => {
expect(data.entries[0].status).toBe("inserted");
});
});
describe("set-based insert (v1.44)", () => {
it("500 fresh samples land in two statements, as the reconciler would have", async () => {
const entries = Array.from({ length: 500 }, (_, i) =>
pulse(`fresh-${i}`, new Date(NOW - DAY - i * 60_000), 60 + (i % 30)),
);
const { next, oldTransactions, transactions } = await golden(
() => undefined,
entries,
);
expect(next.inserted).toBe(500);
expect(oldTransactions).toBe(502);
expect(transactions).toBe(2);
});
it("a mix of fresh, moved and resurrected rows leaves the same table", async () => {
const at = (h: number) => new Date(NOW - DAY - h * HOUR);
const setup = () => {
// Moves: the stored row sits at another instant.
seed({ type: "WEIGHT", externalId: "moved", measuredAt: at(9) });
// Resurrects: a tombstone at a different instant under the same id.
seed({
type: "WEIGHT",
externalId: "back",
measuredAt: at(8),
deletedAt: new Date(NOW - HOUR),
});
// Natural-identity collision under another id: reconciled, not inserted.
seed({ type: "WEIGHT", externalId: "other", measuredAt: at(3) });
};
const { next, transactions } = await golden(setup, [
weight("fresh-a", at(1)),
weight("moved", at(2)),
weight("collides", at(3)),
weight("fresh-b", at(4)),
weight("back", at(5)),
weight("fresh-c", at(6)),
]);
expect(next.entries.map((e) => e.status)).toEqual([
"inserted",
"updated",
"updated",
"inserted",
"updated",
"inserted",
]);
// One set statement, then the three rows it skipped.
expect(transactions).toBe(4);
});
it("a row sharing an identity in the batch flushes the buffer first", async () => {
const at = new Date(NOW - DAY);
const { next } = await golden(
() => undefined,
[
weight("solo", new Date(at.getTime() - HOUR)),
weight("twice", at),
weight("twice", at),
],
);
expect(next.entries.map((e) => e.status)).toEqual([
"inserted",
"inserted",
"duplicate",
]);
});
it("keeps the order when a later reconcile would claim a buffered row's slot", async () => {
// Stored X at t1. C moves X to t5; D and E share t1, so neither is
// buffered. Sequentially C frees t1 before D arrives: D inserts, E
// merges into D. Reconciling D before C would instead take X's row for
// D and leave C to insert, a different table with the same rows.
const t1 = new Date(NOW - DAY - HOUR);
const t5 = new Date(NOW - DAY - 5 * HOUR);
const { next } = await golden(
() => seed({ type: "WEIGHT", externalId: "X", measuredAt: t1 }),
[weight("X", t5), weight("Y", t1), weight("Z", t1)],
);
expect(next.entries.map((e) => e.status)).toEqual([
"updated",
"inserted",
"updated",
]);
});
});
+214 -89
View File
@@ -66,7 +66,11 @@ import {
oppositeMergeSource,
type MergeCandidate,
} from "@/lib/measurements/cross-source-merge";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import {
lockMeasurementUser,
reconcileExternalMeasurement,
type MeasurementReconciliationVerdict,
} from "@/lib/measurements/reconcile-external-measurement";
import { classifyRangeRejection } from "@/lib/measurements/plausibility-gate";
import {
FOLDED_WINDOW_REASON,
@@ -134,6 +138,12 @@ const MAX_BATCH_ENTRIES = 500;
// The cap is a DoS ceiling, not a tight bound.
const MAX_BODY_BYTES = 4 * 1024 * 1024;
/**
* Rows per set-based insert statement. 500 entries fit two statements; each
* row binds about fifteen parameters, far under PostgreSQL's 65 535.
*/
const SET_INSERT_CHUNK = 250;
// v1.4.25 (security H-2): cap batch ingest at 60
// batches per user per minute. Healthy iOS sync drains its
// HealthKit observer queue in well under one batch per minute (the
@@ -635,6 +645,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
let updatedCount = 0;
let duplicateCount = 0;
let failedCount = 0;
// v1.44 — subset of `insertedCount` written by the set-based insert.
let setInsertedCount = 0;
// v1.11.4 (iOS #2) — subset of `duplicateCount` collapsed by the
// MANUAL↔APPLE_HEALTH same-reading merge (as opposed to a plain
// composite-key duplicate). Surfaced as a dedicated wide-event count so
@@ -855,6 +867,188 @@ async function postBatch(request: NextRequest): Promise<Response> {
[...foldedIndexes].map((index) => prepared[index]),
);
/** The reconciler's input for one prepared row. */
const desiredFor = (p: Prepared) => {
const statsRow = isStatsExternalId(p.row.externalId as string);
return {
userId: user.id,
type: p.row.type as MeasurementType,
value: p.row.value as number,
valueMin: p.row.valueMin as number | null,
valueMax: p.row.valueMax as number | null,
unit: p.row.unit as string,
source: p.row
.source as Prisma.MeasurementUncheckedCreateInput["source"],
measuredAt: p.row.measuredAt as Date,
externalId: p.row.externalId as string,
externalSourceVersion: p.row.externalSourceVersion as string | null,
glucoseContext: p.row
.glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"],
sleepStage: p.row
.sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"],
rhythmClassification: p.row
.rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"],
deviceType: p.row.deviceType as string | null,
aggregationProvenance: statsRow
? ("HEALTHKIT_STATISTICS" as const)
: null,
};
};
const pushMergeCandidate = (p: Prepared) => {
if (isMergeableSource(p.row.source as string)) {
inBatchCandidates.push({
type: p.row.type as MeasurementType,
source: p.row.source as string,
value: p.row.value as number,
measuredAt: p.row.measuredAt as Date,
});
}
};
/**
* Record one row's verdict. `candidate` is false for a row whose merge
* candidate was already pushed when it was buffered (see below).
*/
const applyVerdict = (
p: Prepared,
verdict: Pick<MeasurementReconciliationVerdict, "status"> &
Partial<{
dirtyIdentities: Array<{ type: MeasurementType; measuredAt: Date }>;
error: { code?: string };
}>,
candidate: boolean,
) => {
switch (verdict.status) {
case "inserted":
results[p.index] = { index: p.index, status: "inserted" };
insertedCount++;
insertedPrepared.push(p);
writtenIdentities.push({
type: p.row.type as MeasurementType,
measuredAt: p.row.measuredAt as Date,
});
break;
case "updated":
case "resurrected":
results[p.index] = { index: p.index, status: "updated" };
updatedCount++;
writtenIdentities.push(
{
type: p.row.type as MeasurementType,
measuredAt: p.row.measuredAt as Date,
},
...(verdict.dirtyIdentities ?? []),
);
break;
case "duplicate":
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount++;
break;
case "failed":
results[p.index] = {
index: p.index,
status: "failed",
reason: verdict.error?.code ?? "write_failed",
};
failedCount++;
return;
case "rejected_range":
// The reconciler carries the same plausibility gate this route
// already applied above, so reaching here means the two disagreed —
// report it exactly as the earlier guard would have, and let the
// wide-event tally record which end refused.
results[p.index] = outOfRangeResult(
p.index,
p.row.type as string,
p.row.value as number,
);
return;
}
if (candidate) pushMergeCandidate(p);
};
const reconcileRow = (p: Prepared) =>
prisma.$transaction((tx) =>
reconcileExternalMeasurement(tx, desiredFor(p), {
exactExternalMatch: isStatsExternalId(p.row.externalId as string)
? "update"
: "duplicate",
}),
);
// v1.44 — set-based inserts. A fresh sample used to cost one interactive
// transaction of about ten round trips (savepoint, three advisory locks,
// two candidate reads, the insert), so a 500-entry batch of new samples
// was 500 of those in a row: the 2–15 s outliers.
//
// The reconciler inserts exactly when no row, live or tombstoned, holds
// the entry's external identity `(type, source, externalId)` or its
// natural identity `(type, measuredAt, source, sleepStage)`. Both are
// full unique indexes (the natural one NULLS NOT DISTINCT), so
// `INSERT … ON CONFLICT DO NOTHING` inserts exactly those rows and skips
// every other; a skipped row then goes through the reconciler as before
// and gets its own verdict (update, resurrect, duplicate). The insert
// runs under the same per-account advisory lock the reconciler takes, so
// the two serialise like two reconcilers would.
//
// Only a row whose external and natural key each occur once in the batch
// is buffered, and the buffer is flushed before any other row reaches the
// reconciler. Such a row shares no identity with another entry, so no
// other entry's reconcile can create, move or retire a row at its
// identity, and inserting it out of turn changes nothing. Rows the insert
// skips are reconciled in their original order. A buffered row's merge
// candidate is pushed when it is buffered, as the sequential loop pushed
// it after a successful write; the only case that differs is a write that
// fails outright, where the candidate stays.
const pendingInserts: Prepared[] = [];
const flushPendingInserts = async () => {
if (pendingInserts.length === 0) return;
const batch = pendingInserts.splice(0, pendingInserts.length);
const landed = new Set<Prepared>();
try {
for (let i = 0; i < batch.length; i += SET_INSERT_CHUNK) {
const slice = batch.slice(i, i + SET_INSERT_CHUNK);
const rows = await prisma.$transaction(async (tx) => {
await lockMeasurementUser(tx, user.id);
return tx.measurement.createManyAndReturn({
data: slice.map((p) => ({
...desiredFor(p),
sleepStage: (p.row.sleepStage ??
null) as Prisma.MeasurementUncheckedCreateInput["sleepStage"],
deletedAt: null,
})),
skipDuplicates: true,
select: { type: true, source: true, externalId: true },
});
});
const keys = new Set(
rows.map((r) => `${r.type}::${r.source}::${r.externalId}`),
);
for (const p of slice) {
if (keys.has(`${p.row.type}::${p.row.source}::${p.row.externalId}`))
landed.add(p);
}
}
} catch (err) {
// Whatever did not land goes through the reconciler below, which
// turns a real database fault into a per-entry `failed` verdict.
annotate({
action: { name: "measurement.batch.set_insert_fallback" },
meta: {
error_class: err instanceof Error ? err.name : "unknown",
landed: landed.size,
pending: batch.length,
},
});
}
setInsertedCount += landed.size;
for (const p of batch) {
if (landed.has(p)) applyVerdict(p, { status: "inserted" }, false);
else applyVerdict(p, await reconcileRow(p), false);
}
};
for (const p of prepared) {
const externalId = p.row.externalId as string;
const statsRow = isStatsExternalId(externalId);
@@ -895,101 +1089,29 @@ async function postBatch(request: NextRequest): Promise<Response> {
: { index: p.index, status: "duplicate" };
duplicateCount++;
if (folded) foldedWindowCount++;
if (isMergeableSource(p.row.source as string)) {
inBatchCandidates.push({
type: p.row.type as MeasurementType,
source: p.row.source as string,
value: p.row.value as number,
measuredAt: p.row.measuredAt as Date,
});
}
pushMergeCandidate(p);
continue;
}
const verdict = await prisma.$transaction((tx) =>
reconcileExternalMeasurement(
tx,
{
userId: user.id,
type: p.row.type as MeasurementType,
value: p.row.value as number,
valueMin: p.row.valueMin as number | null,
valueMax: p.row.valueMax as number | null,
unit: p.row.unit as string,
source: p.row
.source as Prisma.MeasurementUncheckedCreateInput["source"],
measuredAt: p.row.measuredAt as Date,
externalId,
externalSourceVersion: p.row.externalSourceVersion as string | null,
glucoseContext: p.row
.glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"],
sleepStage: p.row
.sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"],
rhythmClassification: p.row
.rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"],
deviceType: p.row.deviceType as string | null,
aggregationProvenance: statsRow ? "HEALTHKIT_STATISTICS" : null,
},
{ exactExternalMatch: statsRow ? "update" : "duplicate" },
),
const naturalKey = naturalKeyOf(
p.row.type,
p.row.source,
p.row.measuredAt as Date,
p.row.sleepStage,
);
switch (verdict.status) {
case "inserted":
results[p.index] = { index: p.index, status: "inserted" };
insertedCount++;
insertedPrepared.push(p);
writtenIdentities.push({
type: p.row.type as MeasurementType,
measuredAt: p.row.measuredAt as Date,
});
break;
case "updated":
case "resurrected":
results[p.index] = { index: p.index, status: "updated" };
updatedCount++;
writtenIdentities.push(
{
type: p.row.type as MeasurementType,
measuredAt: p.row.measuredAt as Date,
},
...(verdict.dirtyIdentities ?? []),
);
break;
case "duplicate":
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount++;
break;
case "failed":
results[p.index] = {
index: p.index,
status: "failed",
reason: verdict.error.code ?? "write_failed",
};
failedCount++;
continue;
case "rejected_range":
// The reconciler carries the same plausibility gate this route
// already applied above, so reaching here means the two disagreed —
// report it exactly as the earlier guard would have, and let the
// wide-event tally record which end refused.
results[p.index] = outOfRangeResult(
p.index,
p.row.type as string,
p.row.value as number,
);
continue;
if (
externalKeyCounts.get(key) === 1 &&
naturalKeyCounts.get(naturalKey) === 1
) {
pendingInserts.push(p);
pushMergeCandidate(p);
continue;
}
if (isMergeableSource(p.row.source as string)) {
inBatchCandidates.push({
type: p.row.type as MeasurementType,
source: p.row.source as string,
value: p.row.value as number,
measuredAt: p.row.measuredAt as Date,
});
}
await flushPendingInserts();
applyVerdict(p, await reconcileRow(p), true);
}
await flushPendingInserts();
}
// v1.43 — the hours, after the measurements. One write per slot: the last
@@ -1342,6 +1464,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
// v1.42 — duplicates refused because a live `stats:` row already
// covers the sample's hour or day (`folded-window.ts`).
folded_window: foldedWindowCount,
// v1.44 — inserts that took the set-based path rather than one
// reconciler transaction each.
set_inserted: setInsertedCount,
},
});
@@ -203,8 +203,11 @@ beforeEach(() => {
// v1.4.43 — default to no per-user source-priority override so
// the write-time picker walks the canonical default ladder. Tests
// that exercise a custom ladder override this in-line.
// The route opt-in is on here so the route cases below exercise the
// write; the off case sets it explicitly.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
storeWorkoutRoutes: true,
} as never);
});
@@ -507,6 +510,55 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => {
});
});
describe("POST /api/workouts/batch — workout-route opt-in", () => {
const withRoute = () =>
validWorkout("uuid-with-route", {
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
],
},
},
});
it("keeps the workout and drops the track while the opt-in is off", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
storeWorkoutRoutes: false,
} as never);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
mockInsertReturning([
{ id: "wkt-off", source: "APPLE_HEALTH", externalId: "uuid-with-route" },
]);
const res = await POST(makeRequest({ workouts: [withRoute()] }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { entries: Array<{ status: string }> };
};
// Same per-entry status as a stored track: the client needs no branch.
expect(body.data.entries[0]?.status).toBe("inserted");
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
});
it("drops the track when the account row carries no opt-in at all", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
mockInsertReturning([
{ id: "wkt-none", source: "APPLE_HEALTH", externalId: "uuid-with-route" },
]);
const res = await POST(makeRequest({ workouts: [withRoute()] }));
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
});
});
describe("POST /api/workouts/batch — nested route attachment", () => {
it("creates a WorkoutRoute row for a workout that ships a route", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
@@ -700,7 +752,7 @@ describe("POST /api/workouts/batch — user source-priority (v1.4.43 W9)", () =>
expect(sourcePriorityReads).toHaveLength(1);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { sourcePriorityJson: true },
select: { sourcePriorityJson: true, storeWorkoutRoutes: true },
});
});
+21 -8
View File
@@ -66,6 +66,7 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
import { routeClimbM } from "@/lib/workouts/route-climb";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -259,10 +260,15 @@ async function postBatch(request: NextRequest): Promise<Response> {
// time before the read-time picker ever ran. One indexed
// findUnique per batch — well under the cost of letting the
// duplicate persist and carrying it forward on every read.
// The same read carries the route opt-in: a GPS track is kept only when
// the person turned `storeWorkoutRoutes` on. While it is off the workout
// is stored and the track dropped, with the same per-entry status, so a
// client cannot tell the two apart and needs no new branch.
const userPriority = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
select: { sourcePriorityJson: true, storeWorkoutRoutes: true },
});
const keepRoutes = userPriority?.storeWorkoutRoutes === true;
// Content-Length pre-flight. The body cap exists to bound the
// request-pipeline cost BEFORE Node has to read the full payload —
@@ -440,12 +446,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
0,
Math.round((w.endedAt.getTime() - w.startedAt.getTime()) / 1000),
);
const route = w.route
? {
geometry: w.route.geometry as unknown,
sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown,
}
: null;
const route =
w.route && keepRoutes
? {
geometry: w.route.geometry as unknown,
sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown,
}
: null;
const samples = w.samples
? {
samples: w.samples as unknown,
@@ -464,7 +471,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
maxHeartRate: w.maxHeartRate ?? null,
minHeartRate: w.minHeartRate ?? null,
stepCount: w.stepCount ?? null,
elevationM: w.elevationM ?? null,
// A kept track fills the climb when the source sent none, so the figure
// lives on the workout row and no reader has to open the track for it.
elevationM: w.elevationM ?? (route ? routeClimbM(route.geometry) : null),
pauseDurationSec: w.pauseDurationSec ?? null,
// `EXTERNAL` under a scoped credential, and nothing else: the guard
// above refused any body that named a source, so nothing asserted is
@@ -908,6 +917,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
enriched: enrichedCount,
updated: updatedCount,
skipped: skipped.length,
// Tracks the body carried but the opt-in told us not to keep.
routes_dropped: keepRoutes
? 0
: workouts.filter((w) => w.route != null).length,
},
});
+1 -1
View File
@@ -135,7 +135,7 @@
/* The one sanctioned step below `text-xs` (12px). Minted so badges,
tag chips, and tabular-nums micro-labels stop hand-rolling
`text-[0.6875rem]` / `text-[0.65rem]` arbitrary values that drift by a
third of a pixel per site. UI-STANDARDS §5 pins the usage: never for
third of a pixel per site. ui-standards.md §5 pins the usage: never for
muted body or meta text (the muted floor stays `text-xs`) — only for
chips, badges, and tabular micro-labels with verified contrast. */
--text-2xs: 0.6875rem;
+1 -1
View File
@@ -98,7 +98,7 @@ export default function InsightsWorkoutDetailPage({
<Skeleton className="h-60 w-full rounded-lg" />
</div>
) : error ? (
// A failed query must never read as "no data" (UI-STANDARDS §6).
// A failed query must never read as "no data" (ui-standards.md §6).
<QueryErrorCard error={error} onRetry={() => refetch()} />
) : !data ? (
<p
+13 -2
View File
@@ -61,8 +61,19 @@ export interface SystemStatus {
buildTime: string;
startTime: string;
database: string;
/** The process's heap ceiling; `low` when it is below what a long-used account needs. */
memory: { heapLimitMb: number; low: boolean };
/**
* The process's heap ceiling; `low` when it is below what a long-used
* account needs. `containerLimitMb` is the container's memory limit
* (`null` when there is none or it cannot be read), `belowFloor` when it is
* under the supported floor of `floorMb`.
*/
memory: {
heapLimitMb: number;
low: boolean;
containerLimitMb: number | null;
floorMb: number;
belowFloor: boolean;
};
worker: WorkerStatus;
/**
* Queues whose jobs exhausted their retries recently. `null` means the
+15 -4
View File
@@ -164,11 +164,22 @@ export function SystemStatusSection() {
icon={MemoryStick}
label={t("admin.heapLimitLabel")}
value={
status.memory.low
? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb })
: t("admin.heapLimitValue", { mb: status.memory.heapLimitMb })
status.memory.belowFloor && status.memory.containerLimitMb
? t("admin.memoryBelowFloor", {
mb: status.memory.containerLimitMb,
floor: status.memory.floorMb,
})
: status.memory.low
? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb })
: t("admin.heapLimitValue", {
mb: status.memory.heapLimitMb,
})
}
tone={
status.memory.belowFloor || status.memory.low
? "warning"
: undefined
}
tone={status.memory.low ? "warning" : undefined}
/>
<StatusItem
icon={Clock}
@@ -29,7 +29,7 @@ import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
* v1.29 — 30-day daily bar chart for the nutrients hydration / caffeine
* cards. One bar per day (dense series — 0 for an unlogged day, summed
* across sources), an optional thin dashed EFSA reference line, no
* attainment colouring (UI-STANDARDS: value stays foreground, the bar +
* attainment colouring (ui-standards.md: value stays foreground, the bar +
* reference line ARE the context). Registered in
* `src/components/charts/chart-runtime.ts` — never import recharts
* directly at the call site.
+1 -1
View File
@@ -539,7 +539,7 @@ export function TodayHero({
</div>
)}
{/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs
{/* Meta row — the hero's quiet tier (ui-standards.md §3: `text-xs
text-muted-foreground` is the meta floor; never an accent, never an
opacity modifier). Freshness note (plan §2.4) — provisional day,
last night's sleep not yet folded in. Muted, non-blocking,
+1 -1
View File
@@ -73,7 +73,7 @@ import { useDayValueFormat } from "./use-day-value-format";
* to look from, and the entry is where it is edited.
*/
/** Section label, UI-STANDARDS §5. */
/** Section label, ui-standards.md §5. */
export const DAY_SECTION_LABEL =
"text-muted-foreground text-xs font-medium tracking-wide uppercase";
@@ -199,7 +199,7 @@ describe("<HeroStrip>", () => {
/data-slot="insights-hero-strip-greeting"[^>]*class="([^"]*)"/,
);
expect(greeting).not.toBeNull();
// App H1 weight is bold (UI-STANDARDS §5) — never font-semibold.
// App H1 weight is bold (ui-standards.md §5) — never font-semibold.
expect(greeting![1]).toContain("font-bold");
expect(greeting![1]).not.toContain("font-semibold");
// No off-scale arbitrary type sizes (§5) — round to the neighbouring step.
@@ -65,7 +65,7 @@ describe("<SleepStageStackedBar>", () => {
expect(html).toContain("Stage composition");
expect(html).toContain("Last 23 nights");
// The title routes through the canonical tile header rather than a
// hand-rolled title row (UI-STANDARDS §1 / §8).
// hand-rolled title row (ui-standards.md §1 / §8).
expect(html).toContain('data-slot="tile-header"');
});
@@ -76,7 +76,7 @@ describe("Coach unverified-figures notice", () => {
/>,
);
expect(html).toContain('data-slot="coach-unverified-notice"');
// Muted meta treatment (UI-STANDARDS §3) — never a warning colour.
// Muted meta treatment (ui-standards.md §3) — never a warning colour.
expect(html).toContain("text-muted-foreground");
});
@@ -520,7 +520,7 @@ function ChatBubbleImpl({
// #781 — the persisted cancelled-turn marker (an EMPTY assistant row the
// server writes when the client aborts mid-generation, e.g. navigating
// away). Rendered as a quiet interrupted note — muted meta text per
// UI-STANDARDS §3, never an alarm colour: nothing failed, the user left —
// ui-standards.md §3, never an alarm colour: nothing failed, the user left —
// plus a retry that resubmits the question that produced the turn (the
// regenerate closure, so the resend runs through the normal send path and
// every budget/rate gate). No provenance, feedback, copy, or token footer:
@@ -700,7 +700,7 @@ function ChatBubbleImpl({
<p className="text-warning text-xs">{safeError}</p>
)}
{/* v1.32.14 — quiet grounding caveat when the guard withheld a figure.
Muted meta (UI-STANDARDS §3), never a warning colour — it is a
Muted meta (ui-standards.md §3), never a warning colour — it is a
grounding caveat, not an error. Static single line, decorative icon,
no interactivity. The `[…]` marks in the prose above point here. */}
{showUnverifiedNotice && (
+1 -1
View File
@@ -52,7 +52,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
});
if (isError) {
// A failed read must never read as "no data" (UI-STANDARDS §6), and the
// A failed read must never read as "no data" (ui-standards.md §6), and the
// alert is content, not muted meta — it also has to be recoverable.
return (
<QueryErrorCard
+1 -1
View File
@@ -164,7 +164,7 @@ export function HeroStrip({
>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex items-center gap-2">
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
{/* Header glyph stays in the foreground tier — ui-standards.md §1
reserves primary/accent for actions, not decorative header
icons. */}
<Sparkles
@@ -24,7 +24,7 @@ import { cn } from "@/lib/utils";
*
* RSC-safe: no hooks, no browser API.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1
* @see docs/contributing/ui-standards.md §1
*/
export function InsightSectionCard({
children,
@@ -71,7 +71,7 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
* line discloses the coverage so the shape doesn't read as more continuous
* than it is.
*
* v1.29.x — a day navigator (`TileHeader`'s `right` slot, per UI-STANDARDS
* v1.29.x — a day navigator (`TileHeader`'s `right` slot, per ui-standards.md
* §11) pages the same route backward through prior days; "next" is disabled
* at today, so the view can never step into the future. A day outside the
* dense retention window (`DENSE_INTRADAY_RETENTION_DAYS`) reads back at the
@@ -99,7 +99,7 @@ export function LabsChangesCard({
</div>
{/* The delta is CONTENT — the number the row exists to report —
so it takes the analyte's tier, not the meta tier of the
line under it (UI-STANDARDS §3). At `text-xs` muted it wore
line under it (ui-standards.md §3). At `text-xs` muted it wore
the sub-line's typography and optically attached itself to
that lower line, which is what made a row centred to the
pixel read as bottom-aligned. `self-center` states the row's
@@ -8,7 +8,7 @@
* strictly association-framed, never a verdict. Each row links through to the
* medication's Wirkung tab for the full view.
*
* Card anatomy per UI-STANDARDS §8: TileHeader + the inherited `px-4 md:px-6`
* Card anatomy per ui-standards.md §8: TileHeader + the inherited `px-4 md:px-6`
* inset (never re-declared), foreground for content, muted for meta.
*/
import Link from "next/link";
@@ -22,7 +22,7 @@ const WINDOW_DAYS = 30;
* reference-intake meta line (omitted when the profile has no sex on
* file — the catalog's own contract), a 30-day bar chart with a dashed
* reference line. No ring, no attainment colour — the bar chart with a
* reference line IS the context (UI-STANDARDS: value stays foreground,
* reference line IS the context (ui-standards.md: value stays foreground,
* never coloured by attainment). Display only: water arrives by sync
* (Apple Health etc.), there is no in-app water entry here.
*/
@@ -26,7 +26,7 @@ import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marke
*
* Why a POST under a `useQuery` rather than a dedicated GET endpoint:
* the route already cache-returns the most recent generation (24h TTL on
* `User.insightsCachedAt` / `User.insightsCachedText`) without burning a
* `User.insightsCachedAt` / `User.insightsCachedTextEncrypted`) without burning a
* rate-limit token, so a single POST-without-`force` is functionally a
* GET-or-generate. Adding a separate GET would duplicate the cache-read
* branch and split the audit-log surface; the reconcile report deferred
@@ -540,7 +540,7 @@ export function useInsightsAdvisorQuery(
// re-fetches.
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
// v1.18.10 / v1.32.19 — the dashboard hero surfaces the briefing
// headline from the SNAPSHOT (server-lifted `User.insightsCachedText`),
// headline from the SNAPSHOT (server-lifted `User.insightsCachedTextEncrypted`),
// and the Today hero's lead line is the SAME briefing lifted into the
// digest — neither is this advisor cache. A regenerate rewrites that
// server cache, so both the snapshot and the digest must refetch.
+1 -1
View File
@@ -489,7 +489,7 @@ export function AuthShell({
// `overflow-y-auto` never clips them: their static position sits
// wherever the flow put them and extends the DOCUMENT's
// scrollable overflow instead, painting a second vertical scroll
// surface beside `<main>` (UI-STANDARDS §9 one-scroll-floor).
// surface beside `<main>` (ui-standards.md §9 one-scroll-floor).
// The class is not hypothetical and not confined to app code. A
// Radix `<Switch>` outside a `<form>` renders a hidden
// `position:absolute` bubble input on the first pass and drops it
+1 -1
View File
@@ -274,7 +274,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
*
* Settings and Notifications are account utilities, not feature destinations:
* on desktop they live in the sidebar footer + avatar menu, on mobile in the
* top-bar user menu (UI-STANDARDS §10; the More hub carries features only).
* top-bar user menu (ui-standards.md §10; the More hub carries features only).
* They used to be a second hand-curated list on each bar — the exact drift the
* one-model contract above set out to kill, just pushed down a level. This
* list is the single source both bars consume, so the two surfaces can no
@@ -33,7 +33,7 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
* dark for the muted token at this text size, while the foreground token keeps
* the context legible at the same contrast floor as the name.
*
* The row is the inline-action shape from UI-STANDARDS §11 rather than a
* The row is the inline-action shape from ui-standards.md §11 rather than a
* centred wrapping stack: sentence `min-w-0 flex-1`, action `shrink-0`, so the
* way out stays beside the sentence instead of dropping onto its own line once
* the name is long. `<MaintainershipBanner>` is the sibling that already had an
@@ -132,6 +132,16 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
PAIN_NRS: "measurements.typePainNrs",
WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
WAIST_TO_HEIGHT: "measurements.typeWaistToHeight",
// ── v1.44 — activity rings, mindfulness, State of Mind, AFib burden ──
EXERCISE_MINUTES: "measurements.typeExerciseMinutes",
STAND_MINUTES: "measurements.typeStandMinutes",
STAND_HOURS: "measurements.typeStandHours",
MINDFUL_MINUTES: "measurements.typeMindfulMinutes",
STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence",
AFIB_BURDEN: "measurements.typeAfibBurden",
INDOOR_CO2: "measurements.typeIndoorCo2",
INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature",
INDOOR_HUMIDITY: "measurements.typeIndoorHumidity",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -267,6 +277,18 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
PAIN_NRS: Activity,
WAIST_CIRCUMFERENCE: Scale,
WAIST_TO_HEIGHT: Scale,
// ── v1.44 — the rings take the activity glyphs, mindful minutes the
// breath, the valence the score dial, AFib burden the rhythm trace.
EXERCISE_MINUTES: Dumbbell,
STAND_MINUTES: PersonStanding,
STAND_HOURS: PersonStanding,
MINDFUL_MINUTES: Wind,
STATE_OF_MIND_VALENCE: Gauge,
AFIB_BURDEN: HeartPulse,
// Room climate: air, warmth, moisture.
INDOOR_CO2: Wind,
INDOOR_TEMPERATURE: Thermometer,
INDOOR_HUMIDITY: Droplets,
};
// The colour map lives in its own icon-free module so a surface that only
@@ -129,4 +129,17 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
PAIN_NRS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
WAIST_CIRCUMFERENCE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
WAIST_TO_HEIGHT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
// ── v1.44 — the rings and mindful minutes join the activity family
// (chart-2), the valence the score family (chart-1), AFib burden the
// cardio family (chart-3).
EXERCISE_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
STAND_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
STAND_HOURS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
MINDFUL_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
STATE_OF_MIND_VALENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
AFIB_BURDEN: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
// Room climate takes the environment family of daylight (chart-2).
INDOOR_CO2: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
INDOOR_TEMPERATURE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
INDOOR_HUMIDITY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
};
@@ -151,7 +151,7 @@ export function MedicationOrderEditor({
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-4" data-slot="medication-order-editor">
{medications.length === 0 && (
<EmptyState
@@ -110,7 +110,7 @@ export function ModuleOrderEditor({ items, onChange }: ModuleOrderEditorProps) {
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-2" data-slot="module-order-editor">
<DndContext
sensors={sensors}
@@ -224,7 +224,7 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-3" data-slot="mood-tag-groups-card">
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground min-w-0 text-xs">
@@ -296,7 +296,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-4" data-slot="mood-tag-manager-card">
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground min-w-0 text-xs">
+1 -1
View File
@@ -13,7 +13,7 @@ import { useEffect, useRef, type ReactNode } from "react";
* put it — the heading is at the top of the column anyway.
*
* Semibold rather than the app-wide bold PageHeader H1: the onboarding hero
* is the documented exception (UI-STANDARDS §5). Do not sweep to font-bold.
* is the documented exception (ui-standards.md §5). Do not sweep to font-bold.
*/
export function StepHeading({
id,
@@ -194,11 +194,11 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "medications")).toBe("0/4");
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
expect(groupChip(html, "history")).toBe("0/5");
expect(groupChip(html, "cardio")).toBe("0/14");
expect(groupChip(html, "activity")).toBe("0/8");
expect(groupChip(html, "cardio")).toBe("0/15");
expect(groupChip(html, "activity")).toBe("0/11");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
expect(groupChip(html, "mobility")).toBe("0/11");
expect(groupChip(html, "environment")).toBe("0/4");
expect(groupChip(html, "environment")).toBe("0/7");
});
it("disables Generate while the scope is empty, and says what to do", () => {
@@ -404,7 +404,7 @@ describe("how a field says what is wrong with it", () => {
// Announced, because it MOUNTS with the role rather than a class being
// swapped onto a node that was already there.
expect(alert).toContain('role="alert"');
// Content, not meta. UI-STANDARDS §3, and the same reasoning
// Content, not meta. ui-standards.md §3, and the same reasoning
// `grant-action-error.tsx` spells out: a refusal rendered as muted is a
// refusal nobody reads.
expect(alert).toContain("text-destructive");
@@ -521,7 +521,7 @@ describe("ending a managed profile", () => {
authRef.value = { accounts: [entry()], active: null };
const html = renderGatedSection();
expect(html).toContain('data-slot="managed-profile-delete"');
// The mobile tap floor UI-STANDARDS §11 asks of an inline action.
// The mobile tap floor ui-standards.md §11 asks of an inline action.
expect(html).toMatch(
/data-slot="managed-profile-delete"[^>]*class="[^"]*min-h-11/,
);
@@ -629,7 +629,7 @@ describe("ending a managed profile", () => {
const html = renderGatedSection();
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain("Could not load who looks after this record");
// And the way back is reachable, which is what UI-STANDARDS §6 asks of an
// And the way back is reachable, which is what ui-standards.md §6 asks of an
// error state.
expect(html).toContain('data-slot="query-error-retry"');
});
@@ -172,7 +172,7 @@ export function grantActionErrorKey(err: unknown, action: GrantAction): string {
* a person who pressed a button and got nothing has already looked away, and
* a screen reader that does not announce it is the same silence the panel had
* before. `text-destructive` because this is content somebody has to act on —
* UI-STANDARDS §3 reserves muted for meta, and a refusal rendered as meta is a
* ui-standards.md §3 reserves muted for meta, and a refusal rendered as meta is a
* refusal nobody reads.
*
* `data-grant-error-for` carries the row id so a panel holding two failed rows
@@ -466,7 +466,7 @@ export function resolveInviteScope({
* a button inside a label forwards its own tap to the label's control, so the
* radio and the popover would fight for the same click (the same reason
* `SectionOption` avoids the wrapping label). Muted, `text-xs` — the meta floor
* per UI-STANDARDS §3.
* per ui-standards.md §3.
*/
function AccessOption({
level,
+1 -1
View File
@@ -54,7 +54,7 @@ export function GrantRowItem({
/**
* Something the person has to read before they act on this row, rendered in
* the content column rather than beside the button: consent copy is content
* (UI-STANDARDS §3), and a sentence squeezed into the action column would
* (ui-standards.md §3), and a sentence squeezed into the action column would
* wrap into a stripe nobody reads.
*/
notice?: ReactNode;
@@ -92,7 +92,7 @@ export function ManagedProfileCard() {
/>
<div className="space-y-4">
{/* Content, not meta: this is what somebody is consenting to take on,
and UI-STANDARDS §3 reserves muted for the incidental. */}
and ui-standards.md §3 reserves muted for the incidental. */}
<p data-slot="managed-profile-explainer" className="text-sm">
{t("recordSharing.managed.explainer")}
</p>
@@ -159,7 +159,7 @@ function ManagedProfileRow({
data-managed-profile-id={profile.accountId}
className="space-y-2 py-3 first:pt-0 last:pb-0"
>
{/* UI-STANDARDS §11 inline action row. */}
{/* ui-standards.md §11 inline action row. */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{/* A person's name is user data and stays content. */}
@@ -168,7 +168,7 @@ function ManagedProfileRow({
{t("recordSharing.lookingAfter.kindManaged")}
</p>
</div>
{/* UI-STANDARDS §11: the actions stay beside the name, never below it.
{/* ui-standards.md §11: the actions stay beside the name, never below it.
Edit is offered without waiting for the roster — it changes what the
record IS and does not depend on how many people look after it,
unlike the deletion beside it, whose confirm copy states that
@@ -223,7 +223,7 @@ function ManagedProfileRow({
{/* A failed roster read is not an absent roster.
Without this branch a 500 renders exactly as a still-loading one —
the row with no guardian panel and no delete control — and the person
waits for something that is never coming. UI-STANDARDS §6: a read
waits for something that is never coming. ui-standards.md §6: a read
that failed says so and offers the way back. It is deliberately NOT
the same as the 404 arm: a 404 means this account is no longer a
Guardian of that profile, which is an answer, and the row correctly
@@ -321,7 +321,7 @@ export function ManagedProfileCreateForm({
* Two nodes, never one that swaps. The form used to render a single muted
* `<p>` whose TEXT changed from hint to error, which is wrong twice over:
*
* * a refusal is content somebody has to act on, and UI-STANDARDS §3
* * a refusal is content somebody has to act on, and ui-standards.md §3
* reserves `text-muted-foreground` for meta. The same reasoning is written
* out at `grant-action-error.tsx` — a refusal rendered as meta is a
* refusal nobody reads;
@@ -292,7 +292,7 @@ function ManagedProfileEditFields({
/>
</div>
{/* Above the action row, per UI-STANDARDS §12: nothing follows it. */}
{/* Above the action row, per ui-standards.md §12: nothing follows it. */}
{error && (
<p
role="alert"
@@ -151,7 +151,7 @@ export function ManagedProfileGuardians({
data-guardian-state={guardian.state}
className="space-y-2 py-3 first:pt-0 last:pb-0"
>
{/* UI-STANDARDS §11 inline action row. */}
{/* ui-standards.md §11 inline action row. */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
@@ -449,7 +449,7 @@ function HandoverLink({
</p>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
{/* The QR quiet zone stays white in both themes: a scanner needs the
contrast, and UI-STANDARDS §4 names this exemption. */}
contrast, and ui-standards.md §4 names this exemption. */}
{/* eslint-disable-next-line @next/next/no-img-element -- a data: URL minted client-side; nothing for the image optimiser to do */}
<img
src={link.qrDataUrl}
@@ -574,7 +574,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
// then sits at its static position (below this long list) in DOCUMENT
// coordinates and silently makes the document itself scrollable, a
// second vertical scrollbar next to the shell-owned `<main>` scroll
// (UI-STANDARDS §9 one-scroll-floor; guarded by
// (ui-standards.md §9 one-scroll-floor; guarded by
// e2e/settings-overscroll.spec.ts).
<div className="relative space-y-2">
{/* v1.4.15 — table-style header naming the two
@@ -32,6 +32,7 @@ import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
import { WorkoutRoutesCard } from "@/components/settings/workout-routes-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry";
@@ -130,6 +131,9 @@ export function PrivacySection() {
</ul>
</SettingsCard>
{/* Workout GPS tracks: kept only on opt-in. */}
<WorkoutRoutesCard />
{/* Retention */}
<SettingsCard>
<SettingsCardHeader
@@ -110,7 +110,7 @@ export function ReportScopePicker({
return (
<div className="space-y-3" data-testid={`report-scope-picker-${surface}`}>
{/* The one-click way to a complete report. An inline action row
(UI-STANDARDS §11): the sentence wraps, the action stays beside it.
(ui-standards.md §11): the sentence wraps, the action stays beside it.
It is offered, never applied — and it stays offered so a person can
come back to the standard set after experimenting. On a phone the
action's own label is wider than half the row, so beside it the
@@ -518,7 +518,7 @@ export function ShareLinkCreateForm({
opens the record (and its shared documents). Promoted as the
primary mobile affordance: a clinician scans it on the spot to
open the record on their own device. Shown exactly once. The
white quiet-zone is the documented QR exemption (UI-STANDARDS §4)
white quiet-zone is the documented QR exemption (ui-standards.md §4)
so the code stays scannable in the dark theme too. */}
{qrDataUrl && (
<div
@@ -0,0 +1,83 @@
"use client";
/**
* The workout-route opt-in on the Data & Privacy page.
*
* Off by default. A GPS track shows where a person lives, works and trains,
* so new tracks from the iPhone app and the Apple Health export are kept
* only once this is on; the workouts themselves are kept either way. No AI
* feature ever reads a track.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { MapPinned } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Switch } from "@/components/ui/switch";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
interface WorkoutRoutesPref {
storeWorkoutRoutes: boolean;
}
export function WorkoutRoutesCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const pref = useQuery<WorkoutRoutesPref>({
queryKey: queryKeys.workoutRoutesPref(),
queryFn: () => apiGet<WorkoutRoutesPref>("/api/auth/me/workout-routes"),
staleTime: 60_000,
});
const update = useMutation<WorkoutRoutesPref, Error, boolean>({
mutationFn: (storeWorkoutRoutes) =>
apiPatch<WorkoutRoutesPref>("/api/auth/me/workout-routes", {
storeWorkoutRoutes,
}),
onSuccess: (data) => {
queryClient.setQueryData(queryKeys.workoutRoutesPref(), data);
},
});
return (
<SettingsCard data-testid="workout-routes-card">
<SettingsCardHeader
anchor="workout-routes"
icon={MapPinned}
title={t("settings.privacy.workoutRoutes.title")}
description={t("settings.privacy.workoutRoutes.description")}
status={
<Switch
checked={pref.data?.storeWorkoutRoutes ?? false}
disabled={pref.isLoading || pref.isError || update.isPending}
onCheckedChange={(checked) =>
update.mutate(checked, {
onSuccess: () =>
toastWrittenOutcome("success", t("settings.saved")),
onError: (failure) =>
showErrorToast(t("settings.savingError"), {
cause: failure,
}),
})
}
aria-label={t("settings.privacy.workoutRoutes.title")}
/>
}
/>
{pref.isError ? (
<QueryErrorRow
inset
error={pref.error}
onRetry={() => void pref.refetch()}
message={t("settings.privacy.workoutRoutes.loadError")}
/>
) : (
<p className="text-sm">{t("settings.privacy.workoutRoutes.detail")}</p>
)}
</SettingsCard>
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
* geometry and pushes everything the user came for below the fold — so this
* shape stays, promoted to a primitive instead of being copied a third time.
*
* Pick by surface (UI-STANDARDS §6):
* Pick by surface (ui-standards.md §6):
* - primary / list surface → `QueryErrorCard`
* - dense metric strip or tile where the tall card would dominate →
* `QueryErrorRow`
+87 -40
View File
@@ -70,57 +70,104 @@ export async function register() {
// A diagnostic must never break boot.
}
const { shouldRunWorker } = await import("@/lib/process-type");
// One line when the container's memory limit is below the supported
// floor. The app keeps running.
try {
const { logMemoryFloorAtBoot } = await import("@/lib/support/heap-limit");
logMemoryFloorAtBoot();
} catch {
// A diagnostic must never break boot.
}
// The background role starts after the server is up. Awaiting it here
// held every request, `/api/health` included, for the seconds pg-boss
// needs to start, provision its queues and run the boot discovery; the
// probe now answers `starting` meanwhile. A request that enqueues while
// the boss is not bound yet takes the same path as during a producer
// reconnect: a garnish skips its enqueue (the owning cron catches up),
// and a route that needs the worker answers 503 until it is up.
const [
{ shouldRunWorker },
{ markStartup, startupPhase, startupElapsedMs, STARTING_MAX_MS },
] = await Promise.all([
import("@/lib/process-type"),
import("@/lib/boot/startup-state"),
]);
markStartup("starting");
// One line when the start takes longer than any real boot; from then on
// the probe reports plain `degraded` instead of `starting`.
setTimeout(() => {
if (startupPhase() === "stalled") {
console.warn(
`[boot] The background role is still not up after ${Math.round(STARTING_MAX_MS / 1000)} s; /api/health now reports degraded without "starting".`,
);
}
}, STARTING_MAX_MS + 1_000).unref();
const ready = (up: boolean) => {
const elapsed = startupElapsedMs();
markStartup(up ? "ready" : "failed");
console.info(
`[boot] background role ${up ? "ready" : "failed"} after ${elapsed ?? 0} ms`,
);
};
if (!shouldRunWorker()) {
// Split deployment: web requests still produce queue jobs, but this
// process owns no schedules, supervision, or consumers.
try {
const { startGlobalBossProducer } =
await import("@/lib/jobs/boss-instance");
const boss = await startGlobalBossProducer(process.env.DATABASE_URL);
if (boss) {
const { registerShutdownHandlers } =
await import("@/lib/jobs/reminder/worker-lifecycle");
registerShutdownHandlers(boss);
void (async () => {
try {
const { startGlobalBossProducer } =
await import("@/lib/jobs/boss-instance");
const boss = await startGlobalBossProducer(process.env.DATABASE_URL);
if (boss) {
const { registerShutdownHandlers } =
await import("@/lib/jobs/reminder/worker-lifecycle");
registerShutdownHandlers(boss);
}
ready(boss !== null);
} catch (err) {
ready(false);
// Queue-backed garnishes degrade independently; never make the web
// server unavailable because its producer connection failed.
console.error(
"[instrumentation] Failed to start pg-boss producer:",
err,
);
}
} catch (err) {
// Queue-backed garnishes degrade independently; never make the web
// server unavailable because its producer connection failed.
console.error(
"[instrumentation] Failed to start pg-boss producer:",
err,
);
}
})();
return;
}
const { WideEventBuilder } = await import("@/lib/logging/event-builder");
const { emitIfSampled } = await import("@/lib/logging/transports");
void (async () => {
const { WideEventBuilder } = await import("@/lib/logging/event-builder");
const { emitIfSampled } = await import("@/lib/logging/transports");
// Emit startup event
const startupEvt = new WideEventBuilder("background");
startupEvt.setBackground({ task_name: "startup" });
// Emit startup event
const startupEvt = new WideEventBuilder("background");
startupEvt.setBackground({ task_name: "startup" });
try {
const { startReminderWorker } =
await import("@/lib/jobs/reminder-worker");
await startReminderWorker();
try {
const { startReminderWorker } =
await import("@/lib/jobs/reminder-worker");
await startReminderWorker();
ready(true);
startupEvt.addMeta("reminder_worker", "started");
startupEvt.finish();
emitIfSampled(startupEvt.toJSON());
} catch (err) {
startupEvt.setError(err);
startupEvt.addMeta("reminder_worker", "failed");
startupEvt.finish();
emitIfSampled(startupEvt.toJSON());
startupEvt.addMeta("reminder_worker", "started");
startupEvt.finish();
emitIfSampled(startupEvt.toJSON());
} catch (err) {
ready(false);
startupEvt.setError(err);
startupEvt.addMeta("reminder_worker", "failed");
startupEvt.finish();
emitIfSampled(startupEvt.toJSON());
// Still log to stderr for container health checks
console.error(
"[instrumentation] CRITICAL: Failed to start reminder worker:",
err,
);
}
// Still log to stderr for container health checks
console.error(
"[instrumentation] CRITICAL: Failed to start reminder worker:",
err,
);
}
})();
}
}
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more