Merge branch 'fix/v1438-r2' into release/v1.43.8

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 17:08:00 +02:00
commit 72ce7a0ead
108 files changed
+2529 -793

No files matched your search

+40 -12
View File
@@ -398,14 +398,15 @@ paths:
"429": &a12
description: "Rate limit exceeded. This route shares one per-account bucket with every other authenticated write —
`record-write:<accountId>`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns
their own allowance rather than the record owner's. Only the bulk ingest endpoints, which meter themselves
with a dedicated bucket, stay outside it. Nothing was written. `meta.errorCode` is
`record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when
both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`
headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against
the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422
spends the same allowance as one that writes rows. A client with more than a handful of rows to send should
use the batch endpoint for its domain instead of looping this one."
their own allowance rather than the record owner's. Only the routes that meter themselves with a dedicated
bucket (the bulk ingest endpoints and the model-turn chats) and a POST that writes nothing stay outside it.
If the bucket cannot be read, the write goes ahead unmetered rather than failing. Nothing was written.
`meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own
narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`
and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every
request counts against the bucket, refused ones included: the check runs before the body is read, so a
client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful
of rows to send should use the batch endpoint for its domain instead of looping this one."
headers: *a8
content:
application/json:
@@ -16369,8 +16370,9 @@ paths:
tags:
- Records
summary: Soft-delete a practitioner
description: Stamps `deletedAt`. Every visit that named this entry survives with no practice attached, because the
reference nulls rather than cascades. Idempotent. Audits as `practitioner.contact.delete`.
description: Stamps `deletedAt`. The entry leaves the address book and the pickers; every live visit and vaccination
that named it keeps showing it, and the tombstone is kept while one does. Idempotent. Audits as
`practitioner.contact.delete`.
parameters:
- in: path
name: id
@@ -75718,7 +75720,7 @@ components:
conversations:
type: array
items:
$ref: "#/components/schemas/DocumentChatConversation"
$ref: "#/components/schemas/DocumentChatListItem"
nextCursor:
type:
- string
@@ -75727,7 +75729,7 @@ components:
- conversations
- nextCursor
additionalProperties: false
DocumentChatConversation:
DocumentChatListItem:
type: object
properties:
id:
@@ -75740,12 +75742,38 @@ components:
type: string
messageCount:
type: number
fenced:
type: boolean
description: "Always true here: a chat about a document is a fenced thread (no tools, no health snapshot)."
attachments:
description: The documents the thread is about, as they are now.
type: array
items:
type: object
properties:
documentId:
type: string
title:
description: The attached document's title, falling back to its filename; null when it has neither.
type:
- string
- "null"
required:
- documentId
- title
additionalProperties: false
documentTitle:
description: The first attached document's title, for a one-line badge; null when none is attached any more.
type:
- string
- "null"
required:
- id
- title
- createdAt
- updatedAt
- messageCount
- fenced
additionalProperties: false
DocumentReindexEnvelope:
type: object
+5
View File
@@ -68,6 +68,11 @@ Database error code: 55P03
HealthLog: migration 0412_measurement_hot_updates timed out waiting for a lock (55P03) and changed nothing; marking it rolled back and retrying in 15s (retry 1 of 5).
```
A marked migration that loses a deadlock against an app transaction
(`Database error code: 40P01`) is in the same state, its block rolled back
whole, and is handled the same way; the entrypoint's line then says
`was cancelled in a deadlock (40P01)`.
What happens on its own: such a migration is written so that a timed-out
attempt changes nothing, and it says so in its file
(`-- @retry-on-lock-timeout`). The entrypoint then marks that one migration
+59 -1
View File
@@ -151,7 +151,8 @@ SELECT pg_size_pretty(pg_total_relation_size('measurements')) AS total,
## The nightly check
Since v1.43.7 the same queue also runs by itself at 03:40 (Europe/Berlin).
That run names no steps and changes nothing: vacuuming the table is
That run names no steps and changes nothing beyond dropping a retired index
(next section): vacuuming the table is
autovacuum's job, which migration 0412 starts at 5 % dead rows instead of
Postgres's 20 %. The check reads the table's live and dead rows and warns
(`maintenance_autovacuum_behind` on the wide event) when there are at least
@@ -185,6 +186,63 @@ FROM pg_stat_user_tables WHERE relname = 'measurements';
applies to pages written after the migration; a `VACUUM FULL` after the
upgrade applies it to the whole table.
## Retired indexes
An index the schema no longer declares has to leave the database too, and on
`measurements` the obvious migration is the wrong one. A plain `DROP INDEX`
needs an exclusive lock on the table; `DROP INDEX CONCURRENTLY` blocks nobody
but waits, without limit, for every transaction already running on the
table. A migration runs at boot, so either one waits for whatever holds the
table (a `pg_dump`, a long backfill, a `VACUUM FULL`); past the container's
start period the container is replaced with the migration half done, and the
next boot stops at `P3009` until someone runs `prisma migrate resolve`.
So an index is retired in two halves (v1.43.8, migration 0416, for
`measurements_user_id_type_measured_at_idx`):
1. The migration tries a plain `DROP INDEX IF EXISTS` inside a `DO` block with
a two-second `lock_timeout` and catches the timeout
(`EXCEPTION WHEN lock_not_available`). When nothing holds the table the
index is gone at once; otherwise the migration still succeeds and the
index stays. The longest the table's other statements can wait behind the
attempt is those two seconds. A new database and Prisma's shadow database
always take the lock, so the migration history ends without the index,
exactly as the schema does, and `prisma migrate dev` / `migrate diff` see
no drift and generate nothing.
2. The index name goes into `RETIRED_MEASUREMENT_INDEXES` in
`src/lib/jobs/measurement-maintenance.ts`. Every run of this job, the
nightly 03:40 check included, drops each name still present with
`DROP INDEX CONCURRENTLY IF EXISTS`, waiting at most 60 seconds for its
locks. A run that runs out of time leaves the index (possibly marked
invalid, which the next drop finishes), sets
`maintenance_retired_index_pending` on the wide event with a warning, and
the next night tries again. The index costs writes until then, as it did
before the release; nothing reads it.
Until the job has run, the database holds one index the schema does not
declare. Nothing compares the two at runtime, and the only place they are
compared, a `migrate diff` against the migration history, never sees it. To
check by hand:
```sql
SELECT indexname FROM pg_indexes
WHERE tablename = 'measurements'
AND indexname = 'measurements_user_id_type_measured_at_idx';
```
No row: it is gone. To drop it now rather than at night, start a run from the
admin card (any steps), or run the same statement yourself:
```sql
SET lock_timeout = '60s';
DROP INDEX CONCURRENTLY IF EXISTS "measurements_user_id_type_measured_at_idx";
```
For the next large index, follow the same two halves.
`src/__tests__/migration-lock-timeout-guard.test.ts` refuses a
`DROP INDEX CONCURRENTLY` in any migration and requires every retired name to
have a migration that bounds its wait and catches the timeout.
## "could not resize shared memory segment"
Up to v1.42.0 the run could stop at once with `could not resize shared memory
+6 -6
View File
@@ -137,12 +137,12 @@ async function expectSharedTabs(page: Page, chart: string, pressed: string) {
await expect(tabs).toHaveCount(4);
expect(
await tabs.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-range")),
nodes.map((node) => node.getAttribute("data-value")),
),
).toEqual(RANGES);
await expect(
page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`),
).toHaveAttribute("data-range", pressed);
).toHaveAttribute("data-value", pressed);
}
test.describe("score histories", () => {
@@ -170,7 +170,7 @@ test.describe("score histories", () => {
expect(asked).toContain(30);
await chart
.locator('[data-slot="chart-range-tab"][data-range="7"]')
.locator('[data-slot="chart-range-tab"][data-value="7"]')
.click();
await expect(chart).toHaveAttribute("data-range", "7");
await expect.poll(() => asked.at(-1)).toBe(7);
@@ -185,7 +185,7 @@ test.describe("score histories", () => {
await expect(chart).toHaveAttribute("data-range", "7");
await chart
.locator('[data-slot="chart-range-tab"][data-range="0"]')
.locator('[data-slot="chart-range-tab"][data-value="0"]')
.click();
await expect(chart).toHaveAttribute("data-range", "0");
await expect.poll(() => asked.at(-1)).toBe(3650);
@@ -231,7 +231,7 @@ test.describe("score histories", () => {
await expect(chart).toHaveAttribute("data-score", "READINESS");
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
await chart
.locator('[data-slot="chart-range-tab"][data-range="90"]')
.locator('[data-slot="chart-range-tab"][data-value="90"]')
.click();
await expect(chart).toHaveAttribute("data-range", "90");
await expect.poll(() => asked.at(-1)).toBe(90);
@@ -262,7 +262,7 @@ test.describe("score histories", () => {
page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`),
).toHaveCount(0);
await page
.locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`)
.locator(`${chart} [data-slot="chart-range-tab"][data-value="7"]`)
.click();
await expectSharedTabs(page, chart, "7");
await page.reload();
@@ -58,8 +58,12 @@ ruleTester.run("no-unlocalised-decimal", rule, {
code: "const s = value.toFixed(2);",
filename: "/repo/src/app/api/export/route.ts",
},
// Outside the UI roots and in tests the rule is silent.
{ code: "const s = value.toFixed(2);", filename: "/repo/src/lib/x.ts" },
// Outside the source roots and in tests the rule is silent.
{ code: "const s = value.toFixed(2);", filename: "/repo/scripts/x.ts" },
{
code: "const s = value.toFixed(2);",
filename: "/repo/src/lib/__tests__/x.test.ts",
},
{
code: "const s = value.toFixed(2);",
filename: "/repo/src/components/__tests__/x.test.tsx",
@@ -71,6 +75,18 @@ ruleTester.run("no-unlocalised-decimal", rule, {
filename: APP_FILE,
errors: [{ messageId: "fixed", data: { method: "toFixed" } }],
},
// A sentence built on the server for a person: the weekday correlation
// printed "0.8 kg" in a German sentence from src/lib.
{
code: 'const delta = Math.abs(a - b).toFixed(1); t("x", { delta });',
filename: "/repo/src/lib/insights/correlations.ts",
errors: [{ messageId: "fixed" }],
},
{
code: "const s = value.toFixed(1);",
filename: "/repo/src/hooks/use-x.ts",
errors: [{ messageId: "fixed" }],
},
{
code: "const s = `${value.toFixed(1)} kg`;",
filename: PAGE_FILE,
@@ -18,8 +18,14 @@
* - rounding, not display: `Number(value.toFixed(2))` stays legal — the
* result is a number again and is formatted wherever it is shown
*
* FLAGGED, in `src/components/` and `src/app/` outside `src/app/api/` (wire
* formats are not display) and outside test files:
* FLAGGED, in `src/components/`, `src/app/`, `src/hooks/` and `src/lib/`
* outside `src/app/api/` (wire formats are not display) and outside test
* files. `src/lib/` is in scope because sentences for people are built
* there too (a correlation hypothesis, a deterministic fallback, a narrative)
* and the weekday correlation printed "0.8 kg" into a German sentence; a
* machine-read string there (a prompt for the model, an operator log line,
* SVG path data, a wire value a client re-formats) carries a reasoned
* directive like any other.
*
* - `<x>.toFixed(…)` and `<x>.toPrecision(…)`, unless the call is the
* argument of `Number(…)` / `parseFloat(…)` / `Number.parseFloat(…)` or
@@ -52,7 +58,12 @@
"use strict";
const RULE_NAME = "healthlog/no-unlocalised-decimal";
const ENFORCED_ROOTS = ["src/components/", "src/app/"];
const ENFORCED_ROOTS = [
"src/components/",
"src/app/",
"src/hooks/",
"src/lib/",
];
const EXEMPT_ROOTS = ["src/app/api/"];
const MIN_REASON_CHARS = 8;
+2 -1
View File
@@ -122,7 +122,8 @@ const eslintConfig = defineConfig([
// planning-tags.js for the shape and its limits.
"healthlog/no-planning-tags": "error",
// A number shown to a person goes through useFormatters() (or
// makeFormatters outside React): toFixed() prints a full stop in every
// makeFormatters / formatDecimal outside React), in src/components,
// src/app, src/hooks and src/lib: toFixed() prints a full stop in every
// language and a locale-less toLocaleString() follows the browser
// instead of the language picked in the app. SVG path data and other
// machine-read strings carry `eslint-disable-next-line ... -- <reason>`;
+11 -26
View File
@@ -4221,13 +4221,8 @@
"metricNextDayRecovery": "Erholung am Folgetag",
"metricNextDayRestingHr": "Ruhepuls am Folgetag",
"metricNextDayHrv": "Herzfrequenzvariabilität am Folgetag",
"unitHours": "Std.",
"unitKcal": "kcal",
"unitScore": "Pkt.",
"unitBpm": "bpm",
"unitMs": "ms",
"detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.",
"detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst."
"detailSameDay": "Durchschnittlich {withAvg} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} ohne.",
"detailNextDay": "Durchschnittlich {withAvg} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} sonst."
},
"factorCrosstab": {
"title": "Deine bewerteten Faktoren und deine Vitalwerte",
@@ -4239,15 +4234,8 @@
"metricHrv": "Herzfrequenzvariabilität",
"metricWeight": "Gewicht",
"metricBloodPressureSys": "Systolisch",
"unitHours": "Std.",
"unitScore": "Pkt.",
"unitSteps": "Schritte",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "Durchschnittlich {lowAvg} {unit} an den {lowDays} Tagen mit niedriger {factor}-Bewertung gegenüber {highAvg} {unit} an den Tagen mit hoher Bewertung.",
"detailInverse": "Durchschnittlich {lowAvg} {unit} an deinen {lowDays} schlechteren {factor}-Tagen gegenüber {highAvg} {unit} an deinen besseren."
"detail": "Durchschnittlich {lowAvg} an den {lowDays} Tagen mit niedriger {factor}-Bewertung gegenüber {highAvg} an den Tagen mit hoher Bewertung.",
"detailInverse": "Durchschnittlich {lowAvg} an deinen {lowDays} schlechteren {factor}-Tagen gegenüber {highAvg} an deinen besseren."
},
"discovery": {
"title": "Was auffällt",
@@ -7748,6 +7736,7 @@
"tokensEmptyTitle": "Noch keine API-Tokens",
"tokensEmptyDescription": "Tokens werden bei der Anmeldung nativer Clients oder über Einstellungen → Account ausgestellt.",
"loginOverviewDescription": "Anmelde- und Auth-Ereignisse aller Konten, filterbar und als CSV exportierbar.",
"loginOverviewFilterLabel": "Ereignisse filtern",
"allAuthEvents": "Alle Auth-Ereignisse",
"failedOnly": "Nur fehlgeschlagene",
"loginEmptyDescription": "Anmeldungen, Abmeldungen und Passkey-Änderungen erscheinen hier.",
@@ -7876,6 +7865,7 @@
"admin": "Admins",
"user": "Nutzer"
},
"filterLabel": "Benutzer filtern",
"forceLogout": "Abmeldung erzwingen",
"forceLogoutConfirmTitle": "{name} abmelden?",
"forceLogoutConfirmBody": "Alle aktiven Sitzungen dieses Nutzers werden widerrufen. Auf allen Geräten ist eine erneute Anmeldung erforderlich.",
@@ -9158,9 +9148,9 @@
"insights": {
"headline": {
"title": "Dein Zyklus und dein Körper",
"restingHeartRate": "Dein Ruhepuls liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.",
"heartRateVariability": "Deine Herzfrequenzvariabilität liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.",
"generic": "Dein/e {metric} liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.",
"restingHeartRate": "Dein Ruhepuls liegt in der Lutealphase etwa {delta} {dir} als in der Follikelphase.",
"heartRateVariability": "Deine Herzfrequenzvariabilität liegt in der Lutealphase etwa {delta} {dir} als in der Follikelphase.",
"generic": "Dein/e {metric} liegt in der Lutealphase etwa {delta} {dir} als in der Follikelphase.",
"dirHigher": "höher",
"dirLower": "niedriger",
"empty": "Noch nicht genug Zyklen und Messwerte für ein Phasenmuster. Logge weiter, das füllt sich, je länger deine Historie wird."
@@ -9168,7 +9158,7 @@
"crosstab": {
"description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht, auf deinen eigenen Daten.",
"pairLabel": "{metric}: Luteal- vs. Follikelphase",
"detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).",
"detail": "Luteal {lutealAvg} ({lutealDays} Tage) vs. Follikel {follicularAvg} ({follicularDays} Tage).",
"metricRestingHeartRate": "Ruhepuls",
"metricHrv": "Herzfrequenzvariabilität",
"metricSleepDuration": "Schlafdauer",
@@ -9178,12 +9168,7 @@
"metricWristTemperature": "Handgelenkstemperatur nachts",
"metricSkinTemperature": "Hauttemperatur",
"metricBloodGlucose": "Blutzucker",
"metricMood": "Stimmung",
"unitHours": "Std.",
"unitSteps": "Schritte",
"unitBpm": "S/min",
"unitMs": "ms",
"unitMood": "Pkt."
"metricMood": "Stimmung"
},
"title": "Zyklus-Erkenntnisse",
"symptomPatternsTitle": "Symptommuster nach Phase",
+11 -26
View File
@@ -4221,13 +4221,8 @@
"metricNextDayRecovery": "Next-day recovery",
"metricNextDayRestingHr": "Next-day resting heart rate",
"metricNextDayHrv": "Next-day heart-rate variability",
"unitHours": "h",
"unitKcal": "kcal",
"unitScore": "pts",
"unitBpm": "bpm",
"unitMs": "ms",
"detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.",
"detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise."
"detailSameDay": "Averages {withAvg} on the {withDays} days you tagged this vs {withoutAvg} without it.",
"detailNextDay": "Averages {withAvg} the day after the {withDays} days you tagged this vs {withoutAvg} otherwise."
},
"factorCrosstab": {
"title": "Your rated factors and your vitals",
@@ -4239,15 +4234,8 @@
"metricHrv": "Heart-rate variability",
"metricWeight": "Weight",
"metricBloodPressureSys": "Systolic",
"unitHours": "h",
"unitScore": "pts",
"unitSteps": "steps",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "Averages {lowAvg} {unit} on the {lowDays} days you rated {factor} low vs {highAvg} {unit} on the days you rated it high.",
"detailInverse": "Averages {lowAvg} {unit} on your {lowDays} worse {factor} days vs {highAvg} {unit} on your better ones."
"detail": "Averages {lowAvg} on the {lowDays} days you rated {factor} low vs {highAvg} on the days you rated it high.",
"detailInverse": "Averages {lowAvg} on your {lowDays} worse {factor} days vs {highAvg} on your better ones."
},
"discovery": {
"title": "What stands out",
@@ -7748,6 +7736,7 @@
"tokensEmptyTitle": "No API tokens yet",
"tokensEmptyDescription": "Tokens are issued when a native client signs in or via Settings → Account.",
"loginOverviewDescription": "Sign-in and auth events across all accounts, filterable and exportable as CSV.",
"loginOverviewFilterLabel": "Filter events",
"allAuthEvents": "All auth events",
"failedOnly": "Failed only",
"loginEmptyDescription": "Sign-ins, sign-outs, and passkey changes will show up here.",
@@ -7876,6 +7865,7 @@
"admin": "Admins",
"user": "Users"
},
"filterLabel": "Filter users",
"forceLogout": "Force logout",
"forceLogoutConfirmTitle": "Force logout {name}?",
"forceLogoutConfirmBody": "All active sessions for this user will be revoked. They will need to sign in again on every device.",
@@ -9158,9 +9148,9 @@
"insights": {
"headline": {
"title": "Your cycle and your body",
"restingHeartRate": "Your resting heart rate runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.",
"heartRateVariability": "Your heart-rate variability runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.",
"generic": "Your {metric} runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.",
"restingHeartRate": "Your resting heart rate runs about {delta} {dir} in your luteal phase than your follicular phase.",
"heartRateVariability": "Your heart-rate variability runs about {delta} {dir} in your luteal phase than your follicular phase.",
"generic": "Your {metric} runs about {delta} {dir} in your luteal phase than your follicular phase.",
"dirHigher": "higher",
"dirLower": "lower",
"empty": "Not enough cycles and readings yet to surface a phase pattern. Keep logging; this fills in as your history grows."
@@ -9168,7 +9158,7 @@
"crosstab": {
"description": "How each measurement compares between your luteal and follicular phases, on your own data.",
"pairLabel": "{metric}: luteal vs follicular",
"detail": "Luteal {lutealAvg} {unit} ({lutealDays} days) vs follicular {follicularAvg} {unit} ({follicularDays} days).",
"detail": "Luteal {lutealAvg} ({lutealDays} days) vs follicular {follicularAvg} ({follicularDays} days).",
"metricRestingHeartRate": "Resting heart rate",
"metricHrv": "Heart-rate variability",
"metricSleepDuration": "Sleep duration",
@@ -9178,12 +9168,7 @@
"metricWristTemperature": "Overnight wrist temperature",
"metricSkinTemperature": "Skin temperature",
"metricBloodGlucose": "Blood glucose",
"metricMood": "Mood",
"unitHours": "h",
"unitSteps": "steps",
"unitBpm": "bpm",
"unitMs": "ms",
"unitMood": "pts"
"metricMood": "Mood"
},
"title": "Cycle insights",
"symptomPatternsTitle": "Symptom patterns by phase",
+11 -26
View File
@@ -4221,13 +4221,8 @@
"metricNextDayRecovery": "Recuperación al día siguiente",
"metricNextDayRestingHr": "Frecuencia cardíaca en reposo al día siguiente",
"metricNextDayHrv": "Variabilidad de la frecuencia cardíaca al día siguiente",
"unitHours": "h",
"unitKcal": "kcal",
"unitScore": "pts",
"unitBpm": "ppm",
"unitMs": "ms",
"detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.",
"detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto."
"detailSameDay": "Promedia {withAvg} en los {withDays} días que lo etiquetaste frente a {withoutAvg} sin él.",
"detailNextDay": "Promedia {withAvg} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} el resto."
},
"factorCrosstab": {
"title": "Tus factores valorados y tus constantes",
@@ -4239,15 +4234,8 @@
"metricHrv": "Variabilidad de la frecuencia cardíaca",
"metricWeight": "Peso",
"metricBloodPressureSys": "Sistólica",
"unitHours": "h",
"unitScore": "pts",
"unitSteps": "pasos",
"unitBpm": "ppm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "Promedia {lowAvg} {unit} en los {lowDays} días que valoraste {factor} bajo frente a {highAvg} {unit} en los días que lo valoraste alto.",
"detailInverse": "Promedia {lowAvg} {unit} en tus {lowDays} peores días de {factor} frente a {highAvg} {unit} en los mejores."
"detail": "Promedia {lowAvg} en los {lowDays} días que valoraste {factor} bajo frente a {highAvg} en los días que lo valoraste alto.",
"detailInverse": "Promedia {lowAvg} en tus {lowDays} peores días de {factor} frente a {highAvg} en los mejores."
},
"discovery": {
"title": "Lo que destaca",
@@ -7748,6 +7736,7 @@
"tokensEmptyTitle": "Aún no hay tokens de API",
"tokensEmptyDescription": "Los tokens se emiten cuando un cliente nativo inicia sesión o desde Ajustes → Cuenta.",
"loginOverviewDescription": "Eventos de inicio de sesión y autenticación de todas las cuentas, filtrables y exportables como CSV.",
"loginOverviewFilterLabel": "Filtrar eventos",
"allAuthEvents": "Todos los eventos de autenticación",
"failedOnly": "Solo fallidos",
"loginEmptyDescription": "Aquí aparecerán los inicios de sesión, los cierres de sesión y los cambios de passkey.",
@@ -7876,6 +7865,7 @@
"admin": "Administradores",
"user": "Usuarios"
},
"filterLabel": "Filtrar usuarios",
"forceLogout": "Forzar cierre de sesión",
"forceLogoutConfirmTitle": "¿Forzar el cierre de sesión de {name}?",
"forceLogoutConfirmBody": "Se revocarán todas las sesiones activas de este usuario. Tendrá que volver a iniciar sesión en todos los dispositivos.",
@@ -9158,9 +9148,9 @@
"insights": {
"headline": {
"title": "Tu ciclo y tu cuerpo",
"restingHeartRate": "Tu frecuencia cardíaca en reposo es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.",
"heartRateVariability": "Tu variabilidad de la frecuencia cardíaca es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.",
"generic": "Tu {metric} es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.",
"restingHeartRate": "Tu frecuencia cardíaca en reposo es unos {delta} {dir} en tu fase lútea que en tu fase folicular.",
"heartRateVariability": "Tu variabilidad de la frecuencia cardíaca es unos {delta} {dir} en tu fase lútea que en tu fase folicular.",
"generic": "Tu {metric} es unos {delta} {dir} en tu fase lútea que en tu fase folicular.",
"dirHigher": "más alta",
"dirLower": "más baja",
"empty": "Aún no hay suficientes ciclos ni registros para mostrar un patrón de fase. Sigue registrando: se completará a medida que crezca tu historial."
@@ -9168,7 +9158,7 @@
"crosstab": {
"description": "Cómo se compara cada medición entre tus fases lútea y folicular, con tus propios datos.",
"pairLabel": "{metric}: fase lútea vs. folicular",
"detail": "Lútea {lutealAvg} {unit} ({lutealDays} días) vs. folicular {follicularAvg} {unit} ({follicularDays} días).",
"detail": "Lútea {lutealAvg} ({lutealDays} días) vs. folicular {follicularAvg} ({follicularDays} días).",
"metricRestingHeartRate": "Frecuencia cardíaca en reposo",
"metricHrv": "Variabilidad de la frecuencia cardíaca",
"metricSleepDuration": "Duración del sueño",
@@ -9178,12 +9168,7 @@
"metricWristTemperature": "Temperatura de la muñeca nocturna",
"metricSkinTemperature": "Temperatura de la piel",
"metricBloodGlucose": "Glucosa en sangre",
"metricMood": "Estado de ánimo",
"unitHours": "h",
"unitSteps": "pasos",
"unitBpm": "lpm",
"unitMs": "ms",
"unitMood": "pts"
"metricMood": "Estado de ánimo"
},
"title": "Análisis del ciclo",
"symptomPatternsTitle": "Patrones de síntomas por fase",
+11 -26
View File
@@ -4221,13 +4221,8 @@
"metricNextDayRecovery": "Récupération le lendemain",
"metricNextDayRestingHr": "Fréquence cardiaque au repos le lendemain",
"metricNextDayHrv": "Variabilité de la fréquence cardiaque le lendemain",
"unitHours": "h",
"unitKcal": "kcal",
"unitScore": "pts",
"unitBpm": "bpm",
"unitMs": "ms",
"detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.",
"detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon."
"detailSameDay": "En moyenne {withAvg} les {withDays} jours avec ce tag contre {withoutAvg} sans.",
"detailNextDay": "En moyenne {withAvg} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} sinon."
},
"factorCrosstab": {
"title": "Vos facteurs notés et vos constantes",
@@ -4239,15 +4234,8 @@
"metricHrv": "Variabilité de la fréquence cardiaque",
"metricWeight": "Poids",
"metricBloodPressureSys": "Systolique",
"unitHours": "h",
"unitScore": "pts",
"unitSteps": "pas",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "En moyenne {lowAvg} {unit} les {lowDays} jours où vous avez noté {factor} bas contre {highAvg} {unit} les jours où vous l'avez noté haut.",
"detailInverse": "En moyenne {lowAvg} {unit} sur vos {lowDays} pires jours de {factor} contre {highAvg} {unit} sur les meilleurs."
"detail": "En moyenne {lowAvg} les {lowDays} jours où vous avez noté {factor} bas contre {highAvg} les jours où vous l'avez noté haut.",
"detailInverse": "En moyenne {lowAvg} sur vos {lowDays} pires jours de {factor} contre {highAvg} sur les meilleurs."
},
"discovery": {
"title": "Ce qui ressort",
@@ -7748,6 +7736,7 @@
"tokensEmptyTitle": "Aucun jeton d'API pour l'instant",
"tokensEmptyDescription": "Les jetons sont émis lorsqu'un client natif se connecte ou via Paramètres → Compte.",
"loginOverviewDescription": "Événements de connexion et d’authentification de tous les comptes, filtrables et exportables en CSV.",
"loginOverviewFilterLabel": "Filtrer les événements",
"allAuthEvents": "Tous les événements d'authentification",
"failedOnly": "Échecs uniquement",
"loginEmptyDescription": "Les connexions, déconnexions et modifications de passkey apparaîtront ici.",
@@ -7876,6 +7865,7 @@
"admin": "Administrateurs",
"user": "Utilisateurs"
},
"filterLabel": "Filtrer les utilisateurs",
"forceLogout": "Forcer la déconnexion",
"forceLogoutConfirmTitle": "Forcer la déconnexion de {name} ?",
"forceLogoutConfirmBody": "Toutes les sessions actives de cet utilisateur seront révoquées. Il devra se reconnecter sur chaque appareil.",
@@ -9158,9 +9148,9 @@
"insights": {
"headline": {
"title": "Ton cycle et ton corps",
"restingHeartRate": "Ta fréquence cardiaque au repos est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.",
"heartRateVariability": "Ta variabilité de la fréquence cardiaque est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.",
"generic": "Ton/ta {metric} est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.",
"restingHeartRate": "Ta fréquence cardiaque au repos est d'environ {delta} {dir} en phase lutéale qu'en phase folliculaire.",
"heartRateVariability": "Ta variabilité de la fréquence cardiaque est d'environ {delta} {dir} en phase lutéale qu'en phase folliculaire.",
"generic": "Ton/ta {metric} est d'environ {delta} {dir} en phase lutéale qu'en phase folliculaire.",
"dirHigher": "plus élevée",
"dirLower": "plus basse",
"empty": "Pas encore assez de cycles et de relevés pour faire ressortir un schéma de phase. Continue à enregistrer : cela se complétera à mesure que ton historique grandit."
@@ -9168,7 +9158,7 @@
"crosstab": {
"description": "Comment chaque mesure se compare entre tes phases lutéale et folliculaire, sur tes propres données.",
"pairLabel": "{metric} : phase lutéale vs folliculaire",
"detail": "Lutéale {lutealAvg} {unit} ({lutealDays} jours) vs folliculaire {follicularAvg} {unit} ({follicularDays} jours).",
"detail": "Lutéale {lutealAvg} ({lutealDays} jours) vs folliculaire {follicularAvg} ({follicularDays} jours).",
"metricRestingHeartRate": "Fréquence cardiaque au repos",
"metricHrv": "Variabilité de la fréquence cardiaque",
"metricSleepDuration": "Durée du sommeil",
@@ -9178,12 +9168,7 @@
"metricWristTemperature": "Température du poignet la nuit",
"metricSkinTemperature": "Température de la peau",
"metricBloodGlucose": "Glycémie",
"metricMood": "Humeur",
"unitHours": "h",
"unitSteps": "pas",
"unitBpm": "bpm",
"unitMs": "ms",
"unitMood": "pts"
"metricMood": "Humeur"
},
"title": "Analyses du cycle",
"symptomPatternsTitle": "Tendances des symptômes par phase",
+11 -26
View File
@@ -4221,13 +4221,8 @@
"metricNextDayRecovery": "Recupero il giorno dopo",
"metricNextDayRestingHr": "Frequenza cardiaca a riposo il giorno dopo",
"metricNextDayHrv": "Variabilità della frequenza cardiaca il giorno dopo",
"unitHours": "h",
"unitKcal": "kcal",
"unitScore": "pt",
"unitBpm": "bpm",
"unitMs": "ms",
"detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.",
"detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti."
"detailSameDay": "In media {withAvg} nei {withDays} giorni con questo tag contro {withoutAvg} senza.",
"detailNextDay": "In media {withAvg} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} altrimenti."
},
"factorCrosstab": {
"title": "I tuoi fattori valutati e i tuoi parametri vitali",
@@ -4239,15 +4234,8 @@
"metricHrv": "Variabilità della frequenza cardiaca",
"metricWeight": "Peso",
"metricBloodPressureSys": "Sistolica",
"unitHours": "h",
"unitScore": "pti",
"unitSteps": "passi",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "In media {lowAvg} {unit} nei {lowDays} giorni in cui hai valutato basso {factor} contro {highAvg} {unit} nei giorni in cui lo hai valutato alto.",
"detailInverse": "In media {lowAvg} {unit} nei tuoi {lowDays} giorni peggiori di {factor} contro {highAvg} {unit} in quelli migliori."
"detail": "In media {lowAvg} nei {lowDays} giorni in cui hai valutato basso {factor} contro {highAvg} nei giorni in cui lo hai valutato alto.",
"detailInverse": "In media {lowAvg} nei tuoi {lowDays} giorni peggiori di {factor} contro {highAvg} in quelli migliori."
},
"discovery": {
"title": "Cosa risalta",
@@ -7748,6 +7736,7 @@
"tokensEmptyTitle": "Ancora nessun token API",
"tokensEmptyDescription": "I token vengono emessi quando un client nativo accede o tramite Impostazioni → Account.",
"loginOverviewDescription": "Eventi di accesso e autenticazione di tutti gli account, filtrabili ed esportabili in CSV.",
"loginOverviewFilterLabel": "Filtra eventi",
"allAuthEvents": "Tutti gli eventi di autenticazione",
"failedOnly": "Solo non riusciti",
"loginEmptyDescription": "Qui compariranno accessi, disconnessioni e modifiche delle passkey.",
@@ -7876,6 +7865,7 @@
"admin": "Amministratori",
"user": "Utenti"
},
"filterLabel": "Filtra utenti",
"forceLogout": "Forza la disconnessione",
"forceLogoutConfirmTitle": "Forzare la disconnessione di {name}?",
"forceLogoutConfirmBody": "Tutte le sessioni attive di questo utente verranno revocate. Dovrà accedere di nuovo su ogni dispositivo.",
@@ -9158,9 +9148,9 @@
"insights": {
"headline": {
"title": "Il tuo ciclo e il tuo corpo",
"restingHeartRate": "La tua frequenza cardiaca a riposo è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.",
"heartRateVariability": "La tua variabilità della frequenza cardiaca è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.",
"generic": "Il/la tuo/a {metric} è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.",
"restingHeartRate": "La tua frequenza cardiaca a riposo è di circa {delta} {dir} nella fase luteale rispetto alla fase follicolare.",
"heartRateVariability": "La tua variabilità della frequenza cardiaca è di circa {delta} {dir} nella fase luteale rispetto alla fase follicolare.",
"generic": "Il/la tuo/a {metric} è di circa {delta} {dir} nella fase luteale rispetto alla fase follicolare.",
"dirHigher": "più alta",
"dirLower": "più bassa",
"empty": "Non ci sono ancora abbastanza cicli e rilevazioni per mostrare uno schema di fase. Continua a registrare: si completerà man mano che cresce il tuo storico."
@@ -9168,7 +9158,7 @@
"crosstab": {
"description": "Come si confronta ogni misurazione tra la fase luteale e quella follicolare, sui tuoi dati.",
"pairLabel": "{metric}: fase luteale vs follicolare",
"detail": "Luteale {lutealAvg} {unit} ({lutealDays} giorni) vs follicolare {follicularAvg} {unit} ({follicularDays} giorni).",
"detail": "Luteale {lutealAvg} ({lutealDays} giorni) vs follicolare {follicularAvg} ({follicularDays} giorni).",
"metricRestingHeartRate": "Frequenza cardiaca a riposo",
"metricHrv": "Variabilità della frequenza cardiaca",
"metricSleepDuration": "Durata del sonno",
@@ -9178,12 +9168,7 @@
"metricWristTemperature": "Temperatura del polso notturna",
"metricSkinTemperature": "Temperatura della pelle",
"metricBloodGlucose": "Glicemia",
"metricMood": "Umore",
"unitHours": "h",
"unitSteps": "passi",
"unitBpm": "bpm",
"unitMs": "ms",
"unitMood": "pts"
"metricMood": "Umore"
},
"title": "Analisi del ciclo",
"symptomPatternsTitle": "Pattern dei sintomi per fase",
+11 -26
View File
@@ -4221,13 +4221,8 @@
"metricNextDayRecovery": "다음 날 회복도",
"metricNextDayRestingHr": "다음 날 안정 시 심박수",
"metricNextDayHrv": "다음 날 심박변이도",
"unitHours": "h",
"unitKcal": "kcal",
"unitScore": "점",
"unitBpm": "bpm",
"unitMs": "ms",
"detailSameDay": "이 태그를 붙인 {withDays}일의 평균은 {withAvg}{unit}, 붙이지 않은 날의 평균은 {withoutAvg}{unit}.",
"detailNextDay": "이 태그를 붙인 {withDays}일의 다음 날 평균은 {withAvg}{unit}, 그 외의 날은 {withoutAvg}{unit}."
"detailSameDay": "이 태그를 붙인 {withDays}일의 평균은 {withAvg}, 붙이지 않은 날의 평균은 {withoutAvg}.",
"detailNextDay": "이 태그를 붙인 {withDays}일의 다음 날 평균은 {withAvg}, 그 외의 날은 {withoutAvg}."
},
"factorCrosstab": {
"title": "직접 평가한 요인과 생체 지표",
@@ -4239,15 +4234,8 @@
"metricHrv": "심박변이도",
"metricWeight": "체중",
"metricBloodPressureSys": "수축기",
"unitHours": "h",
"unitScore": "점",
"unitSteps": "걸음",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "{factor} 평가가 낮았던 {lowDays}일의 평균은 {lowAvg}{unit}, 높게 평가한 날의 평균은 {highAvg}{unit}.",
"detailInverse": "{factor} 상태가 나빴던 {lowDays}일의 평균은 {lowAvg}{unit}, 좋았던 날의 평균은 {highAvg}{unit}."
"detail": "{factor} 평가가 낮았던 {lowDays}일의 평균은 {lowAvg}, 높게 평가한 날의 평균은 {highAvg}.",
"detailInverse": "{factor} 상태가 나빴던 {lowDays}일의 평균은 {lowAvg}, 좋았던 날의 평균은 {highAvg}."
},
"discovery": {
"title": "눈에 띄는 점",
@@ -7723,6 +7711,7 @@
"tokensEmptyTitle": "아직 API 토큰이 없어요",
"tokensEmptyDescription": "네이티브 클라이언트가 로그인할 때나 설정 → 계정에서 토큰이 발급돼요.",
"loginOverviewDescription": "모든 계정의 로그인·인증 이벤트예요. 필터링하고 CSV로 내보낼 수 있어요.",
"loginOverviewFilterLabel": "이벤트 필터",
"allAuthEvents": "전체 인증 이벤트",
"failedOnly": "실패만",
"loginEmptyDescription": "로그인, 로그아웃, 패스키 변경이 여기에 표시돼요.",
@@ -7851,6 +7840,7 @@
"admin": "관리자",
"user": "사용자"
},
"filterLabel": "사용자 필터",
"forceLogout": "강제 로그아웃",
"forceLogoutConfirmTitle": "{name} 강제 로그아웃할까요?",
"forceLogoutConfirmBody": "이 사용자의 활성 세션이 모두 해제돼요. 모든 기기에서 다시 로그인해야 해요.",
@@ -9158,9 +9148,9 @@
"insights": {
"headline": {
"title": "내 주기와 내 몸",
"restingHeartRate": "황체기의 안정 시 심박수가 난포기보다 약 {delta}{unit} {dir}.",
"heartRateVariability": "황체기의 심박변이도가 난포기보다 약 {delta}{unit} {dir}.",
"generic": "황체기의 {metric} 값이 난포기보다 약 {delta}{unit} {dir}.",
"restingHeartRate": "황체기의 안정 시 심박수가 난포기보다 약 {delta} {dir}.",
"heartRateVariability": "황체기의 심박변이도가 난포기보다 약 {delta} {dir}.",
"generic": "황체기의 {metric} 값이 난포기보다 약 {delta} {dir}.",
"dirHigher": "높아요",
"dirLower": "낮아요",
"empty": "아직 주기와 측정값이 부족해서 단계별 패턴을 보여줄 수 없어요. 계속 기록하면 기록이 쌓이면서 채워져요."
@@ -9168,7 +9158,7 @@
"crosstab": {
"description": "내 데이터를 기준으로, 각 측정값이 황체기와 난포기에서 어떻게 다른지 비교했어요.",
"pairLabel": "{metric}: 황체기와 난포기 비교",
"detail": "황체기 {lutealAvg}{unit} ({lutealDays}일), 난포기 {follicularAvg}{unit} ({follicularDays}일).",
"detail": "황체기 {lutealAvg} ({lutealDays}일), 난포기 {follicularAvg} ({follicularDays}일).",
"metricRestingHeartRate": "안정 시 심박수",
"metricHrv": "심박변이도",
"metricSleepDuration": "수면 시간",
@@ -9178,12 +9168,7 @@
"metricWristTemperature": "야간 손목 온도",
"metricSkinTemperature": "피부 온도",
"metricBloodGlucose": "혈당",
"metricMood": "기분",
"unitHours": "h",
"unitSteps": "걸음",
"unitBpm": "bpm",
"unitMs": "ms",
"unitMood": "점"
"metricMood": "기분"
},
"title": "주기 인사이트",
"symptomPatternsTitle": "단계별 증상 패턴",
+11 -26
View File
@@ -4221,13 +4221,8 @@
"metricNextDayRecovery": "Herstel de volgende dag",
"metricNextDayRestingHr": "Rusthartslag de volgende dag",
"metricNextDayHrv": "Hartslagvariabiliteit de volgende dag",
"unitHours": "u",
"unitKcal": "kcal",
"unitScore": "ptn",
"unitBpm": "bpm",
"unitMs": "ms",
"detailSameDay": "Gemiddeld {withAvg} {unit} op de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} {unit} zonder.",
"detailNextDay": "Gemiddeld {withAvg} {unit} op de dag na de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} {unit} op andere dagen."
"detailSameDay": "Gemiddeld {withAvg} op de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} zonder.",
"detailNextDay": "Gemiddeld {withAvg} op de dag na de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} op andere dagen."
},
"factorCrosstab": {
"title": "Je beoordeelde factoren en je vitale waarden",
@@ -4239,15 +4234,8 @@
"metricHrv": "Hartslagvariabiliteit",
"metricWeight": "Gewicht",
"metricBloodPressureSys": "Systolisch",
"unitHours": "u",
"unitScore": "ptn",
"unitSteps": "stappen",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "Gemiddeld {lowAvg} {unit} op de {lowDays} dagen dat je {factor} laag beoordeelde, tegenover {highAvg} {unit} op de dagen dat je die hoog beoordeelde.",
"detailInverse": "Gemiddeld {lowAvg} {unit} op je {lowDays} slechtere dagen voor {factor}, tegenover {highAvg} {unit} op je betere dagen."
"detail": "Gemiddeld {lowAvg} op de {lowDays} dagen dat je {factor} laag beoordeelde, tegenover {highAvg} op de dagen dat je die hoog beoordeelde.",
"detailInverse": "Gemiddeld {lowAvg} op je {lowDays} slechtere dagen voor {factor}, tegenover {highAvg} op je betere dagen."
},
"discovery": {
"title": "Wat opvalt",
@@ -7748,6 +7736,7 @@
"tokensEmptyTitle": "Nog geen API-tokens",
"tokensEmptyDescription": "Tokens worden uitgegeven wanneer een native client inlogt of via Instellingen → Account.",
"loginOverviewDescription": "Inlog- en authenticatiegebeurtenissen van alle accounts, te filteren en te exporteren als CSV.",
"loginOverviewFilterLabel": "Gebeurtenissen filteren",
"allAuthEvents": "Alle authenticatiegebeurtenissen",
"failedOnly": "Alleen mislukte",
"loginEmptyDescription": "Inlogs, uitlogs en wijzigingen aan passkeys verschijnen hier.",
@@ -7876,6 +7865,7 @@
"admin": "Beheerders",
"user": "Gebruikers"
},
"filterLabel": "Gebruikers filteren",
"forceLogout": "Geforceerd uitloggen",
"forceLogoutConfirmTitle": "{name} geforceerd uitloggen?",
"forceLogoutConfirmBody": "Alle actieve sessies van deze gebruiker worden ingetrokken. Deze gebruiker moet daarna op elk apparaat opnieuw inloggen.",
@@ -9158,9 +9148,9 @@
"insights": {
"headline": {
"title": "Je cyclus en je lichaam",
"restingHeartRate": "Je rusthartslag ligt in je luteale fase ongeveer {delta} {unit} {dir} dan in je folliculaire fase.",
"heartRateVariability": "Je hartslagvariabiliteit ligt in je luteale fase ongeveer {delta} {unit} {dir} dan in je folliculaire fase.",
"generic": "Je {metric} ligt in je luteale fase ongeveer {delta} {unit} {dir} dan in je folliculaire fase.",
"restingHeartRate": "Je rusthartslag ligt in je luteale fase ongeveer {delta} {dir} dan in je folliculaire fase.",
"heartRateVariability": "Je hartslagvariabiliteit ligt in je luteale fase ongeveer {delta} {dir} dan in je folliculaire fase.",
"generic": "Je {metric} ligt in je luteale fase ongeveer {delta} {dir} dan in je folliculaire fase.",
"dirHigher": "hoger",
"dirLower": "lager",
"empty": "Nog niet genoeg cycli en metingen om een patroon per fase te tonen. Blijf vastleggen; dit vult zich naarmate je geschiedenis groeit."
@@ -9168,7 +9158,7 @@
"crosstab": {
"description": "Hoe elke meting zich verhoudt tussen je luteale en folliculaire fase, op basis van je eigen gegevens.",
"pairLabel": "{metric}: luteaal vs. folliculair",
"detail": "Luteaal {lutealAvg} {unit} ({lutealDays} dagen) vs. folliculair {follicularAvg} {unit} ({follicularDays} dagen).",
"detail": "Luteaal {lutealAvg} ({lutealDays} dagen) vs. folliculair {follicularAvg} ({follicularDays} dagen).",
"metricRestingHeartRate": "Rusthartslag",
"metricHrv": "Hartslagvariabiliteit",
"metricSleepDuration": "Slaapduur",
@@ -9178,12 +9168,7 @@
"metricWristTemperature": "Nachtelijke polstemperatuur",
"metricSkinTemperature": "Huidtemperatuur",
"metricBloodGlucose": "Bloedglucose",
"metricMood": "Stemming",
"unitHours": "u",
"unitSteps": "stappen",
"unitBpm": "bpm",
"unitMs": "ms",
"unitMood": "pnt"
"metricMood": "Stemming"
},
"title": "Cyclusinzichten",
"symptomPatternsTitle": "Symptoompatronen per fase",
+11 -26
View File
@@ -4221,13 +4221,8 @@
"metricNextDayRecovery": "Regeneracja następnego dnia",
"metricNextDayRestingHr": "Tętno spoczynkowe następnego dnia",
"metricNextDayHrv": "Zmienność rytmu serca następnego dnia",
"unitHours": "godz.",
"unitKcal": "kcal",
"unitScore": "pkt",
"unitBpm": "bpm",
"unitMs": "ms",
"detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.",
"detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni."
"detailSameDay": "Średnio {withAvg} w {withDays} dniach z tym tagiem wobec {withoutAvg} bez niego.",
"detailNextDay": "Średnio {withAvg} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} w pozostałe dni."
},
"factorCrosstab": {
"title": "Twoje ocenione czynniki i parametry życiowe",
@@ -4239,15 +4234,8 @@
"metricHrv": "Zmienność rytmu serca",
"metricWeight": "Waga",
"metricBloodPressureSys": "Skurczowe",
"unitHours": "godz.",
"unitScore": "pkt",
"unitSteps": "kroki",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "Średnio {lowAvg} {unit} w {lowDays} dniach z niską oceną {factor} wobec {highAvg} {unit} w dniach z wysoką oceną.",
"detailInverse": "Średnio {lowAvg} {unit} w Twoich {lowDays} gorszych dniach {factor} wobec {highAvg} {unit} w lepszych."
"detail": "Średnio {lowAvg} w {lowDays} dniach z niską oceną {factor} wobec {highAvg} w dniach z wysoką oceną.",
"detailInverse": "Średnio {lowAvg} w Twoich {lowDays} gorszych dniach {factor} wobec {highAvg} w lepszych."
},
"discovery": {
"title": "Co się wyróżnia",
@@ -7748,6 +7736,7 @@
"tokensEmptyTitle": "Brak tokenów API",
"tokensEmptyDescription": "Tokeny są wydawane, gdy klient natywny się loguje, lub przez Ustawienia → Konto.",
"loginOverviewDescription": "Zdarzenia logowania i uwierzytelniania wszystkich kont, z filtrowaniem i eksportem CSV.",
"loginOverviewFilterLabel": "Filtruj zdarzenia",
"allAuthEvents": "Wszystkie zdarzenia uwierzytelniania",
"failedOnly": "Tylko nieudane",
"loginEmptyDescription": "Tutaj pojawią się logowania, wylogowania i zmiany kluczy passkey.",
@@ -7876,6 +7865,7 @@
"admin": "Administratorzy",
"user": "Użytkownicy"
},
"filterLabel": "Filtruj użytkowników",
"forceLogout": "Wymuś wylogowanie",
"forceLogoutConfirmTitle": "Wymusić wylogowanie {name}?",
"forceLogoutConfirmBody": "Wszystkie aktywne sesje tego użytkownika zostaną unieważnione. Będzie musiał zalogować się ponownie na każdym urządzeniu.",
@@ -9158,9 +9148,9 @@
"insights": {
"headline": {
"title": "Twój cykl i Twoje ciało",
"restingHeartRate": "Twoje tętno spoczynkowe jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.",
"heartRateVariability": "Twoja zmienność rytmu serca jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.",
"generic": "Twoje {metric} jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.",
"restingHeartRate": "Twoje tętno spoczynkowe jest o około {delta} {dir} w fazie lutealnej niż w fazie folikularnej.",
"heartRateVariability": "Twoja zmienność rytmu serca jest o około {delta} {dir} w fazie lutealnej niż w fazie folikularnej.",
"generic": "Twoje {metric} jest o około {delta} {dir} w fazie lutealnej niż w fazie folikularnej.",
"dirHigher": "wyższe",
"dirLower": "niższe",
"empty": "Za mało cykli i pomiarów, aby pokazać wzorzec fazy. Rejestruj dalej, a uzupełni się to wraz z historią."
@@ -9168,7 +9158,7 @@
"crosstab": {
"description": "Jak każdy pomiar wypada między fazą lutealną a folikularną, na Twoich danych.",
"pairLabel": "{metric}: faza lutealna vs folikularna",
"detail": "Lutealna {lutealAvg} {unit} ({lutealDays} dni) vs folikularna {follicularAvg} {unit} ({follicularDays} dni).",
"detail": "Lutealna {lutealAvg} ({lutealDays} dni) vs folikularna {follicularAvg} ({follicularDays} dni).",
"metricRestingHeartRate": "Tętno spoczynkowe",
"metricHrv": "Zmienność rytmu serca",
"metricSleepDuration": "Czas snu",
@@ -9178,12 +9168,7 @@
"metricWristTemperature": "Nocna temperatura nadgarstka",
"metricSkinTemperature": "Temperatura skóry",
"metricBloodGlucose": "Glukoza we krwi",
"metricMood": "Nastrój",
"unitHours": "godz.",
"unitSteps": "kroki",
"unitBpm": "ud./min",
"unitMs": "ms",
"unitMood": "pkt"
"metricMood": "Nastrój"
},
"title": "Analizy cyklu",
"symptomPatternsTitle": "Wzorce objawów wg fazy",
@@ -20,13 +20,19 @@
--
-- Lock wait. `CREATE INDEX` takes SHARE on its table (reads go on, writes
-- wait while it builds; every table here is small, so the build is quick)
-- and `DROP INDEX` takes ACCESS EXCLUSIVE for the instant the catalog
-- changes. `CREATE INDEX CONCURRENTLY` cannot share a migration file with
-- any other statement (0416), so the whole set runs in one `DO` block: one
-- statement that commits or rolls back whole, with the lock wait bounded at
-- five seconds so a long reader cannot queue the tables' writes behind the
-- migration. Every statement is `IF [NOT] EXISTS`, so a lock timeout leaves
-- the file safe to apply again, which the marker declares
-- and `DROP INDEX` takes ACCESS EXCLUSIVE. `CREATE INDEX CONCURRENTLY`
-- cannot share a migration file with any other statement, so the whole set
-- runs in one `DO` block: one statement that commits or rolls back whole,
-- with each lock wait bounded at five seconds so a long reader cannot queue
-- the tables' writes behind the migration. Inside the block every lock is
-- held until it commits, not just while its statement runs: the SHARE locks
-- of the builds stay through the drops, and the drops come last so their
-- ACCESS EXCLUSIVE locks are held only for the rest of the block. An app
-- transaction that takes two of these tables in the other order can
-- therefore deadlock with it; Postgres cancels one side, and when that is the
-- migration its block has rolled back whole, as on a lock timeout. Every
-- statement is `IF [NOT] EXISTS`, so either failure leaves the file safe to
-- apply again, which the marker declares and the boot acts on for both
-- (docs/ops/deploy.md, "A migration that timed out on a lock").
--
-- @retry-on-lock-timeout
@@ -1,4 +1,4 @@
-- Drop `measurements_user_id_type_measured_at_idx`.
-- Retire `measurements_user_id_type_measured_at_idx`.
--
-- `(user_id, type, measured_at)` is the leading part of the unique key
-- `(user_id, type, measured_at, source, sleep_stage)`, and live reads have
@@ -8,10 +8,28 @@
-- seeded table). The index cost every write to the densest table in the
-- schema and, on a bloated instance, more space than the table itself.
--
-- `DROP INDEX CONCURRENTLY` takes no lock that blocks reads or writes: it
-- waits for the transactions already using the index to finish and then
-- removes it. It refuses to run inside a transaction block, and under
-- `migrate deploy` it does so whenever the file holds any other statement
-- (Prisma 7 on Postgres 16 reports 25001), so this file holds exactly this
-- one statement. `IF EXISTS` makes a re-run a no-op.
DROP INDEX CONCURRENTLY IF EXISTS "measurements_user_id_type_measured_at_idx";
-- This runs at boot, so it must not wait on the table. A plain `DROP INDEX`
-- needs ACCESS EXCLUSIVE on `measurements`, held for the instant the catalog
-- changes; `DROP INDEX CONCURRENTLY` blocks nobody but waits, without bound,
-- for every transaction on the table (a pg_dump, a backfill), long enough for
-- the container to be replaced mid-migration and the next boot to stop at
-- P3009. So the drop is tried once with a two-second lock wait (the most the
-- table's other statements can queue behind the request), and a timeout is
-- swallowed: the migration then succeeds with the index still there, and
-- the nightly measurement maintenance drops it with
-- `DROP INDEX CONCURRENTLY IF EXISTS` (`RETIRED_MEASUREMENT_INDEXES` in
-- `src/lib/jobs/measurement-maintenance.ts`). A fresh database and Prisma's
-- shadow database take the lock at once, so the migration history ends
-- without the index, as the schema does. The pattern, for the next large
-- index: docs/ops/measurement-maintenance.md, "Retired indexes".
--
-- @retry-on-lock-timeout
DO $migration$
BEGIN
PERFORM set_config('lock_timeout', '2s', true);
DROP INDEX IF EXISTS "measurements_user_id_type_measured_at_idx";
EXCEPTION WHEN lock_not_available THEN
RAISE NOTICE 'measurements_user_id_type_measured_at_idx is in use; the nightly measurement maintenance drops it';
END
$migration$;
+58 -2
View File
@@ -47,6 +47,11 @@ const P3018_UNIQUE = P3018_LOCK.replace("55P03", "23505").replace(
'duplicate key value violates unique constraint "x"',
);
const P3018_DEADLOCK = P3018_LOCK.replace("55P03", "40P01").replace(
"canceling statement due to lock timeout",
"deadlock detected",
);
const P3009 = `Error: P3009
migrate found failed migrations in the target database, new migrations will not be applied. Read more about how to resolve migration issues in a production database: https://pris.ly/d/migrate-resolve
@@ -78,7 +83,11 @@ describe("which failure is retried", () => {
failedRows: null,
readMigration: reader(MARKED),
}),
).toEqual({ retry: true, migration: "0412_measurement_hot_updates" });
).toEqual({
retry: true,
migration: "0412_measurement_hot_updates",
code: "55P03",
});
});
it("does not retry a lock timeout of an unmarked migration", () => {
@@ -93,6 +102,31 @@ describe("which failure is retried", () => {
});
});
it("retries a deadlock of a marked migration, which rolled back whole", () => {
expect(lockTimeoutInReport(P3018_DEADLOCK)).toBe(
"0412_measurement_hot_updates",
);
expect(
decideLockRetry({
output: P3018_DEADLOCK,
failedRows: null,
readMigration: reader(MARKED),
}),
).toEqual({
retry: true,
migration: "0412_measurement_hot_updates",
code: "40P01",
});
// Unmarked, a deadlock stays fatal like a lock timeout.
expect(
decideLockRetry({
output: P3018_DEADLOCK,
failedRows: null,
readMigration: reader(UNMARKED),
}).retry,
).toBe(false);
});
it("does not retry any other database error", () => {
expect(
decideLockRetry({
@@ -123,7 +157,11 @@ describe("which failure is retried", () => {
failedRows,
readMigration: reader(MARKED),
}),
).toEqual({ retry: true, migration: "0412_measurement_hot_updates" });
).toEqual({
retry: true,
migration: "0412_measurement_hot_updates",
code: "55P03",
});
// A stored failure of another kind stays fatal.
expect(
decideLockRetry({
@@ -295,6 +333,24 @@ process.exit(0);
expect(result.stdout).toContain("timed out waiting for a lock (55P03)");
});
it("does the same for a deadlock, and names it", () => {
const { run, calls } = setup(
[
[1, P3018_DEADLOCK],
[0, "All migrations have been successfully applied."],
],
MARKED,
);
const result = run();
expect(result.status).toBe(0);
expect(calls()).toEqual([
"migrate deploy --schema x",
"migrate resolve --rolled-back 0412_measurement_hot_updates --schema x",
"migrate deploy --schema x",
]);
expect(result.stdout).toContain("was cancelled in a deadlock (40P01)");
});
it("gives up after the last delay with Prisma's exit code", () => {
const { run, calls } = setup(
[
+32 -13
View File
@@ -21,9 +21,15 @@
* back whole. `src/__tests__/migration-lock-timeout-guard.test.ts` holds the
* migrations to that.
*
* So: on a 55P03 failure of a migration carrying the marker, mark that one
* migration rolled back and run `migrate deploy` again after a backoff (15,
* 30, 60, 120, 240 seconds). Any other failure, a lock timeout of a migration
* A deadlock (40P01) is the same failure seen from the other side: a marked
* migration's `DO` block holds the locks it took until it commits, and an app
* transaction that took two of the same tables in the other order makes
* Postgres cancel one of the two. When the migration is the one cancelled,
* its block rolled back whole, exactly as on a lock timeout.
*
* So: on a 55P03 or 40P01 failure of a migration carrying the marker, mark
* that one migration rolled back and run `migrate deploy` again after a
* backoff (15, 30, 60, 120, 240 seconds). Any other failure, a lock timeout of a migration
* without the marker, or a fifth lock timeout in a row, ends the boot with
* Prisma's exit code exactly as before. The failure is read from Prisma's
* own report (`Migration name:` and `Database error code:`), or, on a boot
@@ -58,20 +64,27 @@ const DEFAULT_RETRY_DELAYS_S = [15, 30, 60, 120, 240];
/** The line a migration carries when a lock-timeout retry is safe for it. */
export const RETRY_MARKER = /^--\s*@retry-on-lock-timeout\s*$/m;
const LOCK_TIMEOUT = "55P03";
/** Lock timeout and deadlock: both leave a marked migration unapplied. */
const LOCK_FAILURES = new Set(["55P03", "40P01"]);
const TIME_ZONE_OPTION = "-c TimeZone=UTC";
const MIGRATION_NAME = /^[A-Za-z0-9_]+$/;
/** The SQLSTATE a P3018 report gives, or null. */
export function errorCodeInReport(text) {
if (typeof text !== "string") return null;
return /^Database error code: (\S+)\s*$/m.exec(text)?.[1] ?? null;
}
/**
* The migration a P3018 report says failed on a lock timeout, or null.
* Prisma prints `Migration name: <name>` and `Database error code: <state>`
* for a migration that failed to apply.
* The migration a P3018 report says failed on a lock (a lock timeout or a
* deadlock), or null. Prisma prints `Migration name: <name>` and
* `Database error code: <state>` for a migration that failed to apply.
*/
export function lockTimeoutInReport(text) {
if (typeof text !== "string") return null;
const name = /^Migration name: (\S+)\s*$/m.exec(text)?.[1];
const code = /^Database error code: (\S+)\s*$/m.exec(text)?.[1];
return name && code === LOCK_TIMEOUT ? name : null;
const code = errorCodeInReport(text);
return name && code !== null && LOCK_FAILURES.has(code) ? name : null;
}
/** Whether Prisma refused to start because a failed migration is on record. */
@@ -89,14 +102,16 @@ function isFailedMigrationOnRecord(output) {
* when they were not read
* @param {(name: string) => string | null} input.readMigration
* the migration's SQL, or null when the image does not ship it
* @returns {{ retry: true, migration: string } | { retry: false, reason: string }}
* @returns {{ retry: true, migration: string, code: string } | { retry: false, reason: string }}
*/
export function decideLockRetry({ output, failedRows, readMigration }) {
let migration = lockTimeoutInReport(output);
let code = migration ? errorCodeInReport(output) : null;
if (!migration && isFailedMigrationOnRecord(output) && failedRows) {
// More than one failed row is not a state this boot can reason about.
if (failedRows.length === 1) {
migration = lockTimeoutInReport(failedRows[0].logs);
code = migration ? errorCodeInReport(failedRows[0].logs) : null;
}
}
if (!migration) {
@@ -115,7 +130,7 @@ export function decideLockRetry({ output, failedRows, readMigration }) {
reason: `${migration} timed out on a lock but is not marked safe to re-run`,
};
}
return { retry: true, migration };
return { retry: true, migration, code };
}
/** The backoff schedule, from the environment or the default. */
@@ -272,9 +287,13 @@ async function main({
return deploy.code;
}
const delay = delays[attempt];
const cause =
decision.code === "40P01"
? "was cancelled in a deadlock (40P01)"
: "timed out waiting for a lock (55P03)";
console.log(
`HealthLog: migration ${decision.migration} timed out waiting for a lock ` +
`(55P03) and changed nothing; marking it rolled back and retrying in ` +
`HealthLog: migration ${decision.migration} ${cause} ` +
`and changed nothing; marking it rolled back and retrying in ` +
`${delay}s (retry ${attempt + 1} of ${delays.length}).`,
);
const resolve = await runPrisma(cli, [
+41 -23
View File
@@ -5,15 +5,17 @@
* Seventy-five surfaces said "nothing here yet" in a hand-written muted
* paragraph, in five sizes and three alignments, some centred in a padded
* box, some a bare line under the header. This is a ratchet, not a ban: the
* hand-written copies that remain are counted, the ceiling below may only go
* down, and a new copy fails the run. The ones that stay are not list or
* section empty states but a field that has no value ("not recorded" in a
* profile row), where a centred block would be the wrong shape.
* hand-written copies that remain are listed by file and key, a new one
* fails the run, and a site that moves over leaves the list. The ones that
* stay are not list or section empty states but a field that has no value
* ("not recorded" in a profile row), where a centred block would be the
* wrong shape. A count would let a new copy in wherever an old one left.
*
* Matched: a `<p>`, `<div>` or `<span>` whose class list carries
* `text-muted-foreground` and whose only child is `{t("<key>")}` with a last
* key segment that names an empty state (`empty`, `none`, `noData`,
* `noResults`, `notRecorded`, …; a `…Hint` key is a hint, not a state).
* `noResults`, `notRecorded`, `no…Yet`, `nothing…`; a `…Hint` key is a
* hint, not a state).
*
* Not matched (stated, not hidden): a message assembled from several nodes,
* a key built at runtime, a state without the muted colour. The guard holds
@@ -28,15 +30,31 @@ import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(__dirname, "..");
/**
* The hand-written empty states that remain. Lower it when a site moves to
* `EmptyState`; never raise it.
* The hand-written empty states that remain, each a field-level line rather
* than a list or section state. Remove an entry when its site moves to
* `EmptyState`; never add one.
*/
const CEILING = 13;
const REMAINING = [
"components/cycle/cycle-phase-crosstab.tsx: cycle.insights.headline.empty",
"components/documents/episode-documents-card.tsx: documents.episodeCard.emptyLine",
"components/illness/episode-visits-card.tsx: encounters.episodeCard.emptyLine",
"components/insights/workout-detail/day-context.tsx: insights.workouts.detail.dayMoodNone",
"components/insights/workout-detail/day-context.tsx: insights.workouts.detail.daySleepNone",
"components/labs/biomarker-aliases.tsx: labs.aliases.empty",
"components/medications/dose-history-ledger.tsx: medications.detail.verlauf.summaryEmpty",
"components/medications/scheduling/dose-window-editor.tsx: medications.detail.zeitplan.window.noTimes",
"components/medications/sections/composition-section.tsx: medications.composition.editorEmpty",
"components/records/screening-hints-card.tsx: records.life.screening.noReminder",
"components/settings/ai/fallback-chain-card.tsx: settings.ai.providerChain.addNoneAvailable",
"components/settings/integrations/apple-health-card.tsx: settings.appleHealth.delivery.noSyncYet",
"components/settings/report-selection/scope-summary.tsx: reportSelection.scopeNone",
"components/timeline/selection-bar.tsx: timeline.selection.empty",
];
const ELEMENT_RE =
/<(p|div|span)\b([^>]*?)>\s*\{t\(\s*"([a-zA-Z0-9_.]+)"[^)]*\)\}\s*<\/\1>/g;
const EMPTY_KEY_RE =
/(?:^|\.)[A-Za-z0-9]*?(?:empty|Empty|none|None|noData|NoData|noResults|NoResults|notRecorded|NotRecorded|noSeries|NoSeries|noReminder|NoReminder|noMedications|noSymptoms|noTimes)[A-Za-z0-9]*$/;
/(?:^|\.)[A-Za-z0-9]*?(?:empty|Empty|none|None|noData|NoData|noResults|NoResults|notRecorded|NotRecorded|noSeries|NoSeries|noReminder|NoReminder|noMedications|noSymptoms|noTimes|nothing|Nothing)[A-Za-z0-9]*$|(?:^|\.)no[A-Z][A-Za-z0-9]*Yet$/;
function findHandWrittenEmptyStates(
files: { path: string; source: string }[],
@@ -68,20 +86,12 @@ describe("empty states go through EmptyState (ratchet)", () => {
expect(files.length).toBeGreaterThan(800);
});
it("adds no hand-written empty state", () => {
const hits = findHandWrittenEmptyStates(files);
it("adds no hand-written empty state, and lists none that moved over", () => {
const hits = findHandWrittenEmptyStates(files).sort();
expect(
hits.length,
`${hits.length} hand-written empty states, ceiling ${CEILING}. Render the new one through <EmptyState variant="plain" size="compact"> (or ChartEmptyState on a chart body).\n${hits.join("\n")}`,
).toBeLessThanOrEqual(CEILING);
});
it("has its ceiling lowered when a site moves over", () => {
const hits = findHandWrittenEmptyStates(files);
expect(
hits.length,
`Only ${hits.length} hand-written empty states remain: lower CEILING to ${hits.length}.`,
).toBe(CEILING);
hits,
'A new hand-written empty state: render it through <EmptyState variant="plain" size="compact"> (or ChartEmptyState on a chart body). A site that moved over: remove its entry.',
).toEqual([...REMAINING].sort());
});
it("recognises the idiom it exists to count", () => {
@@ -101,7 +111,15 @@ describe("empty states go through EmptyState (ratchet)", () => {
path: "components/c.tsx",
source: '<EmptyState title={t("labs.detail.emptyTitle")} />',
},
{
path: "components/d.tsx",
source:
'<p className="text-muted-foreground text-sm">{t("insights.noAnalysisYet")}</p>',
},
]),
).toEqual(["components/a.tsx: admin.aiQuality.empty"]);
).toEqual([
"components/a.tsx: admin.aiQuality.empty",
"components/d.tsx: insights.noAnalysisYet",
]);
});
});
@@ -0,0 +1,24 @@
/**
* A `useUnitDisplay` result for the metric preference, for static renders
* that have no QueryClient (the real hook hangs off `useAuth`). Metric is the
* identity on values and yields the canonical display symbols.
*/
import * as dt from "@/lib/measurements/display-transform";
import type { UnitDisplay } from "@/hooks/use-unit-display";
export function metricUnitDisplay(
glucoseUnit: UnitDisplay["glucoseUnit"] = "mg/dL",
): UnitDisplay {
const forType = (t: string) => dt.getDisplayTransform(t, "metric");
return {
preference: "metric",
glucoseUnit,
transformFor: forType,
toDisplay: (t, v) => dt.applyDisplayTransform(v, forType(t)),
fromDisplay: (t, v) => dt.invertDisplayTransform(v, forType(t)),
toDisplayDelta: (t, v) => dt.applyDisplayTransformDelta(v, forType(t)),
unitFor: (t) => forType(t).displayUnit,
decimalsFor: (t) => forType(t).decimals,
isTransformed: (t) => dt.hasDisplayTransform(t),
};
}
+44 -4
View File
@@ -41,6 +41,20 @@ export interface DeclaredField {
physicalName: string;
}
/** The owning side of a relation: the field that carries `@relation(fields:)`. */
export interface DeclaredRelation {
/** The relation field's name. */
name: string;
/** The model it points at. */
target: string;
/** Whether the relation field is optional (`Type?`). */
optional: boolean;
/** The scalar fields holding the key, by their datamodel names. */
fields: string[];
/** The declared `onDelete`, or null when Prisma's default applies. */
onDelete: string | null;
}
export interface DeclaredModel {
name: string;
mappedName: string | null;
@@ -48,6 +62,8 @@ export interface DeclaredModel {
physicalName: string;
/** Scalar + enum fields only — relation fields carry no column. */
fields: DeclaredField[];
/** Relations this model owns the key of. */
relations: DeclaredRelation[];
}
export interface DeclaredSchema {
@@ -58,7 +74,9 @@ export interface DeclaredSchema {
const BLOCK_MAP = /^\s*@@map\("([^"]+)"\)/m;
const FIELD_MAP = /@map\("([^"]+)"\)/;
/** `name Type[]? @attr…` — the two leading tokens are what matters. */
const FIELD_LINE = /^\s*(\w+)\s+(\w+)(?:\[\])?\??\s*(.*)$/;
const FIELD_LINE = /^\s*(\w+)\s+(\w+)(?:\[\])?(\??)\s*(.*)$/;
const RELATION_FIELDS = /@relation\([^)]*\bfields:\s*\[([^\]]*)\]/;
const ON_DELETE = /@relation\([^)]*\bonDelete:\s*(\w+)/;
/** Drop `//` and `///` lines so prose can never read as a declaration. */
function stripComments(block: string): string {
@@ -112,15 +130,31 @@ export function readDeclaredSchema(
const models = rawModels.map(({ name, body }) => {
const mappedName = BLOCK_MAP.exec(body)?.[1] ?? null;
const fields: DeclaredField[] = [];
const relations: DeclaredRelation[] = [];
for (const line of body.split("\n")) {
if (/^\s*@@/.test(line)) continue;
const match = FIELD_LINE.exec(line);
if (!match) continue;
const [, fieldName, baseType, rest] = match;
const [, fieldName, baseType, optional, rest] = match;
// A field whose type is another model is a relation, not a column. The
// back-reference side carries no `@relation` attribute at all, so the
// type is what decides it — keying on `@relation` would miss half.
if (modelNames.has(baseType)) continue;
if (modelNames.has(baseType)) {
const keys = RELATION_FIELDS.exec(rest)?.[1];
if (keys !== undefined) {
relations.push({
name: fieldName,
target: baseType,
optional: optional === "?",
fields: keys
.split(",")
.map((key) => key.trim())
.filter(Boolean),
onDelete: ON_DELETE.exec(rest)?.[1] ?? null,
});
}
continue;
}
const fieldMapped = FIELD_MAP.exec(rest)?.[1] ?? null;
fields.push({
name: fieldName,
@@ -128,7 +162,13 @@ export function readDeclaredSchema(
physicalName: fieldMapped ?? fieldName,
});
}
return { name, mappedName, physicalName: mappedName ?? name, fields };
return {
name,
mappedName,
physicalName: mappedName ?? name,
fields,
relations,
};
});
return { enums, models };
@@ -23,6 +23,8 @@ import { readdirSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { RETIRED_MEASUREMENT_INDEXES } from "@/lib/jobs/measurement-maintenance";
import { RETRY_MARKER } from "../../scripts/migrate-deploy.mjs";
const MIGRATIONS_DIR = join(process.cwd(), "prisma", "migrations");
@@ -114,13 +116,14 @@ describe("concurrent index statements", () => {
expect(found, found.join("\n")).toEqual([]);
});
it("sees the concurrent migrations there are", () => {
const concurrent = migrations()
.filter(({ sql }) =>
/\bCONCURRENTLY\b/i.test(topLevelStatements(sql).join(";")),
)
.map(({ name }) => name);
expect(concurrent).toContain("0416_drop_measurements_type_prefix_index");
it("reads the statements there are", () => {
// No migration holds a concurrent statement since 0416 stopped dropping
// concurrently; the rule stands for the next one. A reader that stopped
// splitting statements would see none and pass, so it must see many.
const statements = migrations().flatMap(({ sql }) =>
topLevelStatements(sql),
);
expect(statements.length).toBeGreaterThan(1000);
});
it("refuses a concurrent statement beside another one", () => {
@@ -139,6 +142,58 @@ describe("concurrent index statements", () => {
});
});
/**
* `DROP INDEX CONCURRENTLY` blocks no reader or writer, but it waits without
* bound for every transaction on the table, and in a migration that wait is
* the boot's: a pg_dump or a backfill outlasts the container's start period,
* the half-applied migration is left on record, and the next boot stops at
* P3009. An index is retired instead by a bounded `DROP INDEX` that tolerates
* the timeout, finished by the nightly measurement maintenance (0416).
*/
function concurrentDropViolations(name: string, sql: string): string[] {
return topLevelStatements(sql).some((statement) =>
/^DROP INDEX CONCURRENTLY\b/i.test(statement),
)
? [`${name}: DROP INDEX CONCURRENTLY waits on the table at boot`]
: [];
}
describe("retired indexes", () => {
it("are never dropped concurrently in the boot path", () => {
const found = migrations().flatMap(({ name, sql }) =>
concurrentDropViolations(name, sql),
);
expect(found, found.join("\n")).toEqual([]);
});
it("are each dropped by a migration that bounds its wait and tolerates the timeout", () => {
expect(RETIRED_MEASUREMENT_INDEXES.length).toBeGreaterThan(0);
for (const index of RETIRED_MEASUREMENT_INDEXES) {
const owner = migrations().find(({ sql }) =>
new RegExp(`DROP INDEX IF EXISTS "${index}"`).test(sql),
);
expect(owner, index).toBeDefined();
expect(owner?.sql).toMatch(/set_config\(\s*'lock_timeout'/);
expect(owner?.sql).toMatch(/EXCEPTION WHEN lock_not_available THEN/);
}
});
it("counter-check: refuses a concurrent drop, not one in a comment", () => {
expect(
concurrentDropViolations(
"a",
'DROP INDEX CONCURRENTLY IF EXISTS "measurements_x_idx";',
),
).toHaveLength(1);
expect(
concurrentDropViolations(
"b",
'-- DROP INDEX CONCURRENTLY "x";\nDROP INDEX IF EXISTS "x";',
),
).toEqual([]);
});
});
describe("migration lock waits", () => {
it("bounds lock waits where they take effect and marks only repeatable files", () => {
const found = migrations().flatMap(({ name, sql }) =>
@@ -154,6 +209,7 @@ describe("migration lock waits", () => {
expect(marked).toContain("0412_measurement_hot_updates");
expect(marked).toContain("0414_drop_medication_compliance_rollups");
expect(marked).toContain("0415_foreign_key_and_prefix_indexes");
expect(marked).toContain("0416_drop_measurements_type_prefix_index");
});
it("catches each shape it exists to refuse", () => {
+14 -15
View File
@@ -10,15 +10,15 @@
* keeps a new `<h1>` literal from appearing outside the places where a
* different heading is the point.
*
* Matched: an `<h1` tag in JSX under `src/components` and `src/app`. A
* mention in a comment counts too, which is why the files that only talk
* about an H1 are listed with that reason.
* Matched: an `<h1` tag in JSX under `src/components` and `src/app`, with
* comments stripped first, so a file that only talks about an H1 needs no
* entry and every entry below is a heading that is actually rendered.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import { stripComments, walkSourceFiles } from "./helpers/source-files";
const SRC = join(__dirname, "..");
@@ -48,14 +48,6 @@ const ALLOWED: Record<string, string> = {
// Prose pages (the prose H1 tier, §5).
"app/about/page.tsx": "prose page",
"app/privacy/page.tsx": "prose page",
// Mention an H1 in a comment only.
"app/insights/ecg/page.tsx": "comment",
"components/admin/reminders-section.tsx": "comment",
"components/charts/health-chart.tsx": "comment",
"components/insights/coach-panel/coach-top-bar-trail.tsx": "comment",
"components/insights/ecg-section.tsx": "comment",
"components/measurement-reminders/vorsorge-section.tsx": "comment",
"components/settings/settings-shell.tsx": "comment",
};
describe("page titles go through PageHeader", () => {
@@ -65,9 +57,8 @@ describe("page titles go through PageHeader", () => {
(p.startsWith("components/") || p.startsWith("app/")) &&
!p.includes("__tests__/"),
);
const withH1 = files.filter((p) =>
readFileSync(join(SRC, p), "utf8").includes("<h1"),
);
const hasH1 = (source: string) => /<h1[\s>]/.test(stripComments(source));
const withH1 = files.filter((p) => hasH1(readFileSync(join(SRC, p), "utf8")));
it("finds the H1 sites at all", () => {
expect(withH1.length).toBeGreaterThan(10);
@@ -77,6 +68,14 @@ describe("page titles go through PageHeader", () => {
expect(withH1.filter((p) => !(p in ALLOWED))).toEqual([]);
});
it("counter-check: an H1 in code counts, one in a comment does not", () => {
expect(hasH1(`return <h1 className="text-2xl">Title</h1>;`)).toBe(true);
expect(hasH1(`// the page's <h1> lives in PageHeader\nreturn null;`)).toBe(
false,
);
expect(hasH1(`/* an <h1> above */ return <h2>x</h2>;`)).toBe(false);
});
it("keeps every allowlist entry live", () => {
const set = new Set(withH1);
for (const key of Object.keys(ALLOWED))
+11 -3
View File
@@ -2,8 +2,10 @@
* `readOverPost` lets a delegable route answer a POST at the grant's read
* level. That widens what a read-only grant reaches, so the set of routes
* declaring it is frozen here: adding one is a reviewed diff, not an
* accident. Today it is the record search alone, whose query is health data
* that must stay out of URLs.
* accident. Today it is the record search, whose query is health data that
* must stay out of URLs, and the document assist, whose POST writes nothing
* (under `requireAuth` the declaration only keeps it off the shared write
* ceiling).
*
* The matcher tolerates whitespace and line breaks, and the guard asserts it
* matched something, so a rewrite that hides the declaration from the regex
@@ -16,7 +18,13 @@ import { join, relative, sep } from "node:path";
const ROOT = process.cwd();
const FROZEN = ["src/app/api/search/route.ts"];
const FROZEN = [
// The draft-only document assist: a POST that writes nothing. Under
// `requireAuth` the declaration only keeps it out of the shared write
// ceiling; there is no grant for it to widen.
"src/app/api/documents/inbound/[id]/suggest/route.ts",
"src/app/api/search/route.ts",
];
describe("readOverPost declarations", () => {
it("are exactly the frozen set", () => {
@@ -328,8 +328,14 @@ const UNIT_LITERAL_ALLOWLIST: Record<
reason: "a display-kind tag; the weight row converts through the hook",
},
"components/insights/mood/mood-factor-metric-crosstab.tsx": {
lines: 2,
reason: "a display-kind tag; the weight row converts through the hook",
lines: 1,
reason:
"a display-kind tag; the weight row converts through crosstabReading",
},
"components/insights/metric-crosstab.tsx": {
lines: 3,
reason:
"the boards' display kinds and the storage token each is in; rows convert through getReadingTransform and label through measurementUnitLabel",
},
// ── Canonical by contract ──
"lib/insights/metric-status-registry.ts": {
@@ -75,6 +75,12 @@ const OWN_BUCKET: Record<string, string> = {
"The cycle HealthKit sweep sends 500-day slices in sequence; the route's own bulk bucket meters it.",
"app/api/nutrients/batch/route.ts":
"The nutrient sync sends 500-row batches in sequence; the route's own batch bucket meters it.",
"app/api/insights/chat/route.ts":
"A Coach turn is metered by the Coach's own buckets: 20 turns a minute (`coach-chat`) and one turn slot at a time.",
"app/api/insights/chat/fenced/route.ts":
"A fenced document chat turn is metered by its own per-account bucket, sized for model turns.",
"app/api/documents/inbound/[id]/chat/route.ts":
"A document chat turn is metered by its own per-account bucket, sized for model turns.",
"app/api/insights/ecg/route.ts":
"The ECG sweep sends one recording per request with no client-side delay, newest first, over the whole HealthKit history; the route's own 600-a-minute ingest bucket meters it.",
};
@@ -71,7 +71,9 @@ type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// Metered by the document chat's own bucket below, not the shared
// ceiling.
const { user } = await requireAuth(undefined, { writeBucket: "own" });
const userId = user.id;
const gate = await requireModuleEnabled(userId, "inboundDocuments");
@@ -73,7 +73,9 @@ type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// Writes nothing (the draft goes back to the edit form), so the shared
// write ceiling does not count it; the document-AI bucket meters it.
const { user } = await requireAuth(undefined, { readOverPost: true });
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
+2 -1
View File
@@ -52,7 +52,8 @@ export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Metered by the fenced chat's own bucket below, not the shared ceiling.
const { user } = await requireAuth(undefined, { writeBucket: "own" });
const userId = user.id;
const gate = await requireModuleEnabled(userId, "inboundDocuments");
+3 -1
View File
@@ -81,7 +81,9 @@ const COACH_CONCURRENT_TURNS = 2;
const COACH_TURN_SLOT_MS = TURN_LIMITS.user.wallMs + 30_000;
async function handleChatRequest(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// A turn is metered by the Coach's own buckets (`coach-chat`, the turn
// slot), not the shared write ceiling.
const auth = await requireAuth(undefined, { writeBucket: "own" });
// The `coach` capability, right after auth and before the rate limit and
// the body parse. It folds in what the Coach module gate, the operator
// switch and the `disableCoach` opt-out used to answer separately, plus
+4 -3
View File
@@ -7,9 +7,10 @@
* read by id and then checked against the resolved user, so a foreign id and a
* missing id give the same 404 and probing learns nothing.
*
* Deleting a practitioner keeps every visit that named it. The foreign key is
* SetNull, so the visits survive with no practice attached rather than
* disappearing with the address-book entry.
* Deleting a practitioner takes it out of the address book and the pickers,
* not out of the history: every live visit and dose that named it keeps
* showing it, and the nightly purge keeps the tombstone for as long as one
* does (`soft-delete-retention.ts`).
*/
import { NextRequest } from "next/server";
+12 -20
View File
@@ -16,6 +16,7 @@ import {
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import {
@@ -231,26 +232,17 @@ export function LoginOverviewSection() {
<div className="space-y-3">
{/* Quick-filter pills. Failed-only is the most common
admin "is something wrong" question — keep the one-tap shortcut. */}
<div className="flex gap-1">
<Button
variant={filter === "all" ? "default" : "ghost"}
aria-pressed={filter === "all"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={resetPageOnFilterChange(() => setFilter("all"))}
>
{t("admin.allAuthEvents")}
</Button>
<Button
variant={filter === "failed" ? "default" : "ghost"}
aria-pressed={filter === "failed"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={resetPageOnFilterChange(() => setFilter("failed"))}
>
{t("admin.failedOnly")}
</Button>
</div>
<Segmented
mode="toggle"
label={t("admin.loginOverviewFilterLabel")}
slot="admin-login-filter"
value={filter}
onChange={resetPageOnFilterChange(setFilter)}
options={[
{ value: "all", label: t("admin.allAuthEvents") },
{ value: "failed", label: t("admin.failedOnly") },
]}
/>
{/* Detailed filter row */}
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-[minmax(0,1.5fr)_repeat(3,minmax(0,1fr))]">
@@ -16,6 +16,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -312,20 +313,17 @@ export function UserManagementSection() {
className="flex flex-wrap items-center gap-1.5"
data-slot="admin-users-filter"
>
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
// Neutral pills: the selected filter is a state, not a call to
// action, so it never takes the primary fill.
variant={filter === value ? "secondary" : "outline"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs sm:min-h-9"
onClick={() => setFilter(value)}
aria-pressed={filter === value}
>
{t(`admin.section.users.filter.${value}`)}
</Button>
))}
<Segmented
mode="toggle"
label={t("admin.section.users.filterLabel")}
slot="admin-users-filter-choice"
value={filter}
onChange={setFilter}
options={(["all", "admin", "user"] as const).map((value) => ({
value,
label: t(`admin.section.users.filter.${value}`),
}))}
/>
</div>
{filteredUsers ? (
+13 -18
View File
@@ -1,6 +1,6 @@
"use client";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import type { ChartRangePoints } from "@/lib/dashboard-layout";
import { useTranslations } from "@/lib/i18n/context";
@@ -50,22 +50,17 @@ export function ChartRangeTabs({
}) {
const { t } = useTranslations();
return (
<>
{CHART_RANGE_TABS.map((r) => (
<Button
key={r.labelKey}
variant={value === r.days ? "default" : "ghost"}
aria-pressed={value === r.days}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => onChange(r.days)}
title={t(r.titleKey)}
data-slot="chart-range-tab"
data-range={r.days}
>
{t(r.labelKey)}
</Button>
))}
</>
<Segmented
mode="toggle"
label={t("charts.rangeLabel")}
optionSlot={() => "chart-range-tab"}
value={String(value)}
onChange={(next) => onChange(Number(next) as ChartRangePoints)}
options={CHART_RANGE_TABS.map((r) => ({
value: String(r.days),
label: t(r.labelKey),
title: t(r.titleKey),
}))}
/>
);
}
+18 -52
View File
@@ -15,7 +15,6 @@ import {
ReferenceLine,
} from "recharts";
import { useEffect, useState, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { TagChip } from "@/components/ui/tag-chip";
@@ -43,6 +42,7 @@ import {
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { ChartEmptyState } from "./chart-empty-state";
import { ChartRangeTabs } from "./chart-range-tabs";
import { ChartErrorState } from "./chart-error-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
@@ -127,32 +127,8 @@ interface MoodChartProps {
// --- Constants ---
// The range tabs select a CALENDAR-DAY window ending now, matching
// `health-chart.tsx` (see the note there). Until v1.37.29 this chart
// sliced the last N LOGGED DAYS out of the whole history instead — the
// same "7" tab meant seven calendar days on a weight chart and seven
// logged days (possibly weeks apart) on the mood chart one card over.
const TIME_RANGES_KEYS = [
{
labelKey: "charts.days7Label",
days: 7,
titleKey: "charts.days7Title",
},
{
labelKey: "charts.days30Label",
days: 30,
titleKey: "charts.days30Title",
},
{
labelKey: "charts.days90Label",
days: 90,
titleKey: "charts.days90Title",
},
{
labelKey: "charts.daysAllLabel",
days: 0,
titleKey: "charts.daysAllTitle",
},
] as const;
// `health-chart.tsx`, through the same `ChartRangeTabs` every chart drawn in
// days carries.
// MOOD_LABELS built dynamically via t() in the component
@@ -1126,31 +1102,21 @@ export function MoodChart({
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.days ? "default" : "ghost"}
aria-pressed={rangePoints === r.days}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => {
setRangeHydrated(true);
setRangePoints(r.days);
// v1.30.1 — persist the pick per chart, same
// model as the overlay toggles.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.days,
});
}
}}
title={t(r.titleKey)}
data-slot="chart-range-tab"
>
{t(r.labelKey)}
</Button>
))}
<ChartRangeTabs
value={rangePoints}
onChange={(days) => {
setRangeHydrated(true);
setRangePoints(days);
// v1.30.1 — persist the pick per chart, same
// model as the overlay toggles.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: days,
});
}
}}
/>
{/* v1.4.18 — overlay-controls dropdown next to the
range tabs. Only painted when the chart is bound to
a persistent chartKey; ad-hoc usages (/insights mood
@@ -86,7 +86,9 @@ describe("<CyclePhaseCrosstab>", () => {
);
expect(html).toContain("Mood");
expect(html).toContain("Blood glucose");
expect(html).toContain("pts");
// A mood rating has no unit word, as everywhere else a rating is shown.
expect(html).not.toContain("pts");
expect(html).toContain("Luteal 5.0 (12 days)");
expect(html).toContain("mg/dL");
// Never leaks the raw metric key or the raw i18n key on a covered row.
expect(html).not.toContain("cycle.insights.crosstab");
+43 -74
View File
@@ -2,13 +2,13 @@
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
} from "@/lib/measurements/display-transform";
import { MetricCrosstab } from "@/components/insights/metric-crosstab";
crosstabReading,
MetricCrosstab,
withUnit,
type CrosstabMeasure,
} from "@/components/insights/metric-crosstab";
/**
* v1.15 — CYCLE_PHASE × vital crosstab card.
@@ -57,18 +57,26 @@ const METRIC_LABEL_KEY: Record<string, string> = {
};
/**
* Bundle labels for the displays no preference changes. Weight, the
* temperatures and glucose take their symbol from the reader's transform
* (`DISPLAY_TO_TYPE` below) and have no bundle label to fall back on.
* The measurement each row reads (`src/lib/cycle/phase-crosstab.ts`), which
* is what its unit and its preference conversion come from. Mood has none.
*/
const UNIT_KEY: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
hours: "cycle.insights.crosstab.unitHours",
steps: "cycle.insights.crosstab.unitSteps",
bpm: "cycle.insights.crosstab.unitBpm",
ms: "cycle.insights.crosstab.unitMs",
mood: "cycle.insights.crosstab.unitMood",
const METRIC_TYPE: Record<string, string | null> = {
restingHeartRate: "RESTING_HEART_RATE",
heartRateVariability: "HEART_RATE_VARIABILITY",
sleepDuration: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
weight: "WEIGHT",
basalBodyTemp: "BODY_TEMPERATURE",
wristTemperature: "WRIST_TEMPERATURE",
skinTemperature: "SKIN_TEMPERATURE",
bloodGlucose: "BLOOD_GLUCOSE",
mood: null,
};
function measureOf(row: CyclePhaseCrosstabRow): CrosstabMeasure {
return { type: METRIC_TYPE[row.metricKey] ?? null, display: row.display };
}
/** Whole numbers for steps; one decimal otherwise. */
function fmt(
value: number,
@@ -78,57 +86,6 @@ function fmt(
return display === "steps" ? nf.integer(value) : nf.number(value, 1);
}
/**
* v1.32.26 — the two displays whose values follow the metric/imperial
* preference, mapped to the measurement type that carries the transform. Weight
* is offset-free (kg→lb); temperature is affine (°C→°F), so an ABSOLUTE phase
* average takes `toDisplay` while a phase DELTA takes `toDisplayDelta` (the
* offset must never leak into a difference).
*/
const DISPLAY_TO_TYPE: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
kg: "WEIGHT",
celsius: "BODY_TEMPERATURE",
glucose: "BLOOD_GLUCOSE",
};
/**
* Unit symbol and conversions for one row, from the reader's preferences.
* Glucose follows the glucose unit (it used to print the mg/dL label from
* the bundle beside raw mg/dL values for every reader); weight and
* temperature follow metric/imperial; every other display keeps its bundle
* label and its raw value.
*/
function rowUnits(
display: CyclePhaseCrosstabDisplay,
unitDisplay: UnitDisplay,
t: (key: string) => string,
): {
unit: string;
abs: (v: number) => number;
delta: (v: number) => number;
} {
const type = DISPLAY_TO_TYPE[display];
if (!type) {
// Defensive: an unmapped display must never pass undefined to `t`
// (whose resolver does `key.split(".")`). Empty unit on a miss.
const unitKey = UNIT_KEY[display];
return {
unit: unitKey ? t(unitKey) : "",
abs: (v) => v,
delta: (v) => v,
};
}
const transform = getReadingTransform(type, {
system: unitDisplay.preference,
glucoseUnit: unitDisplay.glucoseUnit,
});
return {
unit: transform.displayUnit,
abs: (v) => applyDisplayTransform(v, transform),
delta: (v) => applyDisplayTransformDelta(v, transform),
};
}
const HEADLINE_KEY: Record<string, string> = {
restingHeartRate: "cycle.insights.headline.restingHeartRate",
heartRateVariability: "cycle.insights.headline.heartRateVariability",
@@ -160,7 +117,11 @@ export function CyclePhaseHeadline({
const metricLabel = t(
METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey,
);
const { unit, delta: deltaConv } = rowUnits(headline.display, unitDisplay, t);
const { unit, delta: deltaConv } = crosstabReading(
measureOf(headline),
unitDisplay,
t,
);
const up = headline.delta >= 0;
const dir = t(
up
@@ -170,8 +131,10 @@ export function CyclePhaseHeadline({
const params = {
metric: metricLabel,
// A phase delta → factor-only conversion (no affine offset).
delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display, nf),
unit,
delta: withUnit(
fmt(Math.abs(deltaConv(headline.delta)), headline.display, nf),
unit,
),
dir,
};
const key =
@@ -202,11 +165,12 @@ export function CyclePhaseCrosstab({
description={t("cycle.insights.crosstab.description")}
rows={rows.map((row) => {
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
const measure = measureOf(row);
const {
unit,
abs: absConv,
delta: deltaConv,
} = rowUnits(row.display, unitDisplay, t);
} = crosstabReading(measure, unitDisplay, t);
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase.
return {
@@ -217,13 +181,18 @@ export function CyclePhaseCrosstab({
}),
up: row.delta >= 0,
deltaValue: fmt(deltaConv(row.delta), row.display, nf),
unit,
measure,
confidence: row.confidence,
detail: t("cycle.insights.crosstab.detail", {
metric: metricLabel,
lutealAvg: fmt(absConv(row.lutealAvg), row.display, nf),
follicularAvg: fmt(absConv(row.follicularAvg), row.display, nf),
unit,
lutealAvg: withUnit(
fmt(absConv(row.lutealAvg), row.display, nf),
unit,
),
follicularAvg: withUnit(
fmt(absConv(row.follicularAvg), row.display, nf),
unit,
),
lutealDays: row.lutealDays,
follicularDays: row.follicularDays,
}),
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Moon } from "lucide-react";
vi.mock("@/hooks/use-unit-display", async () => {
const { metricUnitDisplay } =
await import("@/__tests__/helpers/metric-unit-display");
return { useUnitDisplay: () => metricUnitDisplay() };
});
import { I18nProvider } from "@/lib/i18n/context";
import { MetricCrosstab, type MetricCrosstabRow } from "../metric-crosstab";
@@ -12,7 +18,7 @@ function row(overrides: Partial<MetricCrosstabRow> = {}): MetricCrosstabRow {
pairLabel: "Resting heart rate",
up: true,
deltaValue: "2,5",
unit: "bpm",
measure: { type: "RESTING_HEART_RATE", display: "bpm" },
confidence: "medium",
detail: "62 vs 59.5 bpm",
...overrides,
@@ -68,4 +74,31 @@ describe("<MetricCrosstab>", () => {
const html = render([row({ patternId: "p1", dismissed: true })]);
expect(html).not.toContain('data-slot="board-row"');
});
it("takes the unit from the measure, through the shared unit table", () => {
const html = render([
row(),
row({
key: "b",
measure: { type: "ACTIVITY_STEPS", display: "steps" },
deltaValue: "120",
}),
row({
key: "c",
measure: { type: null, display: "mood" },
deltaValue: "0,4",
}),
]);
expect(html).toContain("+2,5 bpm");
// The word comes from `measurementUnitLabel` (`insights.units.steps`).
expect(html).toContain("+120 steps");
// A rating has no unit word: the number stands alone, no trailing space.
expect(html).toContain(">+0,4<");
});
it("wraps a long pair label instead of cutting it", () => {
const html = render([row({ pairLabel: "A very long custom mood tag" })]);
expect(html).toMatch(/data-slot="metric-crosstab-pair"/);
expect(html).not.toContain("truncate");
});
});
@@ -183,10 +183,10 @@ describe("<SleepStageStackedBar>", () => {
expect(html).toContain('data-slot="sleep-stage-window-30"');
// The 7d button is default-active.
expect(html).toMatch(
/data-slot="sleep-stage-window-7"[^>]*aria-pressed="true"/,
/aria-pressed="true"[^>]*data-slot="sleep-stage-window-7"/,
);
expect(html).toMatch(
/data-slot="sleep-stage-window-14"[^>]*aria-pressed="false"/,
/aria-pressed="false"[^>]*data-slot="sleep-stage-window-14"/,
);
});
@@ -97,3 +97,79 @@ describe("no card title is cut", () => {
expect(found).toHaveLength(1);
});
});
/**
* A tile in a grid row reserves two title lines, so a wrapping title does not
* push its value and sparkline below the neighbours'. Proven on the header
* row's fixed height, and held for the grid tiles by a source check.
*/
describe("<TileHeader reserveLines>", () => {
function headerRow(html: string): string {
const match = html.match(/<div[^>]*data-slot="tile-header"[^>]*>/);
expect(match, "tile header not rendered").not.toBeNull();
return match![0];
}
it("gives a one-line and a two-line title the same row height", () => {
const short = headerRow(
renderToStaticMarkup(
<TileHeader icon={Heart} title="Puls" reserveLines={2} />,
),
);
const long = headerRow(
renderToStaticMarkup(
<TileHeader
icon={Heart}
title="Herzfrequenzvariabilität im Schlaf"
reserveLines={2}
/>,
),
);
expect(short).toBe(long);
expect(short).toContain("h-10");
expect(short).toContain("items-start");
const sm = headerRow(
renderToStaticMarkup(
<TileHeader size="sm" icon={Heart} title="Puls" reserveLines={2} />,
),
);
expect(sm).toContain("h-9");
});
it("clamps a third line and keeps the full title readable", () => {
const html = renderToStaticMarkup(
<TileHeader
icon={Heart}
title="Herzfrequenzvariabilität"
reserveLines={2}
/>,
);
expect(titleClasses(html)).toContain("line-clamp-2");
expect(html).toContain('title="Herzfrequenzvariabilität"');
});
it("counter-check: without the reserve the row has no fixed height", () => {
const row = headerRow(
renderToStaticMarkup(<TileHeader icon={Heart} title="Puls" />),
);
expect(row).not.toMatch(/\bh-(9|10)\b/);
expect(row).toContain("items-center");
});
const GRID_TILES = [
"components/insights/derived/sparkline-delta-tile.tsx",
"components/insights/derived/vitals-dashboard.tsx",
"components/insights/device-score-tile.tsx",
"components/insights/recovery/resilience-tile.tsx",
];
it.each(GRID_TILES)("%s reserves the title lines", (rel) => {
const source = stripComments(
readFileSync(join(process.cwd(), "src", rel), "utf8"),
);
const headers = (source.match(/<TileHeader\b/g) ?? []).length;
const reserved = (source.match(/\breserveLines=\{2\}/g) ?? []).length;
expect(headers, rel).toBeGreaterThan(0);
expect(reserved, rel).toBe(headers);
});
});
@@ -94,7 +94,7 @@ describe("<ScoreTrendChart>", () => {
const html = render(30, { points: POINTS, band: null });
const tabs = html.match(/<button[^>]*data-slot="chart-range-tab"[^>]*>/g);
expect(tabs).toHaveLength(4);
expect(tabs!.map((tab) => tab.match(/data-range="(\d+)"/)![1])).toEqual([
expect(tabs!.map((tab) => tab.match(/data-value="(\d+)"/)![1])).toEqual([
"7",
"30",
"90",
@@ -102,7 +102,7 @@ describe("<ScoreTrendChart>", () => {
]);
const pressed = tabs!.filter((tab) => tab.includes('aria-pressed="true"'));
expect(pressed).toHaveLength(1);
expect(pressed[0]).toContain('data-range="30"');
expect(pressed[0]).toContain('data-value="30"');
expect(html).toContain('data-slot="score-trend-chart"');
// The range is remembered under the page's own slot.
expect(prefs.lastKey).toBe("scoreHealth");
@@ -115,7 +115,7 @@ describe("<ScoreTrendChart>", () => {
html.match(/<button[^>]*data-slot="chart-range-tab"[^>]*>/g) ?? []
).filter((tab) => tab.includes('aria-pressed="true"'));
expect(pressed).toHaveLength(1);
expect(pressed[0]).toContain('data-range="90"');
expect(pressed[0]).toContain('data-value="90"');
// The seeded 90-day read is the one the chart drew from.
expect(html.match(/data-slot="chart-data-table-row"/g)).toHaveLength(3);
});
@@ -163,6 +163,7 @@ export function SparklineDeltaTile({
icon={Icon}
title={label}
titleAs="h2"
reserveLines={2}
className="min-w-0"
right={
provenance ? (
@@ -642,7 +642,7 @@ function BuildingTile({
data-state={state}
className="gap-3"
>
<TileHeader icon={icon} title={title} titleAs="h2" />
<TileHeader icon={icon} title={title} titleAs="h2" reserveLines={2} />
<p className="text-muted-foreground text-sm" data-slot={messageSlot}>
{message}
</p>
@@ -664,9 +664,11 @@ function VitalsTileSkeleton() {
aria-hidden="true"
className="gap-3"
>
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-4 rounded" />
{/* The same two-line reserve the tile headers keep, so the real
tiles drop in without moving the value row. */}
<div className="flex h-10 items-start justify-between gap-2">
<Skeleton className="mt-1 h-3 w-24" />
<Skeleton className="mt-0.5 h-4 w-4 rounded" />
</div>
<Skeleton className="h-8 w-20" />
<Skeleton className="h-3 w-3/4" />
@@ -117,6 +117,7 @@ export function DeviceScoreTile({
size="sm"
icon={Icon}
title={title}
reserveLines={2}
className="min-w-0"
right={
latest != null ? (
@@ -2,6 +2,7 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { TileHeader } from "@/components/insights/tile-header";
import {
useTranslations,
@@ -228,9 +229,11 @@ export function InsightStatusCard({
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.noAnalysisYet")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("insights.noAnalysisYet")}
/>
</CardContent>
</Card>
);
+111 -12
View File
@@ -3,7 +3,15 @@
import type { LucideIcon } from "lucide-react";
import { CONFIDENCE_TONE, ScorePill } from "@/components/ui/score-pill";
import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display";
import { useTranslations } from "@/lib/i18n/context";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
isUnitSensitiveType,
} from "@/lib/measurements/display-transform";
import { measurementUnitLabel } from "@/lib/measurements/unit-label";
import {
PatternDismissButton,
PatternDismissedNotice,
@@ -18,14 +26,101 @@ import {
* same markup that had begun to differ (the cycle copy printed its numbers
* with a full stop in every language).
*
* The caller owns the meaning: it resolves the labels, converts the units and
* formats the numbers (`deltaValue` already in the reader's notation, signed
* when negative). This component owns the shape: the row, the neutral delta
* (a higher value is good on one row and bad on the next, so the number is
* never coloured; the sign and `data-direction` carry the direction), the
* confidence chip, and the optional per-row dismissal.
* The caller owns the meaning: it resolves the labels and formats the numbers
* (`deltaValue` already in the reader's notation, signed when negative). The
* unit is not the caller's: each row names the measurement it reads
* (`measure`), and the unit comes from `measurementUnitLabel`, the one table
* every other read-out uses, after the reader's unit preference
* ({@link crosstabReading}). The boards used to carry a unit table each, and
* the German cycle board printed "S/min" where every other screen says "bpm".
* This component owns the shape: the row, the neutral delta (a higher value
* is good on one row and bad on the next, so the number is never coloured;
* the sign and `data-direction` carry the direction), the confidence chip,
* and the optional per-row dismissal.
*/
/**
* How the server sends a board's values: the scale, not the label. Most are
* the measurement's canonical unit; sleep arrives in hours (stored minutes),
* and a mood value is a rating with no measurement type.
*/
export type CrosstabDisplay =
| "hours"
| "kcal"
| "score"
| "bpm"
| "ms"
| "steps"
| "kg"
| "mmHg"
| "celsius"
| "glucose"
| "mood";
/** The storage token each display is in, for `measurementUnitLabel`. */
const DISPLAY_TOKEN: Record<CrosstabDisplay, string> = {
hours: "h",
kcal: "kcal",
score: "score",
bpm: "bpm",
ms: "ms",
steps: "steps",
kg: "kg",
mmHg: "mmHg",
celsius: "celsius",
glucose: "mg/dL",
mood: "rating",
};
/** What one row reads: the measurement type (null for mood) and its scale. */
export interface CrosstabMeasure {
type: string | null;
display: CrosstabDisplay;
}
/**
* The unit a row reads in, and the conversions of its absolute values and
* its differences, under the reader's preferences. A type whose number
* follows a preference (weight, temperature, glucose) takes the transform; any
* other keeps the server's value. Either way the label is
* `measurementUnitLabel`'s.
*/
export function crosstabReading(
measure: CrosstabMeasure,
units: UnitDisplay,
t: (key: string) => string,
): {
unit: string;
abs: (value: number) => number;
delta: (value: number) => number;
} {
const { type } = measure;
if (type && isUnitSensitiveType(type)) {
// The reading transform, not `units.transformFor`: glucose follows its
// own unit preference, not metric/imperial.
const transform = getReadingTransform(type, {
system: units.preference,
glucoseUnit: units.glucoseUnit,
});
return {
unit: measurementUnitLabel(type, transform.displayUnit, t),
abs: (value) => applyDisplayTransform(value, transform),
// A difference takes the factor only, never an affine offset (°C→°F).
delta: (value) => applyDisplayTransformDelta(value, transform),
};
}
return {
unit: measurementUnitLabel(type ?? "", DISPLAY_TOKEN[measure.display], t),
abs: (value) => value,
delta: (value) => value,
};
}
/** A formatted value with its unit, or the value alone when it has none. */
export function withUnit(value: string, unit: string): string {
return unit ? `${value} ${unit}` : value;
}
export type MetricCrosstabConfidence = "low" | "medium" | "high";
export interface MetricCrosstabRow {
@@ -39,7 +134,8 @@ export interface MetricCrosstabRow {
up: boolean;
/** The difference in the reader's notation, with its minus sign. */
deltaValue: string;
unit: string;
/** What the row reads; the unit beside the difference comes from it. */
measure: CrosstabMeasure;
confidence: MetricCrosstabConfidence;
detail: string;
/** A dismissible pattern; omitted on boards that cannot be dismissed. */
@@ -68,6 +164,7 @@ export function MetricCrosstab({
rows: readonly MetricCrosstabRow[];
}) {
const { t } = useTranslations();
const units = useUnitDisplay();
const dismissal = usePatternDismissalOverrides();
if (rows.length === 0) return null;
@@ -93,6 +190,7 @@ export function MetricCrosstab({
);
}
const Icon = row.icon;
const { unit } = crosstabReading(row.measure, units, t);
return (
<li
key={row.key}
@@ -102,21 +200,22 @@ export function MetricCrosstab({
data-direction={row.up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
<div className="flex items-start gap-2 text-sm">
{Icon ? (
<Icon
className="text-muted-foreground size-4 shrink-0"
className="text-muted-foreground mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
) : null}
{/* The pair wraps rather than cutting a tag name short. */}
<span
className="text-foreground min-w-0 flex-1 truncate"
title={row.pairLabel}
className="text-foreground min-w-0 flex-1 break-words"
data-slot="metric-crosstab-pair"
>
{row.pairLabel}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{`${row.up ? "+" : ""}${row.deltaValue} ${row.unit}`.trim()}
{withUnit(`${row.up ? "+" : ""}${row.deltaValue}`, unit)}
</span>
<ScorePill tone={CONFIDENCE_TONE[row.confidence]}>
{t(CONFIDENCE_KEY[row.confidence])}
@@ -1,6 +1,12 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-unit-display", async () => {
const { metricUnitDisplay } =
await import("@/__tests__/helpers/metric-unit-display");
return { useUnitDisplay: () => metricUnitDisplay() };
});
import { I18nProvider } from "@/lib/i18n/context";
import {
MoodTagMetricCrosstab,
@@ -4,7 +4,12 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { MetricCrosstab } from "@/components/insights/metric-crosstab";
import {
crosstabReading,
MetricCrosstab,
withUnit,
type CrosstabMeasure,
} from "@/components/insights/metric-crosstab";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
/**
@@ -57,14 +62,14 @@ const METRIC_LABEL_KEY: Record<string, string> = {
bloodPressureSystolic: "insights.mood.factorCrosstab.metricBloodPressureSys",
};
const UNIT_KEY: Record<MoodFactorCrosstabDisplay, string> = {
hours: "insights.mood.factorCrosstab.unitHours",
score: "insights.mood.factorCrosstab.unitScore",
steps: "insights.mood.factorCrosstab.unitSteps",
bpm: "insights.mood.factorCrosstab.unitBpm",
ms: "insights.mood.factorCrosstab.unitMs",
kg: "insights.mood.factorCrosstab.unitKg",
mmHg: "insights.mood.factorCrosstab.unitMmHg",
/** The measurement each row reads (`src/lib/insights/mood-crosstab.ts`). */
const METRIC_TYPE: Record<string, string> = {
sleepDuration: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
restingHeartRate: "RESTING_HEART_RATE",
heartRateVariability: "HEART_RATE_VARIABILITY",
weight: "WEIGHT",
bloodPressureSystolic: "BLOOD_PRESSURE_SYS",
};
/** Whole numbers for steps; one decimal otherwise. */
@@ -92,16 +97,13 @@ export function MoodFactorMetricCrosstab({
rows={rows.map((row) => {
const factorLabel = t(row.labelKey);
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
// The weight ("kg") row follows the metric/imperial preference: the
// unit symbol and the values (averages and delta, all in kg,
// offset-free) convert together. Every other display is unitless to
// the preference and keeps its i18n label and raw value.
const isWeightRow = row.display === "kg";
const unit = isWeightRow
? unitDisplay.unitFor("WEIGHT")
: t(UNIT_KEY[row.display]);
const conv = (v: number) =>
isWeightRow ? unitDisplay.toDisplay("WEIGHT", v) : v;
// Weight follows the metric/imperial preference (values and unit
// together); every other row keeps the server's value.
const measure: CrosstabMeasure = {
type: METRIC_TYPE[row.metricKey] ?? null,
display: row.display,
};
const { unit, abs, delta } = crosstabReading(measure, unitDisplay, t);
// `delta` = lowAvg − highAvg: positive = the vital runs higher on the
// low-factor (worse) days.
return {
@@ -113,8 +115,8 @@ export function MoodFactorMetricCrosstab({
}),
icon: moodTagIcon(row.icon),
up: row.delta >= 0,
deltaValue: formatValue(conv(row.delta), row.display, nf),
unit,
deltaValue: formatValue(delta(row.delta), row.display, nf),
measure,
confidence: row.confidence,
detail: t(
row.inverse
@@ -123,9 +125,14 @@ export function MoodFactorMetricCrosstab({
{
factor: factorLabel,
metric: metricLabel,
lowAvg: formatValue(conv(row.lowAvg), row.display, nf),
highAvg: formatValue(conv(row.highAvg), row.display, nf),
unit,
lowAvg: withUnit(
formatValue(abs(row.lowAvg), row.display, nf),
unit,
),
highAvg: withUnit(
formatValue(abs(row.highAvg), row.display, nf),
unit,
),
lowDays: row.lowDays,
},
),
@@ -3,7 +3,13 @@
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { MetricCrosstab } from "@/components/insights/metric-crosstab";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
crosstabReading,
MetricCrosstab,
withUnit,
type CrosstabMeasure,
} from "@/components/insights/metric-crosstab";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
/**
@@ -63,12 +69,13 @@ const METRIC_LABEL_KEY: Record<string, string> = {
nextDayHeartRateVariability: "insights.mood.crosstab.metricNextDayHrv",
};
const UNIT_KEY: Record<MoodCrosstabDisplay, string> = {
hours: "insights.mood.crosstab.unitHours",
kcal: "insights.mood.crosstab.unitKcal",
score: "insights.mood.crosstab.unitScore",
bpm: "insights.mood.crosstab.unitBpm",
ms: "insights.mood.crosstab.unitMs",
/** The measurement each row reads (`src/lib/insights/mood-crosstab.ts`). */
const METRIC_TYPE: Record<string, string> = {
activeEnergy: "ACTIVE_ENERGY_BURNED",
sleepDuration: "SLEEP_DURATION",
nextDayRecovery: "RECOVERY_SCORE",
nextDayRestingHeartRate: "RESTING_HEART_RATE",
nextDayHeartRateVariability: "HEART_RATE_VARIABILITY",
};
/** One decimal for hours/score, whole numbers for kcal. */
@@ -87,6 +94,7 @@ export function MoodTagMetricCrosstab({
}) {
const { t } = useTranslations();
const nf = useFormatters();
const units = useUnitDisplay();
return (
<MetricCrosstab
slot="mood-tag-metric-crosstab"
@@ -95,7 +103,11 @@ export function MoodTagMetricCrosstab({
rows={rows.map((row) => {
const tagLabel = row.label ?? t(row.labelKey);
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
const unit = t(UNIT_KEY[row.display]);
const measure: CrosstabMeasure = {
type: METRIC_TYPE[row.metricKey] ?? null,
display: row.display,
};
const { unit, abs, delta } = crosstabReading(measure, units, t);
return {
key: `${row.metricKey}:${row.tag}`,
metricKey: row.metricKey,
@@ -105,17 +117,22 @@ export function MoodTagMetricCrosstab({
}),
icon: moodTagIcon(row.icon),
up: row.delta >= 0,
deltaValue: formatValue(row.delta, row.display, nf),
unit,
deltaValue: formatValue(delta(row.delta), row.display, nf),
measure,
confidence: row.confidence,
detail: t(
row.mode === "nextDay"
? "insights.mood.crosstab.detailNextDay"
: "insights.mood.crosstab.detailSameDay",
{
withAvg: formatValue(row.withAvg, row.display, nf),
withoutAvg: formatValue(row.withoutAvg, row.display, nf),
unit,
withAvg: withUnit(
formatValue(abs(row.withAvg), row.display, nf),
unit,
),
withoutAvg: withUnit(
formatValue(abs(row.withoutAvg), row.display, nf),
unit,
),
withDays: row.withDays,
},
),
@@ -12,6 +12,7 @@ import {
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { Segmented } from "@/components/ui/segmented";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/ui/section-heading";
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
@@ -175,29 +176,17 @@ export function PeriodNarrativeCard({
reads best. */}
<div className="flex items-start justify-between gap-2">
{/* Calm segmented control, no chart. */}
<div className="flex gap-1">
{(["week", "month"] as const).map((p) => {
const active = period === p;
return (
<button
key={p}
type="button"
aria-pressed={active}
onClick={() => setPeriod(p)}
className={cn(
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{p === "week"
? t("insights.narrativeWeek")
: t("insights.narrativeMonth")}
</button>
);
})}
</div>
<Segmented
mode="toggle"
label={t("insights.narrativeTitle")}
slot="period-narrative-period"
value={period}
onChange={setPeriod}
options={[
{ value: "week", label: t("insights.narrativeWeek") },
{ value: "month", label: t("insights.narrativeMonth") },
]}
/>
<AskCoachIconButton
question={t(
period === "week"
@@ -97,6 +97,7 @@ export function ResilienceTile() {
size="sm"
icon={ShieldCheck}
title={t("measurements.typeResilience")}
reserveLines={2}
className="min-w-0"
right={
bandKey != null ? (
@@ -14,7 +14,7 @@ import {
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import { ChartDataTable } from "@/components/charts/chart-data-table";
import {
ChartDayFooter,
@@ -389,31 +389,21 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
})}
</span>
</div>
<div
// v1.4.27 — bump the gap from `gap-1` to
// `gap-1.5` so the three window-toggle buttons (7d /
// 14d / 30d) breathe enough on Pixel 5 that the active
// pill's border doesn't fuse with the inactive neighbour.
className="flex items-center gap-1.5 self-end sm:self-auto"
data-slot="sleep-stage-window-toggle"
>
{WINDOW_DAYS.map((w) => (
<Button
key={w}
type="button"
variant={windowDays === w ? "default" : "ghost"}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setWindowDays(w)}
aria-pressed={windowDays === w}
data-slot={`sleep-stage-window-${w}`}
>
{/* The page's other range controls read "7T / 30T"; this
one printed an English "7d" beside them. */}
{t("charts.daysNLabel", { days: w })}
</Button>
))}
</div>
{/* The page's other range controls read "7T / 30T"; this one
printed an English "7d" beside them, hence `daysNLabel`. */}
<Segmented
mode="toggle"
label={t("charts.rangeLabel")}
slot="sleep-stage-window-toggle"
optionSlot={(w) => `sleep-stage-window-${w}`}
className="self-end sm:self-auto"
value={String(windowDays)}
onChange={(next) => setWindowDays(Number(next) as WindowSize)}
options={WINDOW_DAYS.map((w) => ({
value: String(w),
label: t("charts.daysNLabel", { days: w }),
}))}
/>
</div>
</CardHeader>
<CardContent>
+28 -2
View File
@@ -61,6 +61,17 @@ interface TileHeaderProps {
* section). Default (unset) keeps every other tile a plain CardTitle.
*/
titleAs?: "h2" | "h3";
/**
* Reserve two title lines at a fixed height, for a tile that sits in a
* grid row beside others. A title that wraps ("Herzfrequenzvariabilität"
* on a narrow tile) would otherwise push its value and sparkline below
* the neighbours' and the row stops reading as one line; with the reserve
* every tile's body starts at the same height whether its title takes one
* line or two. A title that would need a third line clamps, with its full
* text in `title` when it is a string. The dashboard trend strip does the
* same for its labels (`trend-card.tsx`).
*/
reserveLines?: 2;
}
export function TileHeader({
@@ -71,6 +82,7 @@ export function TileHeader({
className,
id,
titleAs,
reserveLines,
}: TileHeaderProps) {
const Heading = titleAs ?? "h2";
// The title wraps; it never truncates. A tile title is the only thing that
@@ -83,14 +95,26 @@ export function TileHeader({
// line clear of the first; on one line it matches the 20 px icon box, so a
// single-line header is the height it was. There is no class override:
// every caller used one to add `truncate`.
const reserve = reserveLines === 2;
const titleClassNames = cn(
"min-w-0 leading-tight break-words hyphens-auto",
size === "sm" ? "text-sm" : "text-base",
reserve && "line-clamp-2",
);
const fullTitle = reserve && typeof title === "string" ? title : undefined;
return (
<div
data-slot="tile-header"
className={cn("flex items-center gap-2", className)}
data-reserve-lines={reserve ? 2 : undefined}
className={cn(
"flex gap-2",
// Two `leading-tight` lines: 2 × 20 px at `text-base`, 2 × 17.5 px
// at `text-sm`. The icon then sits on the first line.
reserve
? cn("items-start", size === "sm" ? "h-9" : "h-10")
: "items-center",
className,
)}
>
{/* Icon and heading share the foreground colour — the calm,
high-contrast read the "Einschätzung" card established. */}
@@ -99,6 +123,7 @@ export function TileHeader({
className={cn(
"text-foreground shrink-0",
size === "sm" ? "h-4 w-4" : "h-5 w-5",
reserve && size === "sm" && "mt-px",
)}
aria-hidden="true"
/>
@@ -109,12 +134,13 @@ export function TileHeader({
<Heading
id={id}
data-slot="card-title"
title={fullTitle}
className={cn("font-semibold", titleClassNames)}
>
{title}
</Heading>
) : (
<CardTitle id={id} className={titleClassNames}>
<CardTitle id={id} title={fullTitle} className={titleClassNames}>
{title}
</CardTitle>
)}
+13 -22
View File
@@ -8,6 +8,7 @@ import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -424,28 +425,18 @@ export function LabForm({
<div className="space-y-1.5">
<Label>{t("labs.form.resultType")}</Label>
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant={resultType === "numeric" ? "secondary" : "ghost"}
className="min-h-11 flex-1 sm:min-h-9"
onClick={() => setResultType("numeric")}
aria-pressed={resultType === "numeric"}
>
{t("labs.form.numeric")}
</Button>
<Button
type="button"
size="sm"
variant={resultType === "qualitative" ? "secondary" : "ghost"}
className="min-h-11 flex-1 sm:min-h-9"
onClick={() => setResultType("qualitative")}
aria-pressed={resultType === "qualitative"}
>
{t("labs.form.qualitative")}
</Button>
</div>
<Segmented
mode="toggle"
stretch
label={t("labs.form.resultType")}
slot="lab-form-result-type"
value={resultType}
onChange={setResultType}
options={[
{ value: "numeric", label: t("labs.form.numeric") },
{ value: "qualitative", label: t("labs.form.qualitative") },
]}
/>
</div>
{/* One column at every width, as in the measurement form. A second
@@ -30,6 +30,7 @@
* sibling of the detail-open button (never nested inside it) so the two
* actions never collide.
*/
import { EmptyState } from "@/components/ui/empty-state";
import { Info } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -170,9 +171,12 @@ export function InstrumentCard({
</div>
</div>
) : (
<div className="text-muted-foreground flex min-h-[2.75rem] items-center px-4 text-sm md:px-6">
{t("mentalHealth.noResultYet")}
</div>
<EmptyState
variant="plain"
size="compact"
className="px-4 md:px-6"
title={t("mentalHealth.noResultYet")}
/>
)}
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
@@ -13,7 +13,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/segmented";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
@@ -180,28 +180,19 @@ export function ExistingFactorReauthDialog({
</AlertDialogHeader>
{methods.length > 1 && (
<div
className="grid grid-cols-2 gap-2"
role="group"
aria-label={t("settings.passkeyReauth.methodLabel")}
>
{methods.map((m) => (
<Button
key={m}
type="button"
variant={method === m ? "secondary" : "outline"}
aria-pressed={method === m}
disabled={busy}
onClick={() => {
setMethod(m);
setSecret("");
setAssertionError(null);
}}
>
{labels[m]}
</Button>
))}
</div>
<Segmented
mode="toggle"
stretch
label={t("settings.passkeyReauth.methodLabel")}
value={method}
disabled={busy}
onChange={(m) => {
setMethod(m);
setSecret("");
setAssertionError(null);
}}
options={methods.map((m) => ({ value: m, label: labels[m] }))}
/>
)}
{method === "password" && (
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { CardTitle } from "../card";
/**
* A card title wraps (`break-words hyphens-auto`), so its line height must
* leave room between the lines: under `leading-none` a wrapped German
* compound's two lines touched.
*/
describe("<CardTitle>", () => {
it("wraps with room between the lines", () => {
const html = renderToStaticMarkup(
<CardTitle>Körperfettverteilung</CardTitle>,
);
expect(html).toContain("leading-tight");
expect(html).not.toContain("leading-none");
});
it("counter-check: a caller's own leading still wins", () => {
const html = renderToStaticMarkup(
<CardTitle className="leading-snug">Puls</CardTitle>,
);
expect(html).toContain("leading-snug");
expect(html).not.toContain("leading-tight");
});
});
@@ -137,3 +137,62 @@ describe("one segmented control", () => {
expect(defining).toEqual(["components/ui/segmented.tsx"]);
});
});
/**
* A row of buttons whose `aria-pressed` compares a state to each button's
* value is a segmented control spelled by hand: six of them sat beside
* `Segmented` (the narrative period, the lab result type, both admin filters,
* the chart range tabs, the mood chart's copy of them), each with its own
* size, fill and gap. New ones are refused; the ones that remain are not one
* choice on one track, and say why.
*/
describe("no hand-built segmented control", () => {
const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g;
const NOT_A_SEGMENT: Record<string, string> = {
"components/settings/security-section/passkey-list-section.tsx":
"one method is disabled while the account has no passkey; Segmented disables the whole track",
"components/medications/units-per-dose-field.tsx":
"a chip row that opens a free-number field for any other count",
"components/body-sites/body-sites-section.tsx":
"wrapping side chips under a body-site picker, not a track",
"components/illness/log-day-sheet.tsx":
"a graded severity scale per symptom, one square per level",
"components/cycle/log-day-sheet.tsx":
"a graded severity scale per symptom, one circle per level",
"components/charts/chart-overlay-controls.tsx":
"baselines stay pressable while greyed, with a tooltip saying why there is no data",
"components/timeline/timeline-view.tsx":
"a single button that opens the range sheet, beside the zoom Segmented",
};
function pressedByValue(source: string): number {
return (source.match(PRESSED_BY_VALUE) ?? []).length;
}
it("finds none outside the reasoned exceptions", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { stripComments, walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const found = walkSourceFiles(src, { floor: 2000 })
.filter((p) => /^(components|app)\//.test(p))
.filter((p) => !p.includes("__tests__/"))
.filter((p) => p !== "components/ui/segmented.tsx")
.filter(
(p) =>
pressedByValue(stripComments(readFileSync(join(src, p), "utf8"))) > 0,
)
.sort();
expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort());
});
it("counter-check: catches a hand-built pair, not an on/off toggle", () => {
expect(
pressedByValue(`<Button aria-pressed={filter === "all"}>All</Button>
<Button aria-pressed={filter === "failed"}>Failed</Button>`),
).toBe(2);
expect(pressedByValue(`<Button aria-pressed={open}>Show</Button>`)).toBe(0);
});
});
+1 -1
View File
@@ -64,7 +64,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-title"
className={cn(
"leading-none font-semibold break-words hyphens-auto",
"leading-tight font-semibold break-words hyphens-auto",
className,
)}
{...props}
+9 -2
View File
@@ -37,6 +37,8 @@ export interface SegmentedOption<T extends string> {
icon?: LucideIcon;
/** Rendered after the label (a connected check beside a system name). */
trailing?: ReactNode;
/** A longer name shown on hover ("Last 7 days" for "7d"). */
title?: string;
}
export interface SegmentedProps<T extends string> {
@@ -118,8 +120,13 @@ export function Segmented<T extends string>({
...getRadioProps(index),
})}
{...(iconOnly
? { "aria-label": option.label, title: option.label }
: {})}
? {
"aria-label": option.label,
title: option.title ?? option.label,
}
: option.title
? { title: option.title }
: {})}
disabled={disabled}
data-value={option.value}
data-slot={segmentSlot?.(option.value)}
+7 -3
View File
@@ -106,18 +106,22 @@ export function buildComparisonBlock(
return `- ${metric.type}: no prior-period data available`;
}
const sign = metric.delta > 0 ? "+" : metric.delta < 0 ? "-" : "±";
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person
const formattedDelta = `${sign}${Math.abs(metric.delta).toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}`;
const pct =
metric.deltaPercent != null
? ` (${metric.deltaPercent > 0 ? "+" : metric.deltaPercent < 0 ? "-" : "±"}${Math.abs(metric.deltaPercent).toFixed(1)} %)`
? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person
` (${metric.deltaPercent > 0 ? "+" : metric.deltaPercent < 0 ? "-" : "±"}${Math.abs(metric.deltaPercent).toFixed(1)} %)`
: "";
const current =
metric.currentAvg != null
? `${metric.currentAvg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}`
? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person
`${metric.currentAvg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}`
: "n/a";
const prior =
metric.baselineAvg != null
? `${metric.baselineAvg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}`
? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person
`${metric.baselineAvg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}`
: "n/a";
return `- ${metric.type}: current ${current}, baseline ${prior}, delta ${formattedDelta}${pct}`;
};
+2
View File
@@ -57,12 +57,14 @@ function deNum(value: number): string {
* that only names 54 mg/dL cannot fire on it.
*/
function glucoseFloor(mgdl: number, locale: ContractLocale): string {
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person
const mmol = mgdlToMmol(mgdl).toFixed(1);
return `${mgdl} mg/dL (${locale === "de" ? mmol.replace(".", ",") : mmol} mmol/L)`;
}
/** A temperature floor in both scales, °C first (the scale it is defined in). */
function temperatureFloor(celsius: number, locale: ContractLocale): string {
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person
const fahrenheit = (celsius * 1.8 + 32).toFixed(1);
return locale === "de"
? `${deNum(celsius)} °C (${fahrenheit.replace(".", ",")} °F)`
+2
View File
@@ -486,12 +486,14 @@ export function generateAlerts(input: AlertInput): HealthAlert[] {
alerts.push({
level: "info",
title: "Weight increasing",
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- English API alert text that no screen renders
message: `Your weight is increasing by approx. ${weeklyChange.toFixed(1)} kg per week over the last 30 days.`,
});
} else if (weeklyChange < -0.5) {
alerts.push({
level: "info",
title: "Weight decreasing",
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- English API alert text that no screen renders
message: `Your weight is decreasing by approx. ${Math.abs(weeklyChange).toFixed(1)} kg per week over the last 30 days.`,
});
}
@@ -359,6 +359,7 @@ export async function computeCorrelationHypothesesFastPath(
const weightWeekday = correlateWeightWeekday(
{ daily: weightWeekdayPairs },
t,
input.locale ?? defaultLocale,
);
annotate({
+1
View File
@@ -81,6 +81,7 @@ export function computeAdiposityPillar(
observed: {
value: roundedRatio,
unit: "ratio",
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire fallback label; the score detail prints the value through its own bundle key
label: `waist-to-height ${roundedRatio.toFixed(3)}`,
asOf: row.at.toISOString(),
sources: [row.source],
+1 -1
View File
@@ -546,7 +546,7 @@ export function isScopedCredential(auth: AuthContext): boolean {
*/
export async function requireAuth(
requiredPermission?: string,
options?: Pick<WriteMeterOptions, "writeBucket">,
options?: WriteMeterOptions,
): Promise<AuthContext> {
const auth = await authenticateCaller(requiredPermission);
const carrier = await readActingCarrier(auth);
@@ -16,6 +16,12 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
const signals = vi.hoisted(() => ({ emitted: [] as unknown[] }));
vi.mock("@/lib/logging/signal", () => ({
emitSignal: (input: unknown) => signals.emitted.push(input),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: state.allowed,
@@ -38,6 +44,7 @@ function request(method: string | undefined) {
beforeEach(() => {
vi.mocked(checkRecordWriteRateLimit).mockClear();
signals.emitted.length = 0;
state.allowed = true;
state.event = null;
});
@@ -99,4 +106,27 @@ describe("meterAccountWrite", () => {
RECORD_WRITE_RATE_LIMITED_CODE,
);
});
it("lets the write through with a warning when the bucket cannot be read", async () => {
vi.mocked(checkRecordWriteRateLimit).mockRejectedValueOnce(
new Error("canceling statement due to lock timeout"),
);
request("POST");
await expect(meterAccountWrite("account-1")).resolves.toBeUndefined();
expect(signals.emitted).toEqual([
expect.objectContaining({
action: "rate_limit.record_write.unavailable",
level: "warn",
}),
]);
});
it("counter-check: a readable, spent bucket still refuses, with no warning", async () => {
state.allowed = false;
request("POST");
await expect(meterAccountWrite("account-1")).rejects.toBeInstanceOf(
HttpError,
);
expect(signals.emitted).toEqual([]);
});
});
+21 -1
View File
@@ -31,9 +31,18 @@
* record owner's, and cannot collect a fresh one by switching records. A
* refused request is counted too, since the charge happens before the body is
* read.
*
* The bucket fails open. It cuts off a runaway loop; it is not what decides
* who may write, which the resolver that called it has already settled. So
* when the bucket itself cannot be read (the upsert fails: a lock on
* `rate_limits`, a pool at its limit), the write goes ahead and a `warn`
* signal says the ceiling was not applied, instead of every authenticated
* write in the app answering 500. The login, sign-up and other auth-surface
* limits call the limiter directly and keep failing closed.
*/
import { HttpError } from "@/lib/api-errors";
import { annotate, getEvent } from "@/lib/logging/context";
import { emitSignal } from "@/lib/logging/signal";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
/** The code a refusal by the shared write bucket carries. */
@@ -76,7 +85,18 @@ export async function meterAccountWrite(
if (charged.has(evt)) return;
charged.add(evt);
const rl = await checkRecordWriteRateLimit(accountId);
let rl: Awaited<ReturnType<typeof checkRecordWriteRateLimit>>;
try {
rl = await checkRecordWriteRateLimit(accountId);
} catch (error) {
annotate({ meta: { record_write_rate_limit_unavailable: true } });
emitSignal({
action: "rate_limit.record_write.unavailable",
level: "warn",
error,
});
return;
}
if (!rl.allowed) {
annotate({ meta: { record_write_rate_limited: true } });
throw new HttpError(
+1
View File
@@ -713,6 +713,7 @@ async function labsPart(frame: DayFrame): Promise<SectionPart> {
row.value !== null
? // Every recorded digit, without the float noise a converted
// reading carries (the labs page prints the same figure).
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- the day contract's meta is wire text; the lab figure keeps the notation it was stored in
`${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${row.unit}`.trim()
: (row.valueText ?? null),
note: openText(row.noteEncrypted, "lab note"),
@@ -18,6 +18,7 @@ import { describe, expect, it } from "vitest";
import {
appointmentNextDueAfterFiring,
appointmentNotifyHour,
appointmentReminderAddress,
} from "@/lib/encounters/appointment-reminder";
const TZ = "Europe/Berlin";
@@ -84,3 +85,34 @@ describe("the hour the nudge fires", () => {
expect(appointmentNotifyHour(APPOINTMENT, "UTC")).toBe(7);
});
});
describe("the address an appointment reminder names", () => {
const reminder = (practitionerUserId: string) => ({
userId: "u1",
origin: "ENCOUNTER",
location: null,
encounters: [
{
userId: "u1",
practitioner: {
userId: practitionerUserId,
location: "Hauptstraße 1",
locationEncrypted: null,
},
},
],
});
it("is the practice of the visit's practitioner, deleted from the address book or not", () => {
// The select carries no `deletedAt`: the booked visit still shows the
// practitioner, so the reminder names the same practice.
expect(appointmentReminderAddress(reminder("u1"))).toBe("Hauptstraße 1");
});
it("counter-check: another account's practitioner names nothing", () => {
expect(appointmentReminderAddress(reminder("u2"))).toBeNull();
expect(
appointmentReminderAddress({ ...reminder("u1"), encounters: [] }),
).toBeNull();
});
});
+4 -5
View File
@@ -199,7 +199,6 @@ export const APPOINTMENT_REMINDER_ADDRESS_INCLUDE = {
practitioner: {
select: {
userId: true,
deletedAt: true,
location: true,
locationEncrypted: true,
},
@@ -212,7 +211,9 @@ export const APPOINTMENT_REMINDER_ADDRESS_INCLUDE = {
/**
* The address a reminder's push names. A checkup's own free-text `location`;
* for an appointment, the owning visit's practitioner address, decrypted
* here. Null when the visit names no live practitioner of the same account.
* here. Null when the visit names no practitioner of the same account. A
* practitioner deleted from the address book is still the one the booked
* visit is with, and the visit still shows it, so the reminder names it too.
*/
export function appointmentReminderAddress(reminder: {
userId: string;
@@ -222,7 +223,6 @@ export function appointmentReminderAddress(reminder: {
userId: string;
practitioner: {
userId: string;
deletedAt: Date | null;
location: string | null;
locationEncrypted: Uint8Array | null;
} | null;
@@ -235,8 +235,7 @@ export function appointmentReminderAddress(reminder: {
!visit ||
!practitioner ||
visit.userId !== reminder.userId ||
practitioner.userId !== reminder.userId ||
practitioner.deletedAt !== null
practitioner.userId !== reminder.userId
) {
return null;
}
@@ -187,6 +187,7 @@ function isTimeZone(value: unknown): value is string {
return false;
}
try {
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- validates a zone name and formats nothing
Intl.DateTimeFormat(undefined, { timeZone: value });
return true;
} catch {
+1
View File
@@ -55,6 +55,7 @@ export function packBackupBlob(json: string): string {
function size(bytes: number): string {
const mb = bytes / 1024 / 1024;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- operator log text about a backup size
return mb < 100 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`;
}
+16 -2
View File
@@ -222,7 +222,19 @@ export async function buildVisitsBackupSection(
conditionLinkRows,
] = await Promise.all([
prisma.practitioner.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
// A portable file leaves the address book's tombstones out, except one
// a live visit or dose still names: the record shows it, so the file
// carries it (with its `deletedAt`) instead of dropping the reference.
where: disasterRecovery
? { userId }
: {
userId,
OR: [
{ deletedAt: null },
{ encounters: { some: { deletedAt: null } } },
{ vaccinations: { some: { deletedAt: null } } },
],
},
orderBy: { name: "asc" },
select: PRACTITIONER_BACKUP_SELECT,
}),
@@ -290,7 +302,9 @@ export async function buildVisitsBackupSection(
}),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(disasterRecovery
// A portable file names a tombstone only when a live visit holds it,
// and says so, so the restore keeps it out of the address book.
...(disasterRecovery || row.deletedAt
? { deletedAt: row.deletedAt?.toISOString() ?? null }
: {}),
})),
+17
View File
@@ -179,6 +179,23 @@ export interface Formatters {
monthShort: (value: DateInput) => string;
}
/**
* A decimal in the reader's notation for text built outside React (a server
* sentence, a deterministic fallback): at most `maxFractionDigits` digits,
* trailing zeros dropped, no grouping. `toFixed` prints a full stop in every
* language; this prints "0,8" in German and "0.8" in English.
*/
export function formatDecimal(
value: number,
locale: Locale,
maxFractionDigits: number,
): string {
return getNumberFormat(resolveIntlLocale(locale), {
maximumFractionDigits: maxFractionDigits,
useGrouping: false,
}).format(value);
}
/**
* Build the locale-aware formatter set.
*
+2
View File
@@ -278,6 +278,7 @@ export async function extractExportXml(
MAX_COMPRESSION_RATIO
) {
throw new Error(
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- zero fraction digits: a whole ratio, no decimal mark to localise
`export.xml advertises a ${(
exportXmlEntry.uncompressedSize / exportXmlEntry.compressedSize
).toFixed(0)}× compression ratio (cap is ${MAX_COMPRESSION_RATIO}×)` +
@@ -399,6 +400,7 @@ export async function extractHealthConnectDb(
entry.uncompressedSize / entry.compressedSize > MAX_COMPRESSION_RATIO
) {
throw new Error(
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- zero fraction digits: a whole ratio, no decimal mark to localise
`${HEALTH_CONNECT_DB_MEMBER} advertises a ${(
entry.uncompressedSize / entry.compressedSize
).toFixed(0)}× compression ratio (cap is ${MAX_COMPRESSION_RATIO}×)` +
@@ -9,6 +9,7 @@ import {
MAX_P_VALUE,
} from "../correlations";
import { seededRng } from "./helpers/seeded-series";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/**
* v1.4.20 — correlation discovery.
@@ -388,6 +389,26 @@ describe("correlateWeightWeekday", () => {
}
});
it("prints the difference in the reader's notation", () => {
const daily: Array<{ weekday: number; weight: number }> = [];
for (let week = 0; week < 4; week++) {
daily.push({ weekday: 0, weight: 84.0 + week * 0.1 });
for (let d = 1; d < 7; d++) daily.push({ weekday: d, weight: 80.0 });
}
const de = correlateWeightWeekday(
{ daily },
getServerTranslator("de").t,
"de",
);
const en = correlateWeightWeekday({ daily });
if (de.status !== "ok" || en.status !== "ok") throw new Error("ok");
// 84.15 against a grand mean of 80.59: a 3.6 difference.
expect(de.interpretation).toMatch(/3,6/);
expect(de.interpretation).not.toMatch(/3\.6/);
// Counter-check: English keeps the full stop.
expect(en.interpretation).toMatch(/3\.6/);
});
it("returns insufficient when no weekday meaningfully differs", () => {
const daily = Array.from({ length: 28 }, (_, i) => ({
weekday: i % 7,
+1
View File
@@ -246,6 +246,7 @@ export function formatRelationsBlock(
// Cap at two so the block never crowds out the metric's own grounding.
const top = correlations.slice(0, 2);
const lines = top.map(
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- text for the model, never shown to a person
(c) => `- ${c.interpretation} (n=${c.n}, r=${c.r.toFixed(2)})`,
);
if (locale === "en") {
+5 -2
View File
@@ -39,7 +39,8 @@
* the never-causal framing survives verbatim in all six locales rather than
* leaking English into a non-English UI.
*/
import { defaultLocale } from "@/lib/i18n/config";
import { formatDecimal } from "@/lib/format-locale";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
dayKeyToIndex,
@@ -919,6 +920,7 @@ export interface WeightWeekdayInput {
export function correlateWeightWeekday(
input: WeightWeekdayInput,
t: Translate = defaultTranslate(),
locale: Locale = defaultLocale,
): CorrelationResult {
const points = input.daily.map((d) => ({ x: d.weekday, y: d.weight }));
const anova = weekdayAnova(
@@ -951,7 +953,8 @@ export function correlateWeightWeekday(
const direction = t(
`insights.correlation.weekdayDirection.${outlierMean > grandMean ? "above" : "below"}`,
);
const delta = Math.abs(outlierMean - grandMean).toFixed(1);
// In the reader's notation: the sentence is theirs ("0,8 kg" in German).
const delta = formatDecimal(Math.abs(outlierMean - grandMean), locale, 1);
const interpretation = t("insights.correlation.hypothesis.weightWeekday", {
weekday: dayLabel,
delta,
+7
View File
@@ -158,6 +158,7 @@ function weekdayNarratives(input: MoodNarrativeInput): MoodNarrative[] {
messageKey: "insights.mood.narrative.weekdayDip",
vars: {
weekdayKey: WEEKDAY_LABEL_KEYS[worst.weekday],
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters
delta: dipDelta.toFixed(1),
},
strength: dipDelta,
@@ -171,6 +172,7 @@ function weekdayNarratives(input: MoodNarrativeInput): MoodNarrative[] {
messageKey: "insights.mood.narrative.weekdayPeak",
vars: {
weekdayKey: WEEKDAY_LABEL_KEYS[best.weekday],
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters
value: best.avgScore.toFixed(1),
},
strength: peakDelta,
@@ -208,6 +210,7 @@ function trendNarrative(input: MoodNarrativeInput): MoodNarrative | null {
: "insights.mood.narrative.trendDown",
vars: {
direction: delta > 0 ? "up" : "down",
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters
delta: Math.abs(delta).toFixed(1),
days: String(w),
},
@@ -248,6 +251,7 @@ function weekendNarrative(input: MoodNarrativeInput): MoodNarrative | null {
: "insights.mood.narrative.weekendDown",
vars: {
direction: delta > 0 ? "up" : "down",
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters
delta: Math.abs(delta).toFixed(1),
},
strength: Math.abs(delta),
@@ -321,6 +325,7 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] {
out.push({
kind: "tag-lift",
messageKey: "insights.mood.narrative.tagLift",
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters
vars: { ...tagVars(topLift), delta: topLift.delta.toFixed(1) },
strength: topLift.delta,
});
@@ -332,6 +337,7 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] {
messageKey: "insights.mood.narrative.tagDrop",
vars: {
...tagVars(topDrop),
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters
delta: Math.abs(topDrop.delta).toFixed(1),
},
strength: Math.abs(topDrop.delta),
@@ -427,6 +433,7 @@ function timeOfDayNarrative(input: MoodNarrativeInput): MoodNarrative | null {
messageKey: "insights.mood.narrative.timeOfDay",
vars: {
bucketKey: TIME_OF_DAY_LABEL_KEYS[best],
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- wire value; the narrative feed parses it and prints it through useFormatters
value: bestRow.avgScore.toFixed(1),
},
strength: delta,
@@ -17,6 +17,7 @@
* — with a prettified-enum fallback for any unmapped type, so a newly added
* measurement type degrades gracefully instead of leaking an enum constant.
*/
import { formatDecimal } from "@/lib/format-locale";
import type { PeriodNarrativeContext, MetricDelta } from "./period-narrative";
/** Marker stored in `InsightNarrative.providerType` for a fallback row. */
@@ -79,14 +80,9 @@ function labelFor(type: string, locale: Locale): string {
/** Format a signed number with the locale's decimal mark (no thousands sep). */
function fmtSigned(n: number, locale: Locale): string {
const sign = n > 0 ? "+" : n < 0 ? "−" : "±"; // U+2212 minus for typography
const abs = Math.abs(n);
// Integers print verbatim; fractions print at 2dp with trailing zeros
// trimmed (so 1.20 → 1.2, 1.50 → 1.5) WITHOUT touching integer zeros.
const s = Number.isInteger(abs)
? String(abs)
: abs.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
const localised = locale === "de" ? s.replace(".", ",") : s;
return `${sign}${localised}`;
// At most two decimals, trailing zeros trimmed (1.20 → 1.2), in the
// reader's notation in every language, not only German.
return `${sign}${formatDecimal(Math.abs(n), locale, 2)}`;
}
/** A delta is "notable" when it has a computable, non-zero change. */
+12 -9
View File
@@ -1,4 +1,5 @@
import type { Locale } from "@/lib/i18n/config";
import { formatDecimal } from "@/lib/format-locale";
import type { MetricSignal } from "@/lib/insights/metric-signal";
/**
@@ -66,16 +67,18 @@ interface FallbackCopy {
pointer: Localized<string>;
}
function fmtValue(value: number, copy: FallbackCopy): string {
function fmtValue(
value: number,
copy: FallbackCopy,
locale: InsightLocale,
): string {
const digits = copy.digits ?? 0;
// Round to the metric's precision, but never render a trailing ".0" — an
// integer reading (steps, a whole-number BMI day) should read as "4200",
// not "4200.0", while a genuine fractional value keeps its decimal.
// Round to the metric's precision, but never render a trailing zero — an
// integer reading (steps, a whole-number BMI day) reads as "4200", not
// "4200.0" — and print the decimal in the reader's notation ("72,5").
const factor = 10 ** digits;
const rounded = Math.round(value * factor) / factor;
const text = Number.isInteger(rounded)
? String(rounded)
: rounded.toFixed(digits);
const text = formatDecimal(rounded, locale, digits);
return copy.unit ? `${text}${copy.unit}` : text;
}
@@ -255,7 +258,7 @@ function composeGroundedFallback(
if (!signal || !Number.isFinite(signal.current)) return null;
const label = capitalise(pick(locale, copy.label));
const current = fmtValue(signal.current, copy);
const current = fmtValue(signal.current, copy, locale);
const sentences: string[] = [];
@@ -271,7 +274,7 @@ function composeGroundedFallback(
signal.delta !== 0 &&
Number.isFinite(signal.baseline)
) {
const baseline = fmtValue(signal.baseline, copy);
const baseline = fmtValue(signal.baseline, copy, locale);
sentences.push(
pick(locale, VALUE_VS_BASELINE)(
label,
@@ -6,6 +6,13 @@
* tombstones stayed for as long as the instance ran. The set is derived from
* `prisma/schema.prisma` (a `deletedAt` field), so a model that starts
* soft-deleting cannot ship without saying how long its tombstones live.
*
* The second half holds the live references. A key into a purged model that
* does not cascade is a live record pointing at the tombstone (a visit naming
* a deleted practitioner); the purge would null it and the record would stop
* showing what it showed. Each such key must be one of the entry's
* `referencedBy`, which keeps the tombstone while a live row holds it, and the
* set is derived from the schema's `@relation` lines in both directions.
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -19,10 +26,67 @@ import {
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
import {
SOFT_DELETE_RETENTION,
purgeCondition,
purgeModels,
type LiveReferrer,
type SoftDeleteRetention,
} from "@/lib/jobs/soft-delete-retention";
/**
* Every key into a purged model that does not cascade, as the register spells
* a referrer. A cascading key belongs to the row it points at (a visit's link
* rows) and goes with it; any other `onDelete` (SetNull, Prisma's default for
* an optional relation, Restrict, NoAction) leaves a live row behind.
*/
function nullingKeys(
schema: DeclaredSchema,
register: Readonly<Record<string, SoftDeleteRetention>>,
): Map<string, LiveReferrer[]> {
const byTarget = new Map<string, LiveReferrer[]>();
for (const model of schema.models) {
const softDeletes = model.fields.some((f) => f.name === "deletedAt");
for (const relation of model.relations) {
if (register[relation.target]?.kind !== "purge") continue;
if (relation.onDelete === "Cascade") continue;
for (const key of relation.fields) {
const column =
model.fields.find((f) => f.name === key)?.physicalName ?? key;
const list = byTarget.get(relation.target) ?? [];
list.push({ table: model.physicalName, column, softDeletes });
byTarget.set(relation.target, list);
}
}
}
return byTarget;
}
const refKey = (ref: LiveReferrer) =>
`${ref.table}.${ref.column}${ref.softDeletes ? "" : " (no tombstone)"}`;
function referrerProblems(
schema: DeclaredSchema,
register: Readonly<Record<string, SoftDeleteRetention>>,
): string[] {
const found: string[] = [];
const derived = nullingKeys(schema, register);
for (const [model, entry] of Object.entries(register)) {
if (entry.kind !== "purge") continue;
const want = new Set((derived.get(model) ?? []).map(refKey));
const have = new Set((entry.referencedBy ?? []).map(refKey));
for (const key of want) {
if (!have.has(key)) {
found.push(`${model}: ${key} does not cascade and is not a referrer`);
}
}
for (const key of have) {
if (!want.has(key)) {
found.push(`${model}: referrer ${key} is not a nulling key`);
}
}
}
return found;
}
function problems(
schema: DeclaredSchema,
register: Readonly<Record<string, SoftDeleteRetention>>,
@@ -91,6 +155,94 @@ describe("soft-delete retention register", () => {
}
});
it("holds every tombstone a live row still points at", () => {
const derived = nullingKeys(schema, SOFT_DELETE_RETENTION);
// A reader that stopped seeing relations would derive nothing and pass.
const count = [...derived.values()].reduce((n, l) => n + l.length, 0);
expect(count).toBeGreaterThanOrEqual(9);
expect(derived.get("Practitioner")?.map(refKey)).toEqual([
"encounters.practitioner_id",
"vaccination_records.practitioner_id",
]);
const found = referrerProblems(schema, SOFT_DELETE_RETENTION);
expect(found, found.join("\n")).toEqual([]);
});
it("turns every referrer into a live-row condition", () => {
const entry = SOFT_DELETE_RETENTION.Practitioner;
if (entry.kind !== "purge") throw new Error("Practitioner is purged");
const sql = purgeCondition(entry);
expect(sql).toContain(
`NOT EXISTS (SELECT 1 FROM "encounters" r WHERE r."practitioner_id" = t."id" AND r."deleted_at" IS NULL)`,
);
const symptoms = SOFT_DELETE_RETENTION.IllnessEpisode;
if (symptoms.kind !== "purge") throw new Error("IllnessEpisode is purged");
// A table without tombstones: every row is live.
expect(purgeCondition(symptoms)).toContain(
`NOT EXISTS (SELECT 1 FROM "symptom_events" r WHERE r."episode_id" = t."id")`,
);
expect(() =>
purgeCondition({
kind: "purge",
table: "widgets",
days: () => 75,
referencedBy: [{ table: "x; DROP", column: "a", softDeletes: true }],
}),
).toThrow("Bad referrer");
});
it("counter-check: names a new nulling key and a stale referrer", () => {
const dir = mkdtempSync(join(tmpdir(), "hl-retention-"));
try {
const path = join(dir, "schema.prisma");
writeFileSync(
path,
`model Widget {
id String @id
userId String @map("user_id")
deletedAt DateTime? @map("deleted_at")
@@map("widgets")
}
model Note {
id String @id
widgetId String? @map("widget_id")
ownerId String? @map("owner_id")
deletedAt DateTime? @map("deleted_at")
widget Widget? @relation(fields: [widgetId], references: [id], onDelete: SetNull)
owner Widget? @relation("Owned", fields: [ownerId], references: [id])
@@map("notes")
}
model Part {
id String @id
widgetId String @map("widget_id")
widget Widget @relation(fields: [widgetId], references: [id], onDelete: Cascade)
@@map("parts")
}
`,
);
const synthetic = readDeclaredSchema(path);
const found = referrerProblems(synthetic, {
Widget: {
kind: "purge",
table: "widgets",
days: () => 75,
referencedBy: [
{ table: "notes", column: "widget_id", softDeletes: true },
{ table: "gone", column: "widget_id", softDeletes: false },
],
},
});
// The SetNull key is handled, the default-`onDelete` key is not, the
// cascading key is owned, and the stale referrer is named.
expect(found).toEqual([
"Widget: notes.owner_id does not cascade and is not a referrer",
"Widget: referrer gone.widget_id (no tombstone) is not a nulling key",
]);
} finally {
rmSync(dir, { recursive: true });
}
});
it("counter-check: names a new soft-delete model, a stale entry and a wrong table", () => {
const dir = mkdtempSync(join(tmpdir(), "hl-retention-"));
try {
+2 -1
View File
@@ -129,7 +129,8 @@ function explainTimeout(message: string, expireSeconds: number | null): string {
const limit =
expireSeconds !== null && expireSeconds > 0
? expireSeconds >= 3600 && expireSeconds % 3600 === 0
? `${expireSeconds / 3600} h`
? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- operator log text, a whole number of hours (checked above)
`${expireSeconds / 3600} h`
: `${Math.round(expireSeconds / 60)} min`
: null;
return (
+100 -3
View File
@@ -46,6 +46,15 @@
* before the first rebuild.
* - Every step's size before and after, and its duration, are on the run's
* wide event; the totals are job facts.
*
* Retired indexes. An index the schema no longer declares is dropped by the
* migration that retires it only when the lock is there at once (a `DO`
* block with a two-second `lock_timeout` that swallows the timeout), so the
* boot never waits on a long reader of the table. Where the migration had to
* leave it, every run of this job, the nightly tick included, drops it with
* `DROP INDEX CONCURRENTLY IF EXISTS` under a bounded lock wait and tries
* again the next night if the wait runs out. Runbook:
* `docs/ops/measurement-maintenance.md`, "Retired indexes".
*/
import { Client } from "pg";
import type { Job } from "pg-boss";
@@ -153,6 +162,22 @@ export function maintenanceSessionStatements(): string[] {
});
}
/**
* Indexes on `measurements` the schema no longer declares, which a run drops
* when the migration that retired them could not. Each one's migration names
* it; `IF EXISTS` makes the drop a no-op once it is gone.
*/
export const RETIRED_MEASUREMENT_INDEXES: readonly string[] = [
// 0416: a prefix of the unique key `(user_id, type, measured_at, source,
// sleep_stage)`, which plans every query it served at the same cost.
"measurements_user_id_type_measured_at_idx",
];
/** How long a run waits for the locks that drop a retired index. */
export const RETIRED_INDEX_LOCK_TIMEOUT = "60s";
const LOCK_TIMEOUT_VALUE = /^\d{1,6}(ms|s)$/;
/** The only shape an index name may have before it is spliced into SQL. */
const INDEX_NAME = /^[a-z0-9_]{1,63}$/;
@@ -162,7 +187,7 @@ const CONCURRENT_COPY = /_ccnew\d*$|_ccold\d*$/;
const PURGE_QUEUE = "compaction-tombstone-purge";
export interface MaintenanceStep {
step: "vacuum" | "reindex" | "drop_invalid";
step: "vacuum" | "reindex" | "drop_invalid" | "drop_retired";
target: string;
bytesBefore: number;
bytesAfter: number;
@@ -336,6 +361,62 @@ async function dropInvalidCopies(
}
}
/**
* Drop every retired index still present. `DROP INDEX CONCURRENTLY` takes no
* lock that stops reads or writes, but it waits for every transaction that
* may use the index, so the wait is bounded for the statement and the
* session's own setting is put back after it. A timed-out drop may leave the
* index marked invalid; the next run's `IF EXISTS` drop finishes it.
*/
export async function dropRetiredIndexes(
db: MaintenanceConnection,
steps: MaintenanceStep[],
lockTimeout: string = RETIRED_INDEX_LOCK_TIMEOUT,
): Promise<void> {
if (!LOCK_TIMEOUT_VALUE.test(lockTimeout)) {
throw new Error(`refusing lock timeout of unexpected shape`);
}
for (const name of RETIRED_MEASUREMENT_INDEXES) {
const bytesBefore = await indexBytes(db, name);
const { rows } = await db.query<{ present: boolean }>(
`SELECT to_regclass($1) IS NOT NULL AS present`,
[name],
);
if (!rows[0]?.present) continue;
const { rows: setting } = await db.query<{ value: string }>(
`SELECT current_setting('lock_timeout') AS value`,
);
const startedAt = Date.now();
try {
await db.query(`SET lock_timeout = '${lockTimeout}'`);
await db.query(`DROP INDEX CONCURRENTLY IF EXISTS ${quoted(name)}`);
steps.push({
step: "drop_retired",
target: name,
bytesBefore,
bytesAfter: 0,
durationMs: Date.now() - startedAt,
ok: true,
});
} catch (err) {
logCaught("measurements.maintenance.drop_retired_failed", err);
steps.push({
step: "drop_retired",
target: name,
bytesBefore,
bytesAfter: await indexBytes(db, name),
durationMs: Date.now() - startedAt,
ok: false,
error: errorText(err),
});
} finally {
await db.query(`SELECT set_config('lock_timeout', $1, false)`, [
setting[0]?.value ?? "0",
]);
}
}
}
/**
* The pass itself, on a connection the caller opened with
* {@link MAINTENANCE_SESSION_OPTIONS}. Exported for the integration test.
@@ -343,7 +424,7 @@ async function dropInvalidCopies(
export async function runMeasurementMaintenance(
db: MaintenanceConnection,
payload: MaintenanceSteps,
options: { shouldStop?: () => boolean } = {},
options: { shouldStop?: () => boolean; retiredLockTimeout?: string } = {},
): Promise<MeasurementMaintenanceSummary> {
const before = await relationSizes(db);
const steps: MaintenanceStep[] = [];
@@ -359,6 +440,10 @@ export async function runMeasurementMaintenance(
indexBytesAfter: after.indexes,
});
// Every run, the nightly tick included: a retired index costs every write
// until it is gone, and the drop blocks no reader or writer.
await dropRetiredIndexes(db, steps, options.retiredLockTimeout);
if (!payload.vacuum && !payload.reindex) return summary("nothing_due");
// The queue table exists wherever a worker has started; a database without
@@ -406,7 +491,10 @@ export async function runMeasurementMaintenance(
if (payload.reindex) {
await dropInvalidCopies(db, steps);
const targets = (await measurementIndexes(db)).filter(
(index) => index.valid && !CONCURRENT_COPY.test(index.name),
(index) =>
index.valid &&
!CONCURRENT_COPY.test(index.name) &&
!RETIRED_MEASUREMENT_INDEXES.includes(index.name),
);
for (const index of targets) {
if (options.shouldStop?.()) break;
@@ -509,6 +597,15 @@ export async function handleMeasurementMaintenance(
);
}
}
const retiredLeft = result.steps.filter(
(s) => s.step === "drop_retired" && !s.ok,
);
if (retiredLeft.length > 0) {
evt.addMeta("maintenance_retired_index_pending", true);
evt.addWarning(
`retired index ${retiredLeft.map((s) => s.target).join(", ")} could not be dropped within ${RETIRED_INDEX_LOCK_TIMEOUT} (a long transaction on measurements); the next nightly run tries again`,
);
}
evt.addMeta("maintenance_table_bytes_before", result.tableBytesBefore);
evt.addMeta("maintenance_table_bytes_after", result.tableBytesAfter);
evt.addMeta("maintenance_index_bytes_before", result.indexBytesBefore);
+1
View File
@@ -425,6 +425,7 @@ export function defaultOffhostObjectLimit(): number {
function size(bytes: number): string {
const mb = bytes / 1024 / 1024;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- operator log text about a backup size
return mb < 100 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`;
}
+81 -10
View File
@@ -24,9 +24,14 @@
* purged measurement.
*
* The delete runs per account under the restore lock, in bounded batches
* (`purgeTombstonesByAccount`). Foreign keys into these tables cascade or set
* null (none restricts), so a purged visit takes its link rows with it and a
* live vaccination that named it simply loses the pointer.
* (`purgeTombstonesByAccount`). A key that cascades belongs to the row it
* points at: a purged visit takes its link rows with it. A key that does not
* cascade is a live record's reference, and a live record keeps what it shows:
* a visit still names the practitioner it was with after the address-book
* entry is deleted, so that entry is not purged while a live visit or dose
* names it. Those references are each entry's `referencedBy`, and
* `soft-delete-retention-register.test.ts` derives the set from the schema,
* so a new nulling key into a purged model cannot ship without one.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
@@ -37,6 +42,19 @@ import {
type TombstonePurgeOutcome,
} from "@/lib/jobs/measurement-tombstone-cleanup";
/**
* A key into a purged model that does not cascade. While a row that is not
* itself tombstoned holds it, the row it names stays.
*/
export interface LiveReferrer {
/** The physical table holding the key; a literal, spliced into the SQL. */
table: string;
/** The key column; a literal, spliced into the SQL. */
column: string;
/** Whether the referring table soft-deletes (a tombstoned row is not live). */
softDeletes: boolean;
}
export type SoftDeleteRetention =
| { kind: "sync-feed"; days: number }
| { kind: "document-grace"; days: number }
@@ -51,14 +69,31 @@ export type SoftDeleteRetention =
* the alias `t`. For a tombstone something live still depends on.
*/
keep?: string;
/** The live references that hold a tombstone back. */
referencedBy?: readonly LiveReferrer[];
/** Why the horizon or the condition differs from the default. */
why?: string;
};
const standard = (table: string): SoftDeleteRetention => ({
const standard = (
table: string,
referencedBy?: readonly LiveReferrer[],
): SoftDeleteRetention => ({
kind: "purge",
table,
days: () => TOMBSTONE_RETENTION_DAYS,
...(referencedBy ? { referencedBy } : {}),
});
const live = (table: string, column: string): LiveReferrer => ({
table,
column,
softDeletes: true,
});
const always = (table: string, column: string): LiveReferrer => ({
table,
column,
softDeletes: false,
});
export const SOFT_DELETE_RETENTION: Readonly<
@@ -75,6 +110,7 @@ export const SOFT_DELETE_RETENTION: Readonly<
kind: "purge",
table: "menstrual_cycles",
days: () => TOMBSTONE_RETENTION_DAYS,
referencedBy: [live("cycle_day_logs", "cycle_id")],
keep: `NOT EXISTS (SELECT 1 FROM "menstrual_cycles" a
WHERE a."id" = t."absorbed_into_id"
AND a."deleted_at" IS NULL)`,
@@ -88,26 +124,61 @@ export const SOFT_DELETE_RETENTION: Readonly<
},
MentalHealthAssessment: standard("mental_health_assessments"),
MeasurementReminder: standard("measurement_reminders"),
MeasurementReminder: standard("measurement_reminders", [
live("encounters", "reminder_id"),
live("vaccination_records", "reminder_id"),
]),
LabResult: standard("lab_results"),
CustomMetric: standard("custom_metrics"),
CustomMetricEntry: standard("custom_metric_entries"),
CoachPlan: standard("coach_plans"),
CoachReminder: standard("coach_reminders"),
IllnessEpisode: standard("illness_episodes"),
IllnessEpisode: standard("illness_episodes", [
live("illness_episodes", "parent_condition_id"),
always("symptom_events", "episode_id"),
]),
IllnessDayLog: standard("illness_day_logs"),
Allergy: standard("allergies"),
FamilyHistoryEntry: standard("family_history_entries"),
Encounter: standard("encounters"),
Practitioner: standard("practitioners"),
Encounter: standard("encounters", [
live("vaccination_records", "encounter_id"),
]),
Practitioner: standard("practitioners", [
live("encounters", "practitioner_id"),
live("vaccination_records", "practitioner_id"),
]),
VaccinationRecord: standard("vaccination_records"),
CustomVaccine: standard("custom_vaccines"),
CustomVaccine: standard("custom_vaccines", [
live("vaccination_records", "custom_vaccine_id"),
]),
LifeEvent: standard("life_events"),
};
const TABLE = /^[a-z_]+$/;
const DAY_MS = 86_400_000;
/**
* The condition a purgeable row must meet, over the alias `t`: the entry's
* own `keep`, and no live row holding a key to it. Every identifier comes
* from the register's literals and is asserted against a closed pattern.
*/
export function purgeCondition(
entry: Extract<SoftDeleteRetention, { kind: "purge" }>,
): string {
const parts: string[] = [];
if (entry.keep) parts.push(entry.keep);
for (const ref of entry.referencedBy ?? []) {
if (!TABLE.test(ref.table) || !TABLE.test(ref.column)) {
throw new Error(`Bad referrer ${ref.table}.${ref.column}`);
}
const alive = ref.softDeletes ? ` AND r."deleted_at" IS NULL` : "";
parts.push(
`NOT EXISTS (SELECT 1 FROM "${ref.table}" r WHERE r."${ref.column}" = t."id"${alive})`,
);
}
return parts.map((part) => `AND ${part}`).join(" ");
}
/** One table's purge, for the run report. */
export interface SoftDeletePurgeLeg extends TombstonePurgeOutcome {
model: string;
@@ -130,7 +201,7 @@ export async function purgeSoftDeletedTable(
}
const table = `"${entry.table}"`;
const cutoff = new Date(now.getTime() - entry.days() * DAY_MS);
const keep = entry.keep ? `AND ${entry.keep}` : "";
const keep = purgeCondition(entry);
const outcome = await purgeTombstonesByAccount({
prisma,
findRows: (take, skipUserIds) =>
+1
View File
@@ -17,6 +17,7 @@ export function valueEditSeed(value: number): string {
MAX_SEED_DECIMALS,
Math.max(DISPLAY_DECIMALS, SEED_SIGNIFICANT_FIGURES - 1 - magnitude),
);
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- seeds an input whose parser reads the full stop back
return value
.toFixed(decimals)
.replace(/(\.\d*?)0+$/, "$1")
@@ -190,11 +190,13 @@ describe("get_visits", () => {
expect(result.visits[0].conditions).toEqual(["Knee pain"]);
});
it("does not name a deleted practitioner", async () => {
it("names the practitioner a live visit was with, even once deleted from the address book", async () => {
// The web record and the doctor report show it; the agent reads the same
// record (the address-book entry is kept while a live visit names it).
const result = (await tool("get_visits").run(CTX, {})) as {
visits: Array<{ practitioner: string | null; specialty: string | null }>;
};
expect(result.visits[0].practitioner).toBeNull();
expect(result.visits[0].specialty).toBeNull();
expect(result.visits[0].practitioner).toBe("Dr. Gone");
expect(result.visits[0].specialty).toBe("Cardiology");
});
});
+13 -3
View File
@@ -797,16 +797,26 @@ describe("short query words", () => {
});
describe("practitioners and planned visits", () => {
it("does not match or show a deleted practitioner's name", async () => {
it("finds and names the practitioner a live visit was with, even once deleted from the address book", async () => {
// Deleting the entry takes it out of the address book, not out of the
// visit: the web record shows it, and so does the agent's read.
store.encounter[0].practitioner = {
name: "Dr. Weber",
specialty: "Orthopädie",
deletedAt: new Date(),
};
expect((await search(ME, "weber")).results.map((r) => r.id)).toEqual([
"visit:v-knee",
]);
const hit = await fetchId(ME, "visit:v-knee");
expect(hit.metadata.practitioner).toBe("Dr. Weber");
expect(hit.text).toContain("Weber");
});
it("counter-check: a visit with no practitioner names none", async () => {
store.encounter[0].practitioner = null;
expect((await search(ME, "weber")).results).toHaveLength(0);
const hit = await fetchId(ME, "visit:v-knee");
expect(hit.metadata.practitioner).toBeNull();
expect(hit.text).not.toContain("Weber");
});
it("lists past visits, not future planned ones, for an empty query", async () => {
+13 -14
View File
@@ -322,27 +322,26 @@ function visitTitle(args: {
}
/**
* The practitioner columns a record read selects. `deletedAt` rides along so
* a practitioner the person removed contributes nothing: the visit keeps its
* scalar id, but the name is no longer something the record says.
* The practitioner columns a record read selects. A live visit or dose names
* the practitioner it was with even after the address-book entry is deleted,
* as the web record and the doctor report do: deleting the entry takes it out
* of the address book and the pickers, not out of the history. The purge keeps
* the entry for as long as a live record names it (`soft-delete-retention.ts`).
*/
const PRACTITIONER_SELECT = {
select: { name: true, specialty: true, deletedAt: true },
select: { name: true, specialty: true },
} as const;
type PractitionerRow = {
name: string;
specialty: string | null;
deletedAt: Date | null;
} | null;
/** The practitioner as the record may still show it, or null. */
export function livePractitioner(
/** The practitioner a live visit or dose names, or null. */
export function visitPractitioner(
row: PractitionerRow | undefined,
): { name: string; specialty: string | null } | null {
return row && row.deletedAt === null
? { name: row.name, specialty: row.specialty }
: null;
return row ? { name: row.name, specialty: row.specialty } : null;
}
async function visitCandidates(
@@ -368,7 +367,7 @@ async function visitCandidates(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const practitioner = livePractitioner(row.practitioner);
const practitioner = visitPractitioner(row.practitioner);
const reason = decryptText(row.reasonEncrypted);
const outcome = decryptText(row.outcomeEncrypted);
const bodySite = decryptText(row.bodySiteEncrypted);
@@ -728,7 +727,7 @@ async function vaccinationCandidates(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const practitioner = livePractitioner(row.practitioner);
const practitioner = visitPractitioner(row.practitioner);
const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug });
const own = ownVaccine(row);
const catalogEn = vaccineCatalogName(row.antigenSlug, "en");
@@ -1098,7 +1097,7 @@ async function fetchVisit(
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.occurredAt, owner.timezone);
const kindLabel = encounterKindLabel(row.kind, "en");
const live = livePractitioner(row.practitioner);
const live = visitPractitioner(row.practitioner);
const practitioner = live?.name ?? null;
const specialty = live?.specialty ?? null;
@@ -1378,7 +1377,7 @@ async function fetchVaccination(
const own = ownVaccine(row);
const name =
row.vaccineName ?? catalog ?? own?.name ?? row.antigenSlug ?? "Vaccination";
const practitioner = livePractitioner(row.practitioner);
const practitioner = visitPractitioner(row.practitioner);
const day = dateOnly(row.occurredAt);
const dose =
row.doseNumber !== null
+8 -14
View File
@@ -2236,19 +2236,15 @@ export const MCP_TOOLS: McpToolDefinition[] = [
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take,
include: {
practitioner: {
select: { name: true, specialty: true, deletedAt: true },
},
practitioner: { select: { name: true, specialty: true } },
},
}),
);
const appointments = appointmentRows.map((r) => {
const reason = decryptVisitText(r.reasonEncrypted);
// A practitioner the person deleted is no longer named.
const practitioner =
r.practitioner && r.practitioner.deletedAt === null
? r.practitioner
: null;
// A live visit names its practitioner even after the address-book
// entry is deleted, as the web record does.
const practitioner = r.practitioner;
return {
occurredAt: r.occurredAt.toISOString(),
practitioner: practitioner
@@ -2578,7 +2574,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
take,
include: {
practitioner: {
select: { name: true, specialty: true, deletedAt: true },
select: { name: true, specialty: true },
},
},
}),
@@ -2613,11 +2609,9 @@ export const MCP_TOOLS: McpToolDefinition[] = [
const reason = decryptVisitText(r.reasonEncrypted);
const outcome = decryptVisitText(r.outcomeEncrypted);
const bodySite = decryptVisitText(r.bodySiteEncrypted);
// A practitioner the person deleted is no longer named.
const seenBy =
r.practitioner && r.practitioner.deletedAt === null
? r.practitioner
: null;
// A live visit names its practitioner even after the address-book
// entry is deleted, as the web record does.
const seenBy = r.practitioner;
return {
occurredAt: r.occurredAt.toISOString(),
status: r.status,
@@ -219,3 +219,65 @@ describe("no display source spells a word unit as a literal", () => {
).toEqual(["breaths/min"]);
});
});
/**
* A component that keeps its own table of unit words is a second source the
* shared one cannot correct: three insight boards each mapped their display
* kinds to `*.unit<Word>` bundle keys, and the German cycle board printed
* "S/min" where every other screen says "bpm". A unit beside a measurement
* value comes from `measurementUnitLabel`; an object literal holding two or
* more unit-word keys is refused.
*/
describe("no display source keeps its own unit table", () => {
const SRC = join(process.cwd(), "src");
const roots = ["app", "components", "hooks"];
const BLOCK = /=\s*\{([^{}]*)\}/g;
const UNIT_KEY = /:\s*"[\w.]+\.unit[A-Z]\w*"/g;
function unitTables(source: string): number[] {
const sizes: number[] = [];
for (const block of source.matchAll(BLOCK)) {
const n = [...block[1].matchAll(UNIT_KEY)].length;
if (n >= 2) sizes.push(n);
}
return sizes;
}
const offenders: string[] = [];
let scanned = 0;
for (const root of roots) {
const dir = join(SRC, root);
for (const rel of walkSourceFiles(dir, {
floor: root === "hooks" ? 50 : 500,
})) {
if (rel.includes("__tests__/") || rel.startsWith("api/")) continue;
scanned += 1;
const source = stripComments(readFileSync(join(dir, rel), "utf8"));
for (const size of unitTables(source)) {
offenders.push(`${root}/${rel}: a table of ${size} unit keys`);
}
}
}
it("reads the display tree", () => {
expect(scanned).toBeGreaterThan(1000);
});
it("finds none", () => {
expect(offenders).toEqual([]);
});
it("counter-check: would catch the boards' old table, not a single key", () => {
expect(
unitTables(`const UNIT_KEY: Record<D, string> = {
hours: "cycle.insights.crosstab.unitHours",
bpm: "cycle.insights.crosstab.unitBpm",
};`),
).toEqual([2]);
expect(
unitTables(
`const entry = { value: "SLEEP", unitKey: "measurements.unitHours" };`,
),
).toEqual([]);
});
});
+32 -1
View File
@@ -118,9 +118,40 @@ const documentChatDetailSchema = documentChatConversationSchema
})
.meta({ id: "DocumentChatDetail" });
/** One thread in the document's rail: the conversation row the Coach rail reads. */
const documentChatListItemSchema = documentChatConversationSchema
.extend({
fenced: z
.boolean()
.describe(
"Always true here: a chat about a document is a fenced thread (no tools, no health snapshot).",
),
attachments: z
.array(
z.object({
documentId: z.string(),
title: z
.string()
.nullable()
.describe(
"The attached document's title, falling back to its filename; null when it has neither.",
),
}),
)
.optional()
.describe("The documents the thread is about, as they are now."),
documentTitle: z
.string()
.nullish()
.describe(
"The first attached document's title, for a one-line badge; null when none is attached any more.",
),
})
.meta({ id: "DocumentChatListItem" });
const documentChatListSchema = z
.object({
conversations: z.array(documentChatConversationSchema),
conversations: z.array(documentChatListItemSchema),
nextCursor: z.string().nullable(),
})
.meta({ id: "DocumentChatList" });
+1 -1
View File
@@ -705,7 +705,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "Soft-delete a practitioner",
description:
"Stamps `deletedAt`. Every visit that named this entry survives with no practice attached, because the reference nulls rather than cascades. Idempotent. Audits as `practitioner.contact.delete`.",
"Stamps `deletedAt`. The entry leaves the address book and the pickers; every live visit and vaccination that named it keeps showing it, and the tombstone is kept while one does. Idempotent. Audits as `practitioner.contact.delete`.",
requestParams: idPath,
responses: {
...recordRefusal(),
+1 -1
View File
@@ -373,7 +373,7 @@ export const stdResponses = {
export const recordWriteRateLimitResponse = {
"429": {
description:
"Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `record-write:<accountId>`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Only the bulk ingest endpoints, which meter themselves with a dedicated bucket, stay outside it. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.",
"Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `record-write:<accountId>`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Only the routes that meter themselves with a dedicated bucket (the bulk ingest endpoints and the model-turn chats) and a POST that writes nothing stay outside it. If the bucket cannot be read, the write goes ahead unmetered rather than failing. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.",
content: { "application/json": { schema: errorEnvelope } },
headers: stdResponses["429"].headers,
},
+1
View File
@@ -46,6 +46,7 @@ const timezoneSchema = z
.max(64)
.refine((timezone) => {
try {
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- validates a zone name and formats nothing
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch {
+1 -10
View File
@@ -14,12 +14,7 @@
*/
import { z } from "zod/v4";
import {
isReportLeafId,
orderLeaves,
type ReportLeafId,
ALL_LEAF_IDS,
} from "./catalogue";
import { isReportLeafId, type ReportLeafId, ALL_LEAF_IDS } from "./catalogue";
declare const brand: unique symbol;
@@ -121,7 +116,3 @@ export function selectionToBlob(
export function isEmptySelection(selection: ReportSelection): boolean {
return selection.leaves.length === 0;
}
// The ordering helper lives beside the catalogue it orders, so a client
// panel that only folds its own state does not pull the schema library in.
export { orderLeaves };
-1
View File
@@ -24,7 +24,6 @@ import {
// Coach and the settings pages do not pull the schema library in with them.
// Re-exported here for the server code that imports them from the schema.
export {
COACH_DATA_CLUSTERS,
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachReasoningLevel,
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more