mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'feat/v144-housekeeping' into release/v1.44.0
This commit is contained in:
commit
ccd84e6d01
182 files changed
+4892
-906
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 토큰을 허용해요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user