mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'fix/v1438-r2' into release/v1.43.8
This commit is contained in:
commit
72ce7a0ead
108 files changed
+2529
-793
No files matched your search
+40
-12
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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$;
|
||||
@@ -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
@@ -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, [
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
})),
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
Reference in new issue
Block a user