mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
500 files changed
+15127
-7043
No files matched your search
@@ -1,5 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
## [1.43.8] — 2026-10-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Numbers and units read in your language.** Decimals follow your language
|
||||
everywhere: fever, cycle and mood figures, custom metrics, nutrient totals,
|
||||
chart tooltips and the sentences the server writes show 37,5 instead of 37.5
|
||||
where your language uses a comma. Units such as breaths per minute, years
|
||||
and minutes read in the app's language instead of the stored English token,
|
||||
and the cycle and mood comparison boards use the same unit names as the rest
|
||||
of the app (bpm, not S/min). File sizes read the same in the document vault
|
||||
and the admin area.
|
||||
- **Titles wrap instead of being cut.** Tile and card titles wrap onto a second
|
||||
line with hyphenation instead of ending in an ellipsis, with room between
|
||||
the lines, and tiles in a grid keep their values on one line when a long
|
||||
title wraps. Dashboard trend tiles keep their full label on up to two lines.
|
||||
Long health-score area names, mood tags, document titles, lab names and
|
||||
token names show in full, and a long field label keeps its colon on the
|
||||
last word.
|
||||
- **One look for the same control.** Range, filter and choice toggles (time
|
||||
ranges, card or table view, recent or all) look and behave the same
|
||||
everywhere. Small text links and buttons such as "Open schedule", "Select
|
||||
all" and "View all" are easy to tap on a phone and show a focus ring.
|
||||
Confidence, score and range chips share one look, and medium confidence has
|
||||
the same colour on every card. Empty lists and charts say "nothing here yet"
|
||||
the same way across the app. Page titles on the insights pages, the
|
||||
medication detail and the check-in screens match every other page title,
|
||||
and compact cards keep their tight spacing on wide screens.
|
||||
- The blood-pressure target card labels its systolic bar as well as its
|
||||
diastolic one, and the sleep-stage legend lists only the stages the visible
|
||||
nights contain.
|
||||
- The Dawarich card shows one action row; set your home place from the card
|
||||
menu.
|
||||
- **Every published GET response is checked against its OpenAPI schema.**
|
||||
Thirteen operations that disagreed now publish what they send, the single
|
||||
mood entry and custom metric reads carry the same fields as their lists, and
|
||||
the document chat list declares its `fenced`, `attachments` and
|
||||
`documentTitle` fields.
|
||||
- **Lists say when they were cut.** Practitioners, vaccinations and encounters
|
||||
report a cut list and accept `offset`, and the web reads them to the end.
|
||||
`hasMore` on symptom events is no longer true for a page that is exactly
|
||||
full. MCP tools say when a list was cut and answer a failure with a stable
|
||||
code instead of internal error text.
|
||||
- **A deleted practitioner stays named where it still matters.** Visits and
|
||||
doses that still show a practitioner removed from the address book keep the
|
||||
name, in the app, the MCP read and appointment reminders, and the portable
|
||||
export carries such a practitioner along so the visit keeps its reference
|
||||
after a restore.
|
||||
- Renouncing a Guardian role while that Guardian's account is being deleted no
|
||||
longer answers "add another Guardian".
|
||||
|
||||
### Changed
|
||||
|
||||
- **Deleted records are removed for good after 75 days.** Deleted lab results,
|
||||
visits, practitioners, allergies, vaccinations, conditions, life events,
|
||||
cycles and the other soft-deleted records can be restored for 75 days and
|
||||
are then purged by a nightly job. A deleted record that a live record still
|
||||
points to is kept for as long as it does.
|
||||
- Expired sessions, tokens, trusted devices and sign-in challenges are cleaned
|
||||
up nightly.
|
||||
- **Writes have one per-account limit** of 300 a minute, edits and deletes
|
||||
included; bulk sync endpoints and the chat routes keep their own limits. A
|
||||
temporary database problem with this limit lets the write through instead of
|
||||
failing every save; the sign-in limits stay strict.
|
||||
- **Cheaper writes.** Deleting a medication or an account no longer scans whole
|
||||
tables for related rows, redundant indexes are gone, and writes to
|
||||
measurements are cheaper. An unused measurement index is dropped at upgrade
|
||||
when that can happen at once and otherwise by the nightly maintenance, so
|
||||
upgrading no longer waits on a long-running database read. The unused
|
||||
per-day medication compliance rollup table is removed; nothing changes in
|
||||
what you see.
|
||||
- **Migrations are steadier.** They run in a UTC session regardless of the
|
||||
database's default time zone, so a migration that mixes `timestamp` and
|
||||
`timestamptz` no longer writes shifted values on a non-UTC database. A
|
||||
migration marked safe to re-run that loses a deadlock is retried like one
|
||||
that timed out on a lock, and the container health check allows up to ten
|
||||
minutes for a slow migration before reporting the container unhealthy.
|
||||
- `/api/feature-flags` announces its deprecation and its sunset on 2027-03-31.
|
||||
- The document vault and the Coach load less JavaScript.
|
||||
|
||||
## [1.43.7] — 2026-10-20
|
||||
|
||||
### Added
|
||||
|
||||
+3
-1
@@ -318,7 +318,9 @@ EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
# Start period: the entrypoint migrates before the server listens, and a
|
||||
# migration waiting out a held lock retries for up to about eight minutes.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"routeBaselineKbGz": {
|
||||
"/page": 355,
|
||||
"/insights/page": 304,
|
||||
"/insights/page": 323,
|
||||
"/measurements/page": 335,
|
||||
"/insights/mood/page": 334,
|
||||
"/settings/[section]/page": 307
|
||||
},
|
||||
"routeDriftAllowanceKbGz": 20,
|
||||
"totalClientKbGz": 3700,
|
||||
"totalClientKbGz": 3755,
|
||||
"maxRechartsChunks": 1,
|
||||
"maxCatalogKbGz": 210
|
||||
}
|
||||
+7
-1
@@ -516,7 +516,13 @@ services:
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
# The entrypoint applies migrations before the server listens. A
|
||||
# migration that waits out a held lock retries for up to about eight
|
||||
# minutes (docs/ops/deploy.md), and failed probes in that time must not
|
||||
# read as a dead container. The first probe that answers still ends
|
||||
# the start period, so a normal boot is healthy within 30 seconds of
|
||||
# listening.
|
||||
start_period: 10m
|
||||
|
||||
# Optional dedicated worker container — uncomment + set
|
||||
# HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment.
|
||||
|
||||
+821
-228
File diff suppressed because it is too large.
Load diff
@@ -40,6 +40,15 @@ not reachable". The entrypoint also runs the migrations on every boot, so
|
||||
step 2 is optional; it is there for when you want to see the migration
|
||||
result before the new container takes traffic.
|
||||
|
||||
The entrypoint runs every migration in a UTC session, the zone the app's own
|
||||
connections use, whatever the database's default `TimeZone` is: it adds
|
||||
`-c TimeZone=UTC` to the `options` parameter of `DATABASE_URL` for the Prisma
|
||||
calls (an `options` value you set yourself is kept, with the pin after it).
|
||||
Behind a connection pooler that refuses `options`, the
|
||||
`DATABASE_SESSION_OPTIONS_DISABLED` switch turns this off along with the
|
||||
app's own session settings; set the database or role default to UTC there
|
||||
(`ALTER DATABASE healthlog SET timezone = 'UTC'`).
|
||||
|
||||
### A migration that timed out on a lock
|
||||
|
||||
Some migrations bound how long they wait for a table lock, so that a
|
||||
@@ -59,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
|
||||
@@ -70,6 +84,12 @@ does the same. Nothing else is retried: any other failure, and a lock timeout
|
||||
in a migration that does not carry the marker, stops the boot exactly as
|
||||
before.
|
||||
|
||||
The retries fit inside the container's health check: its start period is ten
|
||||
minutes (`docker-compose.yml` and the image's own `HEALTHCHECK`), so failed
|
||||
probes while the entrypoint is still migrating do not mark the container
|
||||
unhealthy. The first probe that answers ends the start period, so a normal
|
||||
boot is reported healthy within half a minute of the server listening.
|
||||
|
||||
If all five retries time out, the log ends with
|
||||
`HealthLog: ERROR — migration … timed out on a lock 6 times in a row; giving
|
||||
up.` and the container exits. Something has held the table for about eight
|
||||
|
||||
@@ -33,9 +33,9 @@ metadata (`medication_schedules`):
|
||||
- After upgrading to v1.15.19+, when a user reports a compliance rate above
|
||||
100 %, duplicate entries in the medication history, or a dose shown as
|
||||
taken days away from its slot.
|
||||
- The compliance rollup counts DISTINCT slots since v1.15.19, so the
|
||||
percentages self-correct on recompute even without running this script.
|
||||
Running it additionally clears the phantom rows from the history view.
|
||||
- Compliance counts DISTINCT slots since v1.15.19, so the percentages are
|
||||
right even without running this script. Running it additionally clears
|
||||
the phantom rows from the history view.
|
||||
- The `intake-slot-dedup` worker already collapses canonical-slot
|
||||
duplicates at boot and on a daily cron; this script is the manual,
|
||||
immediately-observable fallback and the only path that surfaces the
|
||||
@@ -104,13 +104,9 @@ healthlog-tsx scripts/repair-intake-anomalies.ts --fix
|
||||
as the duplicate losers). A medication without any derivable anchor
|
||||
(PRN-only / unscheduled) is skipped — there is nothing to compare
|
||||
against.
|
||||
- **Affected compliance rollups are recomputed** for every touched
|
||||
`(user, medication, day)`. The script executes the same DISTINCT-slot
|
||||
aggregation SQL the shared helper runs (a verbatim twin of
|
||||
`recomputeMedicationComplianceForDay` in
|
||||
`src/lib/rollups/medication-compliance-rollups.ts` — kept inline so
|
||||
the script needs no app imports), so the scheduled/taken counts and
|
||||
the rate self-correct immediately.
|
||||
- **No separate recompute is needed.** Every compliance reader folds the
|
||||
rows that share one slot, so the scheduled/taken counts and the rate are
|
||||
right as soon as the duplicates are tombstoned.
|
||||
|
||||
The script is idempotent: a second `--fix` run finds zero duplicate groups
|
||||
and changes nothing. Exit code is 0 on success (including a clean
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,8 +90,11 @@ different things depending on which button you press.
|
||||
it out and it is gone from the app, from exports, and from the doctor
|
||||
report. The row itself stays in PostgreSQL as a tombstone, which is
|
||||
what lets a phone that was offline at delete time learn that the entry
|
||||
went away. No job removes cycle tombstones on a schedule, so the row
|
||||
stays until one of the two actions below.
|
||||
went away. A nightly job removes a tombstone 75 days after the delete
|
||||
(the same horizon as the sync feed's); until then, or until one of the
|
||||
two actions below, the row stays. A start that was folded into a later
|
||||
cycle stays for as long as that cycle does, because deleting the later
|
||||
cycle gives it back.
|
||||
- **Settings → Delete All Data** removes the rows outright, tombstones
|
||||
included. Everything the account owns goes except what you sign in
|
||||
with; the confirmation dialog lists it.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Unit tests for the `healthlog/no-raw-card-shell` rule — a card surface is a
|
||||
* <Card> or a shell primitive. Covers the three-part match, the translucent
|
||||
* and inner-tile radii that stay legal, cn()/template strings, the allow
|
||||
* option, and the scope gating.
|
||||
*/
|
||||
|
||||
import { describe, it } from "vitest";
|
||||
import { RuleTester } from "eslint";
|
||||
import rule from "../no-raw-card-shell.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
RuleTester.itOnly = it.only;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const APP_FILE = "/repo/src/components/insights/example.tsx";
|
||||
|
||||
ruleTester.run("no-raw-card-shell", rule, {
|
||||
valid: [
|
||||
{ code: '<Card className="gap-3" />', filename: APP_FILE },
|
||||
// An inner tile: another radius.
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-lg border p-3" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// The translucent hero glass and inner washes are not the card colour.
|
||||
{
|
||||
code: '<div className="bg-card/65 rounded-xl border p-4 backdrop-blur-sm" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// No border: not a shell.
|
||||
{ code: '<div className="bg-card rounded-xl p-4" />', filename: APP_FILE },
|
||||
// The primitives under src/components/ui own the shell.
|
||||
{
|
||||
code: '<div className="bg-card border rounded-xl p-4" />',
|
||||
filename: "/repo/src/components/ui/trend-tile.tsx",
|
||||
},
|
||||
// An allowed file.
|
||||
{
|
||||
code: '<div className="bg-card border rounded-xl p-4" />',
|
||||
filename: "/repo/src/components/charts/health-chart.tsx",
|
||||
options: [{ allow: ["src/components/charts/health-chart.tsx"] }],
|
||||
},
|
||||
// Outside the UI roots and in tests.
|
||||
{
|
||||
code: 'const c = "bg-card border rounded-xl";',
|
||||
filename: "/repo/src/lib/x.ts",
|
||||
},
|
||||
{
|
||||
code: '<div className="bg-card border rounded-xl" />',
|
||||
filename: "/repo/src/components/__tests__/x.test.tsx",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border p-4 md:p-6" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "rawShell" }],
|
||||
},
|
||||
{
|
||||
code: 'cn("bg-card hover:bg-accent/40 block rounded-xl border p-4", className)',
|
||||
filename: "/repo/src/app/insights/pulse/page.tsx",
|
||||
errors: [{ messageId: "rawShell" }],
|
||||
},
|
||||
{
|
||||
code: "const c = `bg-card rounded-xl border-dashed ${x}`;",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "rawShell" }],
|
||||
},
|
||||
// An allow entry for another file does not cover this one.
|
||||
{
|
||||
code: '<div className="bg-card border rounded-xl" />',
|
||||
filename: APP_FILE,
|
||||
options: [{ allow: ["src/components/charts/health-chart.tsx"] }],
|
||||
errors: [{ messageId: "rawShell" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Unit tests for the `healthlog/no-unlocalised-decimal` rule — a displayed
|
||||
* number goes through the app's formatters. Covers the four flagged idioms,
|
||||
* the rounding round-trip that stays legal, the browser time zone lookup, the
|
||||
* directive contract, and the scope gating.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Linter, RuleTester } from "eslint";
|
||||
import rule from "../no-unlocalised-decimal.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
RuleTester.itOnly = it.only;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const APP_FILE = "/repo/src/components/insights/example.tsx";
|
||||
const PAGE_FILE = "/repo/src/app/(app)/pulse/page.tsx";
|
||||
|
||||
ruleTester.run("no-unlocalised-decimal", rule, {
|
||||
valid: [
|
||||
// The formatter is the sanctioned path.
|
||||
{ code: "const s = fmt.number(value, 1);", filename: APP_FILE },
|
||||
// Rounding, not display: the result is a number again.
|
||||
{ code: "const n = Number(value.toFixed(2));", filename: APP_FILE },
|
||||
{ code: "const n = parseFloat(value.toFixed(2));", filename: APP_FILE },
|
||||
{
|
||||
code: "const n = Number.parseFloat(value.toFixed(2));",
|
||||
filename: APP_FILE,
|
||||
},
|
||||
{ code: "const n = +value.toFixed(2);", filename: APP_FILE },
|
||||
// A locale is passed.
|
||||
{ code: 'const s = n.toLocaleString("de-DE");', filename: APP_FILE },
|
||||
{
|
||||
code: "const s = d.toLocaleDateString(locale, opts);",
|
||||
filename: APP_FILE,
|
||||
},
|
||||
{ code: "const f = new Intl.NumberFormat(locale);", filename: APP_FILE },
|
||||
// The browser time zone lookup formats nothing.
|
||||
{
|
||||
code: "const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;",
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// Integer arithmetic in text is fine.
|
||||
{ code: "const s = `${Math.round(a / b)} h`;", filename: APP_FILE },
|
||||
{ code: "const s = String(Math.round(a / b));", filename: APP_FILE },
|
||||
// A tagged template is not text.
|
||||
{ code: "const q = sql`${a / b}`;", filename: APP_FILE },
|
||||
// Wire formats under src/app/api are not display.
|
||||
{
|
||||
code: "const s = value.toFixed(2);",
|
||||
filename: "/repo/src/app/api/export/route.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",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "const s = value.toFixed(1);",
|
||||
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,
|
||||
errors: [{ messageId: "fixed" }],
|
||||
},
|
||||
{
|
||||
code: "const s = value.toPrecision(3);",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "fixed", data: { method: "toPrecision" } }],
|
||||
},
|
||||
// Number() around something else does not launder the call.
|
||||
{
|
||||
code: "const s = String(Number(a) + value.toFixed(1));",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "fixed" }],
|
||||
},
|
||||
{
|
||||
code: "<span>{total.toLocaleString()}</span>",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "localeless" }],
|
||||
},
|
||||
{
|
||||
code: "const s = d.toLocaleDateString(undefined, { day: 'numeric' });",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "localeless" }],
|
||||
},
|
||||
{
|
||||
code: "const s = d.toLocaleTimeString();",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "localeless" }],
|
||||
},
|
||||
{
|
||||
code: "const f = new Intl.NumberFormat();",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "intlLocaleless" }],
|
||||
},
|
||||
{
|
||||
code: "const f = new Intl.DateTimeFormat(undefined, { hour: '2-digit' });",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "intlLocaleless" }],
|
||||
},
|
||||
{
|
||||
code: "const s = String(Math.round(v * 10) / 10);",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "division" }],
|
||||
},
|
||||
{
|
||||
code: "const s = `${total / count} per day`;",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "division" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Directives resolve against the rule's registered name, which RuleTester
|
||||
* does not use, so they run through a Linter with the plugin registered as
|
||||
* `healthlog` — the way the flat config loads it.
|
||||
*/
|
||||
describe("no-unlocalised-decimal disable directives", () => {
|
||||
const linter = new Linter({ configType: "flat" });
|
||||
const config = [
|
||||
{
|
||||
files: ["**/*.tsx"],
|
||||
plugins: { healthlog: { rules: { "no-unlocalised-decimal": rule } } },
|
||||
rules: { "healthlog/no-unlocalised-decimal": "error" },
|
||||
languageOptions: {
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
];
|
||||
const lint = (code) =>
|
||||
linter
|
||||
.verify(code, config, { filename: "src/components/charts/x.tsx" })
|
||||
.map((m) => m.messageId ?? m.message);
|
||||
|
||||
it("flags the site without a directive", () => {
|
||||
expect(lint("const d = `${x.toFixed(1)}`;")).toEqual(["fixed"]);
|
||||
});
|
||||
|
||||
it("accepts a next-line directive that gives a reason", () => {
|
||||
expect(
|
||||
lint(
|
||||
"// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, not text\nconst d = `${x.toFixed(1)}`;",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a directive without a reason", () => {
|
||||
expect(
|
||||
lint(
|
||||
"// eslint-disable-next-line healthlog/no-unlocalised-decimal\nconst d = x.toFixed(1);",
|
||||
),
|
||||
).toEqual(["missingReason"]);
|
||||
});
|
||||
|
||||
it("refuses a whole-file disable, which would hide the next site too", () => {
|
||||
expect(
|
||||
lint(
|
||||
"const a = 1;\n/* eslint-disable healthlog/no-unlocalised-decimal -- SVG coordinates, not text */\nconst d = x.toFixed(1);",
|
||||
),
|
||||
).toEqual(["nextLineOnly"]);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,11 @@
|
||||
* slicing an ISO string or parsing UTC midnight.
|
||||
* - `no-planning-tags` — no planning-note identifier at the head of a
|
||||
* comment line.
|
||||
* - `no-unlocalised-decimal` — a displayed number goes through the app's
|
||||
* formatters, not toFixed() or a locale-less
|
||||
* toLocaleString().
|
||||
* - `no-raw-card-shell` — a card surface is a <Card> or a shell
|
||||
* primitive, not bg-card + border + rounded-xl.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
@@ -29,6 +34,8 @@ const jobHandlerOutcome = require("./job-handler-outcome.js");
|
||||
const noDefaultZoneLiteral = require("./no-default-zone-literal.js");
|
||||
const noUtcDayKey = require("./no-utc-day-key.js");
|
||||
const noPlanningTags = require("./no-planning-tags.js");
|
||||
const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js");
|
||||
const noRawCardShell = require("./no-raw-card-shell.js");
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
@@ -46,5 +53,7 @@ module.exports = {
|
||||
"no-default-zone-literal": noDefaultZoneLiteral,
|
||||
"no-utc-day-key": noUtcDayKey,
|
||||
"no-planning-tags": noPlanningTags,
|
||||
"no-unlocalised-decimal": noUnlocalisedDecimal,
|
||||
"no-raw-card-shell": noRawCardShell,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — a card surface is a `<Card>` (or one of the
|
||||
* shell primitives), not a hand-rolled `bg-card` + `border` + `rounded-xl`.
|
||||
*
|
||||
* UI standards §1: "New surfaces compose `<Card>` (or `SettingsCard` in
|
||||
* Settings)". A hand-rolled shell drifts on its own: one had `p-4` with no
|
||||
* `md:p-6`, one `py-6` with no horizontal inset, one a header row of its own
|
||||
* instead of `TileHeader`, and every one of them sat beside real cards whose
|
||||
* padding, gap and shadow it did not share.
|
||||
*
|
||||
* FLAGGED, under `src/components/` and `src/app/` outside
|
||||
* `src/components/ui/` and test files: one string literal whose class list
|
||||
* carries `bg-card` (the opaque card colour; a translucent `bg-card/NN` is an
|
||||
* inner tile or the sanctioned hero glass), a `border` utility, and
|
||||
* `rounded-xl` (the card radius; `rounded-lg` / `rounded-md` are inner tiles
|
||||
* and controls, §1).
|
||||
*
|
||||
* The files where a shell is the point (the shell primitives themselves, the
|
||||
* hero identity surface, popovers, the sign-in card that renders outside the
|
||||
* app shell, the chart frame its skeleton and error state share) are passed
|
||||
* in the `allow` option, each with its reason in the flat config. There is no
|
||||
* inline escape: a new exception is a reviewed change to that list.
|
||||
*
|
||||
* WHAT THIS RULE DOES NOT CATCH
|
||||
*
|
||||
* A shell assembled from separate literals (`cn("bg-card", "border",
|
||||
* "rounded-xl")`), one painted through a CSS class, or one in another radius.
|
||||
* It holds the idiom the sweep removed, not every way to draw a box.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const ENFORCED_ROOTS = ["src/components/", "src/app/"];
|
||||
const EXEMPT_ROOTS = ["src/components/ui/"];
|
||||
|
||||
const BG_CARD_RE = /(?:^|\s)bg-card(?=\s|$)/;
|
||||
const BORDER_RE = /(?:^|\s)border(?:-[a-z0-9]|(?=\s|$))/;
|
||||
const RADIUS_RE = /(?:^|\s)rounded-xl(?=\s|$)/;
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isTestFile(posix) {
|
||||
return (
|
||||
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
posix.includes("/__tests__/") ||
|
||||
posix.includes("/__mocks__/")
|
||||
);
|
||||
}
|
||||
|
||||
function isShell(value) {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
BG_CARD_RE.test(value) &&
|
||||
BORDER_RE.test(value) &&
|
||||
RADIUS_RE.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
const noRawCardShellRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Compose <Card> (or a shell primitive) instead of a hand-rolled bg-card + border + rounded-xl shell.",
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
allow: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
rawShell:
|
||||
"Hand-rolled card shell (bg-card + border + rounded-xl). Compose <Card> (with TileHeader inside), SettingsCard in Settings, or TrendTile for a trend tile (UI standards §1). A deliberate exception goes into the rule's `allow` list in eslint.config.mjs with its reason.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = toPosix(context.filename ?? context.getFilename?.() ?? "");
|
||||
if (!ENFORCED_ROOTS.some((root) => filename.includes(root))) return {};
|
||||
if (EXEMPT_ROOTS.some((root) => filename.includes(root))) return {};
|
||||
if (isTestFile(filename)) return {};
|
||||
const allow = (context.options[0] && context.options[0].allow) || [];
|
||||
if (allow.some((suffix) => filename.endsWith(suffix))) return {};
|
||||
|
||||
function check(node, value) {
|
||||
if (isShell(value)) context.report({ node, messageId: "rawShell" });
|
||||
}
|
||||
|
||||
return {
|
||||
Literal(node) {
|
||||
check(node, node.value);
|
||||
},
|
||||
TemplateElement(node) {
|
||||
check(node, node.value.cooked ?? node.value.raw);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = noRawCardShellRule;
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — a number shown to a person goes through the
|
||||
* app's locale, never through the JavaScript default.
|
||||
*
|
||||
* `value.toFixed(1)` always prints a full stop. A German, French, Italian,
|
||||
* Spanish, Dutch or Polish reader expects a comma (`37,5 °C`), and the same
|
||||
* screen already shows a comma wherever the value went through
|
||||
* `useFormatters()`. The mixed reading is the defect: one card says `70,5 kg`,
|
||||
* the card beside it `70.5 kg`. `toLocaleString()` without a locale is the
|
||||
* mirror image of the same mistake — it follows the browser, not the language
|
||||
* the person picked in HealthLog.
|
||||
*
|
||||
* The legitimate forms:
|
||||
*
|
||||
* - in a component: `const fmt = useFormatters(); fmt.number(value, 1)`
|
||||
* (`fmt.integer`, `fmt.percent` for the other shapes)
|
||||
* - outside React: `makeFormatters(locale, …)` from `@/lib/format-locale`
|
||||
* - 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/`, `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
|
||||
* the operand of a unary `+`
|
||||
* - `<x>.toLocaleString()`, `toLocaleDateString()`, `toLocaleTimeString()`
|
||||
* called with no locale or with `undefined` as the locale
|
||||
* - `Intl.NumberFormat()` / `Intl.DateTimeFormat()` constructed with no
|
||||
* locale or with `undefined` (reading `.resolvedOptions()` off it, the
|
||||
* browser time zone lookup, formats nothing and passes)
|
||||
* - a division rendered straight into text: `String(a / b)` and
|
||||
* `` `${a / b}` `` — the quotient is fractional and prints with a stop
|
||||
*
|
||||
* A site where the stop is the point (SVG path data, a value written into a
|
||||
* machine-read attribute) stays, with the reason on the line above:
|
||||
*
|
||||
* // eslint-disable-next-line healthlog/no-unlocalised-decimal -- <why>
|
||||
*
|
||||
* The rule refuses a directive for itself without a reason, or in any form
|
||||
* other than `eslint-disable-next-line`.
|
||||
*
|
||||
* WHAT THIS RULE DOES NOT CATCH
|
||||
*
|
||||
* Without type information it cannot tell a number from a string, so
|
||||
* `String(weight)` or `` `${weight} kg` `` with a fractional `weight` passes,
|
||||
* as does a quotient assigned to a variable first and rendered later. It
|
||||
* removes the idioms that print a stop by construction, not every path to
|
||||
* one.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const RULE_NAME = "healthlog/no-unlocalised-decimal";
|
||||
const ENFORCED_ROOTS = [
|
||||
"src/components/",
|
||||
"src/app/",
|
||||
"src/hooks/",
|
||||
"src/lib/",
|
||||
];
|
||||
const EXEMPT_ROOTS = ["src/app/api/"];
|
||||
const MIN_REASON_CHARS = 8;
|
||||
|
||||
const FIXED_METHODS = new Set(["toFixed", "toPrecision"]);
|
||||
const LOCALE_METHODS = new Set([
|
||||
"toLocaleString",
|
||||
"toLocaleDateString",
|
||||
"toLocaleTimeString",
|
||||
]);
|
||||
const INTL_CTORS = new Set(["NumberFormat", "DateTimeFormat"]);
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isTestFile(posix) {
|
||||
return (
|
||||
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
posix.includes("/__tests__/") ||
|
||||
posix.includes("/__mocks__/")
|
||||
);
|
||||
}
|
||||
|
||||
function isEnforced(filename) {
|
||||
const posix = toPosix(filename);
|
||||
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
|
||||
if (EXEMPT_ROOTS.some((root) => posix.includes(root))) return false;
|
||||
if (isTestFile(posix)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function propertyName(member) {
|
||||
if (!member || member.type !== "MemberExpression") return null;
|
||||
if (!member.computed && member.property.type === "Identifier") {
|
||||
return member.property.name;
|
||||
}
|
||||
if (member.computed && member.property.type === "Literal") {
|
||||
return String(member.property.value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isUndefined(node) {
|
||||
return (
|
||||
!node ||
|
||||
(node.type === "Identifier" && node.name === "undefined") ||
|
||||
(node.type === "UnaryExpression" && node.operator === "void")
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip TS-only wrappers and parentheses-equivalents the parser keeps. */
|
||||
function unwrap(node) {
|
||||
let current = node;
|
||||
while (
|
||||
current &&
|
||||
(current.type === "TSAsExpression" ||
|
||||
current.type === "TSNonNullExpression" ||
|
||||
current.type === "TSSatisfiesExpression" ||
|
||||
current.type === "ChainExpression")
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** The call's value is turned back into a number: rounding, not display. */
|
||||
function isNumericRoundTrip(callNode) {
|
||||
let child = callNode;
|
||||
let parent = callNode.parent;
|
||||
while (
|
||||
parent &&
|
||||
(parent.type === "TSAsExpression" ||
|
||||
parent.type === "TSNonNullExpression" ||
|
||||
parent.type === "ChainExpression")
|
||||
) {
|
||||
child = parent;
|
||||
parent = parent.parent;
|
||||
}
|
||||
if (!parent) return false;
|
||||
if (parent.type === "UnaryExpression" && parent.operator === "+") {
|
||||
return true;
|
||||
}
|
||||
if (parent.type !== "CallExpression" || parent.arguments[0] !== child) {
|
||||
return false;
|
||||
}
|
||||
const callee = parent.callee;
|
||||
if (callee.type === "Identifier") {
|
||||
return callee.name === "Number" || callee.name === "parseFloat";
|
||||
}
|
||||
return (
|
||||
callee.type === "MemberExpression" &&
|
||||
callee.object.type === "Identifier" &&
|
||||
callee.object.name === "Number" &&
|
||||
propertyName(callee) === "parseFloat"
|
||||
);
|
||||
}
|
||||
|
||||
function isDivision(node) {
|
||||
const inner = unwrap(node);
|
||||
return !!inner && inner.type === "BinaryExpression" && inner.operator === "/";
|
||||
}
|
||||
|
||||
function isIntlCtor(callee) {
|
||||
return (
|
||||
callee &&
|
||||
callee.type === "MemberExpression" &&
|
||||
callee.object.type === "Identifier" &&
|
||||
callee.object.name === "Intl" &&
|
||||
INTL_CTORS.has(propertyName(callee))
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
const noUnlocalisedDecimalRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Format a displayed number through useFormatters() / makeFormatters(), not toFixed() or a locale-less toLocaleString().",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
fixed:
|
||||
"`{{method}}()` prints a full stop in every language. Format the value with `useFormatters().number(value, digits)` (or `makeFormatters` outside React). For rounding, wrap it in `Number(…)`. If the stop is meant, say why: `// eslint-disable-next-line healthlog/no-unlocalised-decimal -- <reason>`.",
|
||||
localeless:
|
||||
"`{{method}}()` without a locale follows the browser, not the language picked in HealthLog. Use `useFormatters()` or pass the active locale.",
|
||||
intlLocaleless:
|
||||
"`Intl.{{ctor}}` without a locale follows the browser, not the language picked in HealthLog. Use `useFormatters()` or pass the active locale.",
|
||||
division:
|
||||
"A quotient rendered straight into text prints with a full stop. Format it with `useFormatters().number(value, digits)`.",
|
||||
missingReason:
|
||||
"A disable directive for healthlog/no-unlocalised-decimal must say why the full stop is right here: `-- <reason>` after the rule name.",
|
||||
nextLineOnly:
|
||||
"Disable healthlog/no-unlocalised-decimal only with `eslint-disable-next-line`, one site at a time, so the next occurrence is still caught.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename ?? context.getFilename?.();
|
||||
if (!filename || !isEnforced(filename)) {
|
||||
return {};
|
||||
}
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
function checkDirectives() {
|
||||
for (const comment of sourceCode.getAllComments()) {
|
||||
const text = comment.value.trim();
|
||||
const match = /^eslint-disable(-next-line|-line)?\b([\s\S]*)$/.exec(
|
||||
text,
|
||||
);
|
||||
if (!match || !match[2].includes(RULE_NAME)) continue;
|
||||
if (match[1] !== "-next-line") {
|
||||
context.report({
|
||||
loc: { line: 1, column: 0 },
|
||||
messageId: "nextLineOnly",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const reason = match[2].split(/\s--\s/)[1];
|
||||
if (!reason || reason.trim().length < MIN_REASON_CHARS) {
|
||||
context.report({ loc: comment.loc, messageId: "missingReason" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkIntl(node) {
|
||||
// `Intl.DateTimeFormat().resolvedOptions().timeZone` reads the
|
||||
// browser's zone; nothing is formatted, so the missing locale is right.
|
||||
const readsOptionsOnly =
|
||||
node.parent &&
|
||||
node.parent.type === "MemberExpression" &&
|
||||
node.parent.object === node &&
|
||||
propertyName(node.parent) === "resolvedOptions";
|
||||
if (
|
||||
!readsOptionsOnly &&
|
||||
isIntlCtor(node.callee) &&
|
||||
isUndefined(node.arguments[0])
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "intlLocaleless",
|
||||
data: { ctor: propertyName(node.callee) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"Program:exit": checkDirectives,
|
||||
CallExpression(node) {
|
||||
const name = propertyName(node.callee);
|
||||
if (name && FIXED_METHODS.has(name)) {
|
||||
if (!isNumericRoundTrip(node)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "fixed",
|
||||
data: { method: name },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (name && LOCALE_METHODS.has(name)) {
|
||||
if (isUndefined(node.arguments[0])) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "localeless",
|
||||
data: { method: name },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
node.callee.type === "Identifier" &&
|
||||
node.callee.name === "String" &&
|
||||
isDivision(node.arguments[0])
|
||||
) {
|
||||
context.report({ node, messageId: "division" });
|
||||
return;
|
||||
}
|
||||
checkIntl(node);
|
||||
},
|
||||
NewExpression: checkIntl,
|
||||
TemplateLiteral(node) {
|
||||
if (node.parent && node.parent.type === "TaggedTemplateExpression") {
|
||||
return;
|
||||
}
|
||||
for (const expression of node.expressions) {
|
||||
if (isDivision(expression)) {
|
||||
context.report({ node: expression, messageId: "division" });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = noUnlocalisedDecimalRule;
|
||||
@@ -121,6 +121,61 @@ const eslintConfig = defineConfig([
|
||||
// scripts/strip-planning-tags.mjs; see eslint-plugins/healthlog/
|
||||
// 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 / 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>`;
|
||||
// the rule refuses a directive without one. See the rule header.
|
||||
"healthlog/no-unlocalised-decimal": "error",
|
||||
// A card surface is a <Card> (or SettingsCard / TrendTile), never a
|
||||
// hand-rolled bg-card + border + rounded-xl (UI standards §1). The
|
||||
// files below are where a shell is the point; each says why. A new
|
||||
// exception is a reviewed change to this list, there is no inline
|
||||
// escape. See the rule header.
|
||||
"healthlog/no-raw-card-shell": [
|
||||
"error",
|
||||
{
|
||||
allow: [
|
||||
// The shell primitives themselves.
|
||||
"src/components/settings/settings-card.tsx",
|
||||
"src/components/medications/medication-detail-section.tsx",
|
||||
// The sanctioned identity surface of the dashboard (§1).
|
||||
"src/components/daily/today-hero.tsx",
|
||||
"src/components/daily/today-hero-skeleton.tsx",
|
||||
// Floating surfaces: a chart tooltip, the tour popovers, the
|
||||
// wizard's icon tile.
|
||||
"src/components/charts/chart-tooltip.tsx",
|
||||
"src/components/onboarding/tour.tsx",
|
||||
"src/components/medications/wizard/medication-wizard-dialog.tsx",
|
||||
// The chart frame: chart, skeleton and error state paint the
|
||||
// same frame so a chart never moves when it loads or fails.
|
||||
"src/components/charts/health-chart.tsx",
|
||||
"src/components/charts/chart-skeleton.tsx",
|
||||
"src/components/charts/chart-error-state.tsx",
|
||||
"src/components/charts/medication-compliance-chart.tsx",
|
||||
"src/components/insights/derived/score-trend-chart.tsx",
|
||||
"src/components/insights/intraday-pulse-chart.tsx",
|
||||
// Table frames: a clipped, unpadded border around a table.
|
||||
"src/components/medications/medication-table.tsx",
|
||||
"src/app/notifications/page.tsx",
|
||||
// Surfaces that render outside the app shell: sign-in, register,
|
||||
// claim, the key-mismatch gate.
|
||||
"src/app/auth/login/page.tsx",
|
||||
"src/app/auth/register/page.tsx",
|
||||
"src/components/auth/claim-profile.tsx",
|
||||
"src/app/key-mismatch-page.tsx",
|
||||
// The first-run flow: full-screen steps with their own rhythm.
|
||||
"src/components/onboarding/anamnesis-card.tsx",
|
||||
"src/components/onboarding/baseline-fields.tsx",
|
||||
"src/components/onboarding/confirm-screen.tsx",
|
||||
"src/components/onboarding/done-screen.tsx",
|
||||
"src/components/onboarding/first-result-screen.tsx",
|
||||
"src/components/onboarding/getting-started-checklist.tsx",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// v1.28.17 — every recharts-rendering component funnels through the
|
||||
|
||||
+15
-40
@@ -2597,7 +2597,8 @@
|
||||
"complianceHeatmap": "Heatmap zur Medikamenteneinnahme von {start} bis {end}, {days} aktive Tage.",
|
||||
"complianceHeatmapEmpty": "Heatmap zur Medikamenteneinnahme, noch keine aktiven Tage."
|
||||
},
|
||||
"medicationsEmptyAction": "Medikament anlegen"
|
||||
"medicationsEmptyAction": "Medikament anlegen",
|
||||
"rangeLabel": "Zeitraum"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -4220,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",
|
||||
@@ -4238,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",
|
||||
@@ -6673,21 +6662,12 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Verbunden",
|
||||
"errorReconnect": "Fehler: neu verbinden",
|
||||
"warningServerError": "Sync fehlgeschlagen",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Pausiert: neu verbinden",
|
||||
"notConnected": "Nicht verbunden",
|
||||
"justNow": "gerade eben",
|
||||
"minutesAgo": "vor {count} min",
|
||||
"hoursAgo": "vor {count} Std.",
|
||||
"daysAgo": "vor {count} d",
|
||||
"resumeSuccess": "Integration wieder aktiv",
|
||||
"resumeError": "Wieder verbinden fehlgeschlagen",
|
||||
"stale": "Verbunden, keine neuen Daten",
|
||||
"stalled": "Sync gestoppt",
|
||||
"pendingSetup": "Warte auf erste Daten"
|
||||
"resumeError": "Wieder verbinden fehlgeschlagen"
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Empfangene Daten",
|
||||
@@ -7756,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.",
|
||||
@@ -7884,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.",
|
||||
@@ -8909,6 +8891,7 @@
|
||||
"inTarget": "Im Zielbereich",
|
||||
"aboveTarget": "{delta} {unit} über Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Zielbereich",
|
||||
"systolic": "Systolisch",
|
||||
"diastolic": "Diastolisch",
|
||||
"glucoseFasting": "Glukose, nüchtern",
|
||||
"glucosePostprandial": "Glukose, postprandial",
|
||||
@@ -9165,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."
|
||||
@@ -9175,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",
|
||||
@@ -9185,15 +9168,7 @@
|
||||
"metricWristTemperature": "Handgelenkstemperatur nachts",
|
||||
"metricSkinTemperature": "Hauttemperatur",
|
||||
"metricBloodGlucose": "Blutzucker",
|
||||
"metricMood": "Stimmung",
|
||||
"unitHours": "Std.",
|
||||
"unitSteps": "Schritte",
|
||||
"unitBpm": "S/min",
|
||||
"unitMs": "ms",
|
||||
"unitMood": "Pkt.",
|
||||
"confidenceLow": "Geringe Sicherheit",
|
||||
"confidenceMedium": "Mittlere Sicherheit",
|
||||
"confidenceHigh": "Hohe Sicherheit"
|
||||
"metricMood": "Stimmung"
|
||||
},
|
||||
"title": "Zyklus-Erkenntnisse",
|
||||
"symptomPatternsTitle": "Symptommuster nach Phase",
|
||||
|
||||
+15
-40
@@ -2597,7 +2597,8 @@
|
||||
"complianceHeatmap": "Medication compliance heatmap from {start} to {end}, {days} active days.",
|
||||
"complianceHeatmapEmpty": "Medication compliance heatmap, no active days yet."
|
||||
},
|
||||
"medicationsEmptyAction": "Add a medication"
|
||||
"medicationsEmptyAction": "Add a medication",
|
||||
"rangeLabel": "Time range"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -4220,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",
|
||||
@@ -4238,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",
|
||||
@@ -6673,21 +6662,12 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Connected",
|
||||
"errorReconnect": "Error: reconnect",
|
||||
"warningServerError": "Sync failing",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Paused: reconnect",
|
||||
"notConnected": "Not connected",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{count} min ago",
|
||||
"hoursAgo": "{count} h ago",
|
||||
"daysAgo": "{count} d ago",
|
||||
"resumeSuccess": "Integration resumed",
|
||||
"resumeError": "Reconnect failed",
|
||||
"stale": "Connected, no recent data",
|
||||
"stalled": "Sync stopped",
|
||||
"pendingSetup": "Waiting for first data"
|
||||
"resumeError": "Reconnect failed"
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Data received",
|
||||
@@ -7756,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.",
|
||||
@@ -7884,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.",
|
||||
@@ -8909,6 +8891,7 @@
|
||||
"inTarget": "Within target range",
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
"systolic": "Systolic",
|
||||
"diastolic": "Diastolic",
|
||||
"glucoseFasting": "Glucose (fasting)",
|
||||
"glucosePostprandial": "Glucose (post-meal)",
|
||||
@@ -9165,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."
|
||||
@@ -9175,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",
|
||||
@@ -9185,15 +9168,7 @@
|
||||
"metricWristTemperature": "Overnight wrist temperature",
|
||||
"metricSkinTemperature": "Skin temperature",
|
||||
"metricBloodGlucose": "Blood glucose",
|
||||
"metricMood": "Mood",
|
||||
"unitHours": "h",
|
||||
"unitSteps": "steps",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitMood": "pts",
|
||||
"confidenceLow": "Low confidence",
|
||||
"confidenceMedium": "Medium confidence",
|
||||
"confidenceHigh": "High confidence"
|
||||
"metricMood": "Mood"
|
||||
},
|
||||
"title": "Cycle insights",
|
||||
"symptomPatternsTitle": "Symptom patterns by phase",
|
||||
|
||||
+15
-40
@@ -2597,7 +2597,8 @@
|
||||
"complianceHeatmap": "Mapa de calor de cumplimiento de medicación de {start} a {end}, {days} días activos.",
|
||||
"complianceHeatmapEmpty": "Mapa de calor de cumplimiento de medicación, aún no hay días activos."
|
||||
},
|
||||
"medicationsEmptyAction": "Añadir un medicamento"
|
||||
"medicationsEmptyAction": "Añadir un medicamento",
|
||||
"rangeLabel": "Periodo"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -4220,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",
|
||||
@@ -4238,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",
|
||||
@@ -6673,21 +6662,12 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Conectado",
|
||||
"errorReconnect": "Error: reconectar",
|
||||
"warningServerError": "Sincronización con errores",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Pausado: reconectar",
|
||||
"notConnected": "No conectado",
|
||||
"justNow": "ahora mismo",
|
||||
"minutesAgo": "hace {count} min",
|
||||
"hoursAgo": "hace {count} h",
|
||||
"daysAgo": "hace {count} d",
|
||||
"resumeSuccess": "Integración reanudada",
|
||||
"resumeError": "Reconexión fallida",
|
||||
"stale": "Conectado, sin datos recientes",
|
||||
"stalled": "Sincronización detenida",
|
||||
"pendingSetup": "Esperando los primeros datos"
|
||||
"resumeError": "Reconexión fallida"
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Datos recibidos",
|
||||
@@ -7756,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.",
|
||||
@@ -7884,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.",
|
||||
@@ -8909,6 +8891,7 @@
|
||||
"inTarget": "En rango objetivo",
|
||||
"aboveTarget": "{delta} {unit} por encima del rango",
|
||||
"belowTarget": "{delta} {unit} por debajo del rango",
|
||||
"systolic": "Sistólica",
|
||||
"diastolic": "Diastólica",
|
||||
"glucoseFasting": "Glucemia (en ayunas)",
|
||||
"glucosePostprandial": "Glucemia (posprandial)",
|
||||
@@ -9165,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."
|
||||
@@ -9175,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",
|
||||
@@ -9185,15 +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",
|
||||
"confidenceLow": "Confianza baja",
|
||||
"confidenceMedium": "Confianza media",
|
||||
"confidenceHigh": "Confianza alta"
|
||||
"metricMood": "Estado de ánimo"
|
||||
},
|
||||
"title": "Análisis del ciclo",
|
||||
"symptomPatternsTitle": "Patrones de síntomas por fase",
|
||||
|
||||
+15
-40
@@ -2597,7 +2597,8 @@
|
||||
"complianceHeatmap": "Carte thermique d'observance des médicaments du {start} au {end}, {days} jours actifs.",
|
||||
"complianceHeatmapEmpty": "Carte thermique d'observance des médicaments, aucun jour actif pour l'instant."
|
||||
},
|
||||
"medicationsEmptyAction": "Ajouter un médicament"
|
||||
"medicationsEmptyAction": "Ajouter un médicament",
|
||||
"rangeLabel": "Période"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -4220,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",
|
||||
@@ -4238,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",
|
||||
@@ -6673,21 +6662,12 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Connecté",
|
||||
"errorReconnect": "Erreur : reconnecter",
|
||||
"warningServerError": "Échec de synchronisation",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "En pause : reconnecter",
|
||||
"notConnected": "Non connecté",
|
||||
"justNow": "à l’instant",
|
||||
"minutesAgo": "il y a {count} min",
|
||||
"hoursAgo": "il y a {count} h",
|
||||
"daysAgo": "il y a {count} j",
|
||||
"resumeSuccess": "Intégration reprise",
|
||||
"resumeError": "Échec de la reconnexion",
|
||||
"stale": "Connecté, aucune donnée récente",
|
||||
"stalled": "Synchronisation arrêtée",
|
||||
"pendingSetup": "En attente des premières données"
|
||||
"resumeError": "Échec de la reconnexion"
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Données reçues",
|
||||
@@ -7756,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.",
|
||||
@@ -7884,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.",
|
||||
@@ -8909,6 +8891,7 @@
|
||||
"inTarget": "Dans la plage cible",
|
||||
"aboveTarget": "{delta} {unit} au-dessus de la plage",
|
||||
"belowTarget": "{delta} {unit} en dessous de la plage",
|
||||
"systolic": "Systolique",
|
||||
"diastolic": "Diastolique",
|
||||
"glucoseFasting": "Glycémie (à jeun)",
|
||||
"glucosePostprandial": "Glycémie (postprandiale)",
|
||||
@@ -9165,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."
|
||||
@@ -9175,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",
|
||||
@@ -9185,15 +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",
|
||||
"confidenceLow": "Confiance faible",
|
||||
"confidenceMedium": "Confiance moyenne",
|
||||
"confidenceHigh": "Confiance élevée"
|
||||
"metricMood": "Humeur"
|
||||
},
|
||||
"title": "Analyses du cycle",
|
||||
"symptomPatternsTitle": "Tendances des symptômes par phase",
|
||||
|
||||
+15
-40
@@ -2597,7 +2597,8 @@
|
||||
"complianceHeatmap": "Mappa di calore dell'aderenza ai farmaci dal {start} al {end}, {days} giorni attivi.",
|
||||
"complianceHeatmapEmpty": "Mappa di calore dell'aderenza ai farmaci, nessun giorno attivo."
|
||||
},
|
||||
"medicationsEmptyAction": "Aggiungi un farmaco"
|
||||
"medicationsEmptyAction": "Aggiungi un farmaco",
|
||||
"rangeLabel": "Periodo"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -4220,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",
|
||||
@@ -4238,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",
|
||||
@@ -6673,21 +6662,12 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Connesso",
|
||||
"errorReconnect": "Errore, riconnetti",
|
||||
"warningServerError": "Sincronizzazione in errore",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "In pausa, riconnetti",
|
||||
"notConnected": "Non connesso",
|
||||
"justNow": "proprio ora",
|
||||
"minutesAgo": "{count} min fa",
|
||||
"hoursAgo": "{count} h fa",
|
||||
"daysAgo": "{count} g fa",
|
||||
"resumeSuccess": "Integrazione ripresa",
|
||||
"resumeError": "Riconnessione fallita",
|
||||
"stale": "Connesso, nessun dato recente",
|
||||
"stalled": "Sincronizzazione ferma",
|
||||
"pendingSetup": "In attesa dei primi dati"
|
||||
"resumeError": "Riconnessione fallita"
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Dati ricevuti",
|
||||
@@ -7756,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.",
|
||||
@@ -7884,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.",
|
||||
@@ -8909,6 +8891,7 @@
|
||||
"inTarget": "Nel range obiettivo",
|
||||
"aboveTarget": "{delta} {unit} sopra il range",
|
||||
"belowTarget": "{delta} {unit} sotto il range",
|
||||
"systolic": "Sistolica",
|
||||
"diastolic": "Diastolica",
|
||||
"glucoseFasting": "Glicemia (a digiuno)",
|
||||
"glucosePostprandial": "Glicemia (postprandiale)",
|
||||
@@ -9165,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."
|
||||
@@ -9175,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",
|
||||
@@ -9185,15 +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",
|
||||
"confidenceLow": "Affidabilità bassa",
|
||||
"confidenceMedium": "Affidabilità media",
|
||||
"confidenceHigh": "Affidabilità alta"
|
||||
"metricMood": "Umore"
|
||||
},
|
||||
"title": "Analisi del ciclo",
|
||||
"symptomPatternsTitle": "Pattern dei sintomi per fase",
|
||||
|
||||
+15
-40
@@ -2597,7 +2597,8 @@
|
||||
"days90Title": "최근 90일",
|
||||
"daysAllLabel": "전체",
|
||||
"daysAllTitle": "전체 기간",
|
||||
"medicationsEmptyAction": "약 추가하기"
|
||||
"medicationsEmptyAction": "약 추가하기",
|
||||
"rangeLabel": "기간"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -4220,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": "직접 평가한 요인과 생체 지표",
|
||||
@@ -4238,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": "눈에 띄는 점",
|
||||
@@ -6673,21 +6662,12 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "연결됨",
|
||||
"errorReconnect": "오류: 다시 연결",
|
||||
"warningServerError": "동기화 실패 중",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "일시중지: 다시 연결",
|
||||
"notConnected": "연결 안 됨",
|
||||
"justNow": "방금",
|
||||
"minutesAgo": "{count}분 전",
|
||||
"hoursAgo": "{count}시간 전",
|
||||
"daysAgo": "{count}일 전",
|
||||
"resumeSuccess": "연동을 다시 시작했어요",
|
||||
"resumeError": "다시 연결하지 못했어요",
|
||||
"stale": "연결됨, 최근 데이터 없음",
|
||||
"stalled": "동기화 멈춤",
|
||||
"pendingSetup": "첫 데이터 기다리는 중"
|
||||
"resumeError": "다시 연결하지 못했어요"
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "받은 데이터",
|
||||
@@ -7731,6 +7711,7 @@
|
||||
"tokensEmptyTitle": "아직 API 토큰이 없어요",
|
||||
"tokensEmptyDescription": "네이티브 클라이언트가 로그인할 때나 설정 → 계정에서 토큰이 발급돼요.",
|
||||
"loginOverviewDescription": "모든 계정의 로그인·인증 이벤트예요. 필터링하고 CSV로 내보낼 수 있어요.",
|
||||
"loginOverviewFilterLabel": "이벤트 필터",
|
||||
"allAuthEvents": "전체 인증 이벤트",
|
||||
"failedOnly": "실패만",
|
||||
"loginEmptyDescription": "로그인, 로그아웃, 패스키 변경이 여기에 표시돼요.",
|
||||
@@ -7859,6 +7840,7 @@
|
||||
"admin": "관리자",
|
||||
"user": "사용자"
|
||||
},
|
||||
"filterLabel": "사용자 필터",
|
||||
"forceLogout": "강제 로그아웃",
|
||||
"forceLogoutConfirmTitle": "{name} 강제 로그아웃할까요?",
|
||||
"forceLogoutConfirmBody": "이 사용자의 활성 세션이 모두 해제돼요. 모든 기기에서 다시 로그인해야 해요.",
|
||||
@@ -8909,6 +8891,7 @@
|
||||
"inTarget": "목표 범위 안",
|
||||
"aboveTarget": "목표보다 {delta} {unit} 높음",
|
||||
"belowTarget": "목표보다 {delta} {unit} 낮음",
|
||||
"systolic": "수축기",
|
||||
"diastolic": "이완기",
|
||||
"glucoseFasting": "혈당 (공복)",
|
||||
"glucosePostprandial": "혈당 (식후)",
|
||||
@@ -9165,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": "아직 주기와 측정값이 부족해서 단계별 패턴을 보여줄 수 없어요. 계속 기록하면 기록이 쌓이면서 채워져요."
|
||||
@@ -9175,7 +9158,7 @@
|
||||
"crosstab": {
|
||||
"description": "내 데이터를 기준으로, 각 측정값이 황체기와 난포기에서 어떻게 다른지 비교했어요.",
|
||||
"pairLabel": "{metric}: 황체기와 난포기 비교",
|
||||
"detail": "황체기 {lutealAvg}{unit} ({lutealDays}일), 난포기 {follicularAvg}{unit} ({follicularDays}일).",
|
||||
"detail": "황체기 {lutealAvg} ({lutealDays}일), 난포기 {follicularAvg} ({follicularDays}일).",
|
||||
"metricRestingHeartRate": "안정 시 심박수",
|
||||
"metricHrv": "심박변이도",
|
||||
"metricSleepDuration": "수면 시간",
|
||||
@@ -9185,15 +9168,7 @@
|
||||
"metricWristTemperature": "야간 손목 온도",
|
||||
"metricSkinTemperature": "피부 온도",
|
||||
"metricBloodGlucose": "혈당",
|
||||
"metricMood": "기분",
|
||||
"unitHours": "h",
|
||||
"unitSteps": "걸음",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitMood": "점",
|
||||
"confidenceLow": "신뢰도 낮음",
|
||||
"confidenceMedium": "신뢰도 보통",
|
||||
"confidenceHigh": "신뢰도 높음"
|
||||
"metricMood": "기분"
|
||||
},
|
||||
"title": "주기 인사이트",
|
||||
"symptomPatternsTitle": "단계별 증상 패턴",
|
||||
|
||||
+15
-40
@@ -2597,7 +2597,8 @@
|
||||
"complianceHeatmap": "Heatmap van therapietrouw van {start} tot {end}, {days} actieve dagen.",
|
||||
"complianceHeatmapEmpty": "Heatmap van therapietrouw, nog geen actieve dagen."
|
||||
},
|
||||
"medicationsEmptyAction": "Medicijn toevoegen"
|
||||
"medicationsEmptyAction": "Medicijn toevoegen",
|
||||
"rangeLabel": "Periode"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -4220,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",
|
||||
@@ -4238,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",
|
||||
@@ -6673,21 +6662,12 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Gekoppeld",
|
||||
"errorReconnect": "Fout: opnieuw koppelen",
|
||||
"warningServerError": "Synchronisatie mislukt",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Gepauzeerd: opnieuw koppelen",
|
||||
"notConnected": "Niet gekoppeld",
|
||||
"justNow": "zojuist",
|
||||
"minutesAgo": "{count} min geleden",
|
||||
"hoursAgo": "{count} u geleden",
|
||||
"daysAgo": "{count} d geleden",
|
||||
"resumeSuccess": "Koppeling hervat",
|
||||
"resumeError": "Opnieuw koppelen mislukt",
|
||||
"stale": "Gekoppeld, geen recente gegevens",
|
||||
"stalled": "Synchronisatie gestopt",
|
||||
"pendingSetup": "Wachten op eerste gegevens"
|
||||
"resumeError": "Opnieuw koppelen mislukt"
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Ontvangen gegevens",
|
||||
@@ -7756,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.",
|
||||
@@ -7884,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.",
|
||||
@@ -8909,6 +8891,7 @@
|
||||
"inTarget": "Binnen het streefbereik",
|
||||
"aboveTarget": "{delta} {unit} boven het doel",
|
||||
"belowTarget": "{delta} {unit} onder het doel",
|
||||
"systolic": "Systolisch",
|
||||
"diastolic": "Diastolisch",
|
||||
"glucoseFasting": "Glucose (nuchter)",
|
||||
"glucosePostprandial": "Glucose (na de maaltijd)",
|
||||
@@ -9165,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."
|
||||
@@ -9175,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",
|
||||
@@ -9185,15 +9168,7 @@
|
||||
"metricWristTemperature": "Nachtelijke polstemperatuur",
|
||||
"metricSkinTemperature": "Huidtemperatuur",
|
||||
"metricBloodGlucose": "Bloedglucose",
|
||||
"metricMood": "Stemming",
|
||||
"unitHours": "u",
|
||||
"unitSteps": "stappen",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitMood": "pnt",
|
||||
"confidenceLow": "Lage betrouwbaarheid",
|
||||
"confidenceMedium": "Gemiddelde betrouwbaarheid",
|
||||
"confidenceHigh": "Hoge betrouwbaarheid"
|
||||
"metricMood": "Stemming"
|
||||
},
|
||||
"title": "Cyclusinzichten",
|
||||
"symptomPatternsTitle": "Symptoompatronen per fase",
|
||||
|
||||
+15
-40
@@ -2597,7 +2597,8 @@
|
||||
"complianceHeatmap": "Mapa cieplna przyjmowania leków od {start} do {end}, {days} aktywnych dni.",
|
||||
"complianceHeatmapEmpty": "Mapa cieplna przyjmowania leków, brak aktywnych dni."
|
||||
},
|
||||
"medicationsEmptyAction": "Dodaj lek"
|
||||
"medicationsEmptyAction": "Dodaj lek",
|
||||
"rangeLabel": "Zakres czasu"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -4220,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",
|
||||
@@ -4238,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",
|
||||
@@ -6673,21 +6662,12 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Połączono",
|
||||
"errorReconnect": "Błąd: połącz ponownie",
|
||||
"warningServerError": "Synchronizacja nie działa",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Wstrzymane: połącz ponownie",
|
||||
"notConnected": "Nie połączono",
|
||||
"justNow": "przed chwilą",
|
||||
"minutesAgo": "{count} min temu",
|
||||
"hoursAgo": "{count} godz. temu",
|
||||
"daysAgo": "{count} dni temu",
|
||||
"resumeSuccess": "Integracja wznowiona",
|
||||
"resumeError": "Ponowne połączenie nieudane",
|
||||
"stale": "Połączono, brak nowych danych",
|
||||
"stalled": "Synchronizacja zatrzymana",
|
||||
"pendingSetup": "Czekam na pierwsze dane"
|
||||
"resumeError": "Ponowne połączenie nieudane"
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Odebrane dane",
|
||||
@@ -7756,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.",
|
||||
@@ -7884,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.",
|
||||
@@ -8909,6 +8891,7 @@
|
||||
"inTarget": "W zakresie docelowym",
|
||||
"aboveTarget": "{delta} {unit} powyżej zakresu",
|
||||
"belowTarget": "{delta} {unit} poniżej zakresu",
|
||||
"systolic": "Skurczowe",
|
||||
"diastolic": "Rozkurczowe",
|
||||
"glucoseFasting": "Glikemia: na czczo",
|
||||
"glucosePostprandial": "Glikemia: po posiłku",
|
||||
@@ -9165,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ą."
|
||||
@@ -9175,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",
|
||||
@@ -9185,15 +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",
|
||||
"confidenceLow": "Niska pewność",
|
||||
"confidenceMedium": "Średnia pewność",
|
||||
"confidenceHigh": "Wysoka pewność"
|
||||
"metricMood": "Nastrój"
|
||||
},
|
||||
"title": "Analizy cyklu",
|
||||
"symptomPatternsTitle": "Wzorce objawów wg fazy",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.43.7",
|
||||
"version": "1.43.8",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Drop `medication_compliance_rollups`.
|
||||
--
|
||||
-- The per-(user, medication, day) coverage ledger lost its last reader when
|
||||
-- the dashboard tile and the compliance views moved to the schedule-anchored
|
||||
-- computation; every dose write still upserted a row into it, a boot backfill
|
||||
-- refolded it, and it travelled through the data wipe for nothing. The write
|
||||
-- hook now refreshes only what still derives from a dose day (the supplement
|
||||
-- nutrients), and the boot retires the backfill queue.
|
||||
--
|
||||
-- Nothing is lost: the table held counts derived from
|
||||
-- `medication_intake_events`, which stay.
|
||||
--
|
||||
-- Lock wait. Dropping the table removes its two foreign keys, and removing a
|
||||
-- foreign key's triggers takes ACCESS EXCLUSIVE on the referenced tables,
|
||||
-- `users` and `medications`, for the instant the catalog changes. Waiting for
|
||||
-- that lock behind a long reader would queue every login and every
|
||||
-- medication read behind the migration, so the wait is bounded at five
|
||||
-- seconds inside one `DO` block (one statement, so it commits or rolls back
|
||||
-- whole; `migrate deploy` runs a file statement by statement). A lock
|
||||
-- timeout changes nothing and the file is safe to apply again, which the
|
||||
-- marker declares (docs/ops/deploy.md, "A migration that timed out on a
|
||||
-- lock").
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
|
||||
DROP TABLE IF EXISTS "medication_compliance_rollups";
|
||||
END
|
||||
$migration$;
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Index hygiene: a leading index for every foreign key that cascades, and
|
||||
-- no index that is a prefix of another.
|
||||
--
|
||||
-- 1. Twenty-one foreign keys with ON DELETE CASCADE or SET NULL had no index
|
||||
-- leading with their column. Deleting the parent row (a medication, a
|
||||
-- schedule, a practitioner, an account) then scans the whole child table
|
||||
-- once per deleted parent: `medication_intake_events.medication_id` is the
|
||||
-- one that hurts, scanned in full for every medication an account delete
|
||||
-- removes. Each gets a plain btree on the column.
|
||||
--
|
||||
-- 2. Seventeen indexes repeated the leading columns of another index on the
|
||||
-- same table, in the same order and direction, mostly a `(user_id)` index
|
||||
-- beside a `(user_id, …)` unique key. The longer index answers every
|
||||
-- lookup the shorter one did, so the shorter one only cost writes and
|
||||
-- space. They go. `measurements` has its own migration (0416), because a
|
||||
-- lock on that table weighs differently.
|
||||
--
|
||||
-- `tests/integration/index-hygiene.test.ts` holds the schema to both rules
|
||||
-- from the catalog.
|
||||
--
|
||||
-- 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. `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
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
|
||||
-- 1. Foreign keys without a leading index.
|
||||
CREATE INDEX IF NOT EXISTS "passkeys_user_id_idx"
|
||||
ON "passkeys" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "medication_schedules_medication_id_idx"
|
||||
ON "medication_schedules" ("medication_id");
|
||||
CREATE INDEX IF NOT EXISTS "medication_intake_events_medication_id_idx"
|
||||
ON "medication_intake_events" ("medication_id");
|
||||
CREATE INDEX IF NOT EXISTS "telegram_reminder_messages_schedule_id_idx"
|
||||
ON "telegram_reminder_messages" ("schedule_id");
|
||||
CREATE INDEX IF NOT EXISTS "medication_inventory_items_medication_id_idx"
|
||||
ON "medication_inventory_items" ("medication_id");
|
||||
CREATE INDEX IF NOT EXISTS "medication_side_effects_medication_id_idx"
|
||||
ON "medication_side_effects" ("medication_id");
|
||||
CREATE INDEX IF NOT EXISTS "withings_oauth_states_user_id_idx"
|
||||
ON "withings_oauth_states" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "whoop_oauth_states_user_id_idx"
|
||||
ON "whoop_oauth_states" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "fitbit_oauth_states_user_id_idx"
|
||||
ON "fitbit_oauth_states" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "google_health_oauth_states_user_id_idx"
|
||||
ON "google_health_oauth_states" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "whoop_connect_tickets_user_id_idx"
|
||||
ON "whoop_connect_tickets" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "cycle_day_logs_cycle_id_idx"
|
||||
ON "cycle_day_logs" ("cycle_id");
|
||||
CREATE INDEX IF NOT EXISTS "invite_tokens_created_by_idx"
|
||||
ON "invite_tokens" ("created_by");
|
||||
CREATE INDEX IF NOT EXISTS "invite_tokens_used_by_idx"
|
||||
ON "invite_tokens" ("used_by");
|
||||
CREATE INDEX IF NOT EXISTS "invite_redemptions_user_id_idx"
|
||||
ON "invite_redemptions" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "custom_metric_entries_custom_metric_id_idx"
|
||||
ON "custom_metric_entries" ("custom_metric_id");
|
||||
CREATE INDEX IF NOT EXISTS "encounters_reminder_id_idx"
|
||||
ON "encounters" ("reminder_id");
|
||||
CREATE INDEX IF NOT EXISTS "vaccination_records_encounter_id_idx"
|
||||
ON "vaccination_records" ("encounter_id");
|
||||
CREATE INDEX IF NOT EXISTS "vaccination_records_practitioner_id_idx"
|
||||
ON "vaccination_records" ("practitioner_id");
|
||||
CREATE INDEX IF NOT EXISTS "vaccination_records_reminder_id_idx"
|
||||
ON "vaccination_records" ("reminder_id");
|
||||
CREATE INDEX IF NOT EXISTS "managed_profile_handovers_created_by_id_idx"
|
||||
ON "managed_profile_handovers" ("created_by_id");
|
||||
|
||||
-- 2. Indexes that are a prefix of another index on the same table.
|
||||
DROP INDEX IF EXISTS "biomarkers_user_id_idx";
|
||||
DROP INDEX IF EXISTS "clinician_share_link_documents_share_link_id_idx";
|
||||
DROP INDEX IF EXISTS "coach_usage_user_id_date_key_idx";
|
||||
DROP INDEX IF EXISTS "custom_metrics_user_id_idx";
|
||||
DROP INDEX IF EXISTS "cycle_day_logs_user_id_date_idx";
|
||||
DROP INDEX IF EXISTS "data_backups_user_id_idx";
|
||||
DROP INDEX IF EXISTS "environment_contexts_user_id_date_idx";
|
||||
DROP INDEX IF EXISTS "illness_day_logs_episode_id_idx";
|
||||
DROP INDEX IF EXISTS "integration_statuses_user_id_idx";
|
||||
DROP INDEX IF EXISTS "medications_user_id_idx";
|
||||
DROP INDEX IF EXISTS "menstrual_cycles_user_id_start_date_idx";
|
||||
DROP INDEX IF EXISTS "mood_entries_user_id_date_idx";
|
||||
DROP INDEX IF EXISTS "notification_channels_user_id_idx";
|
||||
DROP INDEX IF EXISTS "push_subscriptions_user_id_idx";
|
||||
DROP INDEX IF EXISTS "refresh_tokens_user_id_idx";
|
||||
DROP INDEX IF EXISTS "user_achievements_user_id_idx";
|
||||
DROP INDEX IF EXISTS "user_known_devices_user_id_idx";
|
||||
END
|
||||
$migration$;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 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
|
||||
-- `measurements_live_covering_idx` besides. Every query shape that used the
|
||||
-- shorter index plans the same way on the unique key, at a cost within a few
|
||||
-- percent (`tests/integration/index-hygiene.test.ts` compares the plans on a
|
||||
-- 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.
|
||||
--
|
||||
-- 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$;
|
||||
+61
-82
@@ -118,61 +118,61 @@ model User {
|
||||
environmentAqHistoryJson Json? @map("environment_aq_history_json")
|
||||
|
||||
// Codex OAuth (ChatGPT subscription)
|
||||
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
|
||||
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
|
||||
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
|
||||
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
|
||||
/// @internal: dead mirror of the expiry inside the encrypted Codex creds —
|
||||
/// the refresh path reads the decrypted blob's own `expiresAt`
|
||||
/// (`src/lib/ai/provider.ts`), never this column. Kept (and kept written)
|
||||
/// for wire/backup compatibility rather than dropped.
|
||||
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
|
||||
codexConnectedAt DateTime? @map("codex_connected_at")
|
||||
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
|
||||
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
|
||||
codexConnectedAt DateTime? @map("codex_connected_at")
|
||||
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
|
||||
// Per-user opt-in to the operator's shared central Codex (ChatGPT
|
||||
// subscription) connection stored on `AppSettings.adminCodex*`. OFF by
|
||||
// default; only takes effect when the operator has actually connected the
|
||||
// central Codex. Routing through it is external egress on a shared,
|
||||
// train-by-default account bound by the operator's rate limits — consent-gated
|
||||
// exactly like any other server-managed egress.
|
||||
useCentralCodex Boolean @default(false) @map("use_central_codex")
|
||||
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
|
||||
insightsCachedAt DateTime? @map("insights_cached_at")
|
||||
useCentralCodex Boolean @default(false) @map("use_central_codex")
|
||||
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
|
||||
insightsCachedAt DateTime? @map("insights_cached_at")
|
||||
/// @internal: legacy plaintext cache of the AI insights payload, read only
|
||||
/// as a fallback for rows the free-text encryption backfill has not reached
|
||||
/// yet (v1.43.7). Every write since then goes to `insightsCachedTextEncrypted`
|
||||
/// and leaves this NULL; the backfill seals and nulls it on the old rows.
|
||||
/// DROP this column in a later release, once the backfill reports zero
|
||||
/// remaining rows on every instance.
|
||||
insightsCachedText String? @map("insights_cached_text")
|
||||
insightsCachedText String? @map("insights_cached_text")
|
||||
/// v1.43.7 — the cached AI insights payload (briefing, per-domain readings,
|
||||
/// recommendations; JSON), AES-256-GCM binary layout under
|
||||
/// `INSIGHTS_CACHED_TEXT_AAD`. Read and written only through
|
||||
/// `src/lib/insights/cached-text-cipher.ts`.
|
||||
insightsCachedTextEncrypted Bytes? @map("insights_cached_text_encrypted")
|
||||
insightsCachedTextEncrypted Bytes? @map("insights_cached_text_encrypted")
|
||||
// Language the cached text was generated in. The cache is one slot per
|
||||
// user, so a reader in another language must not be served it: the
|
||||
// snapshot briefing lift and the advisor read treat a tag that differs
|
||||
// from the reader's locale as an empty cache and re-warm in the reader's.
|
||||
// NULL on rows written before the tag existed (treated as matching; the
|
||||
// next write tags it).
|
||||
insightsCachedLocale String? @map("insights_cached_locale")
|
||||
insightsCachedLocale String? @map("insights_cached_locale")
|
||||
// v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the
|
||||
// cached comprehensive text was generated from. Nightly / forced / lazy
|
||||
// regenerations compare the fresh snapshot's hash against this and skip
|
||||
// the provider call (refreshing only `insightsCachedAt`) when nothing
|
||||
// the prompt sees has changed.
|
||||
insightsSnapshotHash String? @map("insights_snapshot_hash")
|
||||
insightsSnapshotHash String? @map("insights_snapshot_hash")
|
||||
// v1.16.8 — last time a forced full warm's comprehensive step failed or
|
||||
// timed out. The forced warm path backs off for an hour after a failure
|
||||
// so a persistently broken provider chain cannot be re-driven into a
|
||||
// full generation attempt by every page-open.
|
||||
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
|
||||
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
|
||||
// v1.18.7 — UTC YYYY-MM-DD of the last daily-briefing phrasing re-roll.
|
||||
// The content-hash gate keeps the FINDINGS byte-stable on unchanged data,
|
||||
// but the daily-briefing PARAGRAPH is re-rolled once per calendar day at a
|
||||
// higher, seedless temperature so the prose reads fresh day-over-day
|
||||
// without re-paying for the findings. This stamps the day a re-roll ran so
|
||||
// the gate stays at most one extra call per day.
|
||||
insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
|
||||
insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
|
||||
// Morning-freshness marker: the user-local YYYY-MM-DD (their profile
|
||||
// timezone) for which the event-driven morning refresh has run — i.e. last
|
||||
// night's sleep has arrived AND the sleep-dependent insight/score cache was
|
||||
@@ -181,13 +181,13 @@ model User {
|
||||
// `morning-digest-refresh` job the sleep-arrival trigger enqueues; the
|
||||
// string form makes the "same local morning" comparison timezone-honest
|
||||
// without a second timestamp read.
|
||||
morningDigestRefreshedOn String? @map("morning_digest_refreshed_on")
|
||||
morningDigestRefreshedOn String? @map("morning_digest_refreshed_on")
|
||||
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
|
||||
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
|
||||
// filters matching keys off `features` before JSON.stringify so the
|
||||
// model never sees data the user opted out of. Default `[]` = legacy
|
||||
// behaviour for every existing row.
|
||||
insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics")
|
||||
insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics")
|
||||
|
||||
// v1.8.5 — user-level injection-site deny-list. Sites listed here are
|
||||
// never offered for ANY medication's injection-site picker and the
|
||||
@@ -897,11 +897,6 @@ model User {
|
||||
// read serves from this table instead of the unbounded
|
||||
// MoodEntry.findMany walk.
|
||||
moodEntryRollups MoodEntryRollup[]
|
||||
// v1.4.39 W-MED — per-(user, medication, day) compliance rollup.
|
||||
// Per-write hook on MedicationIntakeEvent mutations + the scheduler
|
||||
// keep the row in sync so `/api/medications/intake?scope=compliance`
|
||||
// and `computeUserHealthScoreFastPath` skip the per-event findMany.
|
||||
medicationComplianceRollups MedicationComplianceRollup[]
|
||||
// v1.4.40 SB-10 — AI consent receipts the iOS app POSTs when the user
|
||||
// grants AI processing during onboarding. Persisted audit trail
|
||||
// covers App-Store Guideline 5.1.2(i) ("data-collection consent must
|
||||
@@ -1253,7 +1248,6 @@ model EnvironmentContext {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([userId, date])
|
||||
@@index([userId, date])
|
||||
// `environment_contexts_aq_gap_idx` (user_id, date) WHERE aq_fetched_at IS
|
||||
// NULL is a partial index from migration 0381 (raw SQL only).
|
||||
@@map("environment_contexts")
|
||||
@@ -1305,6 +1299,8 @@ model Passkey {
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([userId])
|
||||
@@map("passkeys")
|
||||
}
|
||||
|
||||
@@ -1489,7 +1485,6 @@ model UserKnownDevice {
|
||||
// twice for one account, so the "first sighting → alert" decision is a
|
||||
// single unique-constrained upsert.
|
||||
@@unique([userId, deviceHash])
|
||||
@@index([userId])
|
||||
@@map("user_known_devices")
|
||||
}
|
||||
|
||||
@@ -2093,7 +2088,6 @@ model Measurement {
|
||||
// v1.4.23 — Apple Health batch dedup key. NULL externalId is distinct
|
||||
// (Postgres semantics) so manual entries don't collide here.
|
||||
@@unique([userId, type, source, externalId])
|
||||
@@index([userId, type, measuredAt])
|
||||
// v1.25 — type-less `(userId, measuredAt)` range scan. The bulk feature read
|
||||
// (`extractFeatures`) and consolidation scans walk a user's measurements by
|
||||
// time with no `type` predicate; `(userId, type, measuredAt)` can't serve
|
||||
@@ -2972,10 +2966,6 @@ model Medication {
|
||||
/// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE /
|
||||
/// GLP1_SPECIFIC) and 1-5 Likert severity per entry.
|
||||
sideEffects MedicationSideEffect[]
|
||||
/// v1.4.39 W-MED — per-(medication, day) compliance rollup. Mutations
|
||||
/// on the linked MedicationIntakeEvent rows refresh the row inline;
|
||||
/// the read paths consume this instead of the per-event findMany.
|
||||
complianceRollups MedicationComplianceRollup[]
|
||||
/// v1.16.3 — archived schedule eras (effective dating). Superseded
|
||||
/// schedule states land here when a wholesale schedule replace changes
|
||||
/// cadence-relevant fields; the band minter segments historical minting
|
||||
@@ -3012,7 +3002,6 @@ model Medication {
|
||||
/// re-mirror of the same concept id returns the existing row (the
|
||||
/// create route pre-queries the triple and catches the P2002 race).
|
||||
@@unique([userId, externalSource, externalId])
|
||||
@@index([userId])
|
||||
@@index([treatmentClass])
|
||||
@@map("medications")
|
||||
}
|
||||
@@ -3222,6 +3211,8 @@ model MedicationSchedule {
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
telegramMessages TelegramReminderMessage[]
|
||||
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([medicationId])
|
||||
@@map("medication_schedules")
|
||||
}
|
||||
|
||||
@@ -3500,6 +3491,8 @@ model MedicationIntakeEvent {
|
||||
// without this one the purge planned a sequential scan and could not finish
|
||||
// inside statement_timeout. Raw SQL only (partial predicate not expressible
|
||||
// in Prisma).
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([medicationId])
|
||||
@@map("medication_intake_events")
|
||||
}
|
||||
|
||||
@@ -3664,6 +3657,8 @@ model MedicationInventoryItem {
|
||||
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
|
||||
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
|
||||
// SQL only (partial predicate not expressible in Prisma).
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([medicationId])
|
||||
@@map("medication_inventory_items")
|
||||
}
|
||||
|
||||
@@ -3727,6 +3722,8 @@ model MedicationSideEffect {
|
||||
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
|
||||
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
|
||||
// SQL only (partial predicate not expressible in Prisma).
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([medicationId])
|
||||
@@map("medication_side_effects")
|
||||
}
|
||||
|
||||
@@ -3818,6 +3815,8 @@ model TelegramReminderMessage {
|
||||
|
||||
@@unique([recipientUserId, medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_recipient_slot_key")
|
||||
@@index([medicationId, date])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([scheduleId])
|
||||
@@map("telegram_reminder_messages")
|
||||
}
|
||||
|
||||
@@ -4013,6 +4012,9 @@ model InviteToken {
|
||||
redemptions InviteRedemption[]
|
||||
|
||||
@@index([expiresAt])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([createdBy])
|
||||
@@index([usedBy])
|
||||
@@map("invite_tokens")
|
||||
}
|
||||
|
||||
@@ -4056,6 +4058,8 @@ model ManagedProfileHandover {
|
||||
@@index([profileId])
|
||||
// `managed_profile_handovers_open_idx` UNIQUE (profile_id) WHERE used_at IS
|
||||
// NULL AND revoked_at IS NULL is a partial index from migration 0383.
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([createdById])
|
||||
@@map("managed_profile_handovers")
|
||||
}
|
||||
|
||||
@@ -4109,6 +4113,8 @@ model InviteRedemption {
|
||||
user User? @relation("InviteRedemptions", fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([inviteId])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([userId])
|
||||
@@map("invite_redemptions")
|
||||
}
|
||||
|
||||
@@ -4145,7 +4151,6 @@ model RefreshToken {
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
/// Composite index — every reuse-detection / device-scoped lookup filters
|
||||
/// on (userId, deviceId), so a single-column device_id index would never
|
||||
/// be used while costing writes. Multiple live rows per (userId, deviceId)
|
||||
@@ -4276,6 +4281,8 @@ model WithingsOAuthState {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([expiresAt])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([userId])
|
||||
@@map("withings_oauth_states")
|
||||
}
|
||||
|
||||
@@ -4357,6 +4364,8 @@ model WhoopOAuthState {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([expiresAt])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([userId])
|
||||
@@map("whoop_oauth_states")
|
||||
}
|
||||
|
||||
@@ -4389,6 +4398,8 @@ model WhoopConnectTicket {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([expiresAt])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([userId])
|
||||
@@map("whoop_connect_tickets")
|
||||
}
|
||||
|
||||
@@ -4443,6 +4454,8 @@ model FitbitOAuthState {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([expiresAt])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([userId])
|
||||
@@map("fitbit_oauth_states")
|
||||
}
|
||||
|
||||
@@ -4521,6 +4534,8 @@ model GoogleHealthOAuthState {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([expiresAt])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([userId])
|
||||
@@map("google_health_oauth_states")
|
||||
}
|
||||
|
||||
@@ -4651,7 +4666,6 @@ model MoodEntry {
|
||||
// that carry a source-stable id upsert on this; native/MANUAL entries
|
||||
// (NULL externalId) are distinct here and stay on the legacy key above.
|
||||
@@unique([userId, source, externalId])
|
||||
@@index([userId, date])
|
||||
// v1.12.1 (P1-3) — mood-insights reads filter `{ userId, deletedAt:
|
||||
// null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this
|
||||
// index turns that into a range scan. See migration 0123.
|
||||
@@ -5038,39 +5052,6 @@ model MoodEntryRollup {
|
||||
@@map("mood_entry_rollups")
|
||||
}
|
||||
|
||||
// ─── Medication Compliance Rollup (v1.4.39 W-MED) ─────────────
|
||||
//
|
||||
// Per-(userId, medicationId, day) compliance ledger. Replaces the
|
||||
// unbounded MedicationIntakeEvent findMany on
|
||||
// `/api/medications/intake?scope=compliance` and the equivalent
|
||||
// fan-out inside `computeUserHealthScoreFastPath`.
|
||||
//
|
||||
// Day-key is anchored to the user's IANA timezone (TEXT, encoded as
|
||||
// `YYYY-MM-DD`) so the read path can collapse a date range into a
|
||||
// straight WHERE clause without re-deriving the boundary per row.
|
||||
//
|
||||
// Write path: every MedicationIntakeEvent create / update / delete
|
||||
// (and the scheduler that mints fresh `scheduledFor` rows) fires
|
||||
// `recomputeMedicationComplianceForDay` which upserts the
|
||||
// `(user, medication, day)` row. Worker handles weekly / monthly
|
||||
// re-fold off the hot path.
|
||||
model MedicationComplianceRollup {
|
||||
userId String @map("user_id")
|
||||
medicationId String @map("medication_id")
|
||||
day String // YYYY-MM-DD anchored to user.timezone
|
||||
scheduled Int
|
||||
taken Int
|
||||
skipped Int
|
||||
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, medicationId, day])
|
||||
@@index([userId, day(sort: Desc)])
|
||||
@@map("medication_compliance_rollups")
|
||||
}
|
||||
|
||||
// ─── Integration Status (v1.4.15) ─────────────────────────
|
||||
//
|
||||
// Per-(user, integration) row that captures the most recent sync attempt
|
||||
@@ -5140,7 +5121,6 @@ model IntegrationStatus {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, integration])
|
||||
@@index([userId])
|
||||
@@index([state])
|
||||
@@map("integration_statuses")
|
||||
}
|
||||
@@ -5493,7 +5473,6 @@ model NotificationChannel {
|
||||
preferences NotificationPreference[]
|
||||
|
||||
@@unique([userId, type])
|
||||
@@index([userId])
|
||||
@@map("notification_channels")
|
||||
}
|
||||
|
||||
@@ -5521,7 +5500,6 @@ model PushSubscription {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, endpoint])
|
||||
@@index([userId])
|
||||
@@map("push_subscriptions")
|
||||
}
|
||||
|
||||
@@ -5568,7 +5546,6 @@ model DataBackup {
|
||||
chunks DataBackupChunk[]
|
||||
|
||||
@@unique([userId, type], map: "data_backups_user_id_type_key")
|
||||
@@index([userId])
|
||||
@@map("data_backups")
|
||||
}
|
||||
|
||||
@@ -6411,7 +6388,6 @@ model Biomarker {
|
||||
mergesInto BiomarkerMerge[]
|
||||
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
@@map("biomarkers")
|
||||
}
|
||||
|
||||
@@ -6454,7 +6430,6 @@ model CustomMetric {
|
||||
entries CustomMetricEntry[]
|
||||
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
@@map("custom_metrics")
|
||||
}
|
||||
|
||||
@@ -6492,6 +6467,8 @@ model CustomMetricEntry {
|
||||
// `(user_id) WHERE note IS NOT NULL` — see
|
||||
// migration 0357 for the free-text encryption boot-discovery scan. Raw SQL
|
||||
// only (partial predicate not expressible in Prisma).
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([customMetricId])
|
||||
@@map("custom_metric_entries")
|
||||
}
|
||||
|
||||
@@ -6539,7 +6516,6 @@ model UserAchievement {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, achievementId])
|
||||
@@index([userId])
|
||||
@@map("user_achievements")
|
||||
}
|
||||
|
||||
@@ -7231,7 +7207,6 @@ model CoachUsage {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, dateKey])
|
||||
@@index([userId, dateKey])
|
||||
@@map("coach_usage")
|
||||
}
|
||||
|
||||
@@ -7482,7 +7457,6 @@ model ClinicianShareLinkDocument {
|
||||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([shareLinkId, documentId])
|
||||
@@index([shareLinkId])
|
||||
@@index([documentId])
|
||||
@@map("clinician_share_link_documents")
|
||||
}
|
||||
@@ -8073,7 +8047,6 @@ model MenstrualCycle {
|
||||
dayLogs CycleDayLog[]
|
||||
|
||||
@@unique([userId, startDate])
|
||||
@@index([userId, startDate])
|
||||
// Keyset support for the `/api/sync/changes` cycle feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@index([userId, updatedAt, id])
|
||||
@@ -8173,9 +8146,10 @@ model CycleDayLog {
|
||||
// NULL-distinct external dedup key for HealthKit re-ingest (the
|
||||
// MoodEntry dual-unique precedent).
|
||||
@@unique([userId, source, externalId])
|
||||
@@index([userId, date])
|
||||
// Keyset support for the `/api/sync/changes` cycle-day feed.
|
||||
@@index([userId, updatedAt, id])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([cycleId])
|
||||
@@map("cycle_day_logs")
|
||||
}
|
||||
|
||||
@@ -8367,7 +8341,6 @@ model IllnessDayLog {
|
||||
|
||||
@@unique([episodeId, date])
|
||||
@@index([userId, date])
|
||||
@@index([episodeId])
|
||||
@@map("illness_day_logs")
|
||||
}
|
||||
|
||||
@@ -9374,6 +9347,8 @@ model Encounter {
|
||||
@@index([userId, deletedAt, occurredAt])
|
||||
@@index([userId, status, occurredAt])
|
||||
@@index([practitionerId])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([reminderId])
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
@@ -9634,6 +9609,10 @@ model VaccinationRecord {
|
||||
@@index([userId, deletedAt, occurredAt])
|
||||
@@index([userId, antigenSlug, occurredAt])
|
||||
@@index([customVaccineId])
|
||||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||||
@@index([encounterId])
|
||||
@@index([practitionerId])
|
||||
@@index([reminderId])
|
||||
@@map("vaccination_records")
|
||||
}
|
||||
|
||||
@@ -9866,21 +9845,21 @@ model DocumentAiRun {
|
||||
model LifeEvent {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// @pending-consumer(#613) The kind of event, from a deliberately short list.
|
||||
/// The kind of event, from a deliberately short list.
|
||||
category LifeEventCategory
|
||||
/// @pending-consumer(#613) First day of the event, `YYYY-MM-DD`. At MONTH or
|
||||
/// First day of the event, `YYYY-MM-DD`. At MONTH or
|
||||
/// YEAR precision the first day of that month or year.
|
||||
startDate String @map("start_date")
|
||||
/// @pending-consumer(#613) Last day, `YYYY-MM-DD`, for an event that spans a
|
||||
/// Last day, `YYYY-MM-DD`, for an event that spans a
|
||||
/// period (parental leave, a year abroad). NULL = a single date.
|
||||
endDate String? @map("end_date")
|
||||
/// @pending-consumer(#613) How much of the date is known. Applies to both
|
||||
/// How much of the date is known. Applies to both
|
||||
/// ends.
|
||||
precision LifeEventPrecision
|
||||
/// @pending-consumer(#613) What happened, in the person's own words,
|
||||
/// What happened, in the person's own words,
|
||||
/// AES-256-GCM encrypted at rest.
|
||||
titleEncrypted Bytes @map("title_encrypted")
|
||||
/// @pending-consumer(#613) An optional note, AES-256-GCM encrypted at rest.
|
||||
/// An optional note, AES-256-GCM encrypted at rest.
|
||||
noteEncrypted Bytes? @map("note_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.43.7";
|
||||
/* @sw-version-fallback */ "v1.43.8";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -24,7 +24,9 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
decideLockRetry,
|
||||
lockTimeoutInReport,
|
||||
prismaEnv,
|
||||
retryDelays,
|
||||
utcSessionUrl,
|
||||
} from "../migrate-deploy.mjs";
|
||||
|
||||
const P3018_LOCK = `Applying migration \`0412_measurement_hot_updates\`
|
||||
@@ -45,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
|
||||
@@ -76,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", () => {
|
||||
@@ -91,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({
|
||||
@@ -121,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({
|
||||
@@ -167,6 +207,53 @@ describe("which failure is retried", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session time zone Prisma migrates in", () => {
|
||||
const options = (url: string | undefined) =>
|
||||
new URL(url ?? "").searchParams.get("options");
|
||||
|
||||
it("pins TimeZone=UTC through the libpq options parameter", () => {
|
||||
const url = utcSessionUrl(
|
||||
"postgresql://u:p@db:5432/healthlog?schema=public",
|
||||
);
|
||||
expect(options(url)).toBe("-c TimeZone=UTC");
|
||||
expect(new URL(url ?? "").searchParams.get("schema")).toBe("public");
|
||||
});
|
||||
|
||||
it("appends to options the operator set, so a later -c wins", () => {
|
||||
const url = utcSessionUrl(
|
||||
"postgresql://u:p@db/h?options=-c%20TimeZone%3DEurope%2FBerlin",
|
||||
);
|
||||
expect(options(url)).toBe("-c TimeZone=Europe/Berlin -c TimeZone=UTC");
|
||||
// Running it again adds nothing.
|
||||
expect(utcSessionUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
it("leaves a URL alone when the startup options are switched off", () => {
|
||||
const raw = "postgresql://u:p@db/h?schema=public";
|
||||
expect(utcSessionUrl(raw, { optionsDisabled: true })).toBe(raw);
|
||||
expect(
|
||||
prismaEnv({ DATABASE_URL: raw, DATABASE_SESSION_OPTIONS_DISABLED: "1" })
|
||||
.DATABASE_URL,
|
||||
).toBe(raw);
|
||||
});
|
||||
|
||||
it("passes an unset or unparseable URL through untouched", () => {
|
||||
expect(utcSessionUrl(undefined)).toBeUndefined();
|
||||
expect(utcSessionUrl("")).toBe("");
|
||||
expect(utcSessionUrl("not a url")).toBe("not a url");
|
||||
expect(prismaEnv({}).DATABASE_URL).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hands Prisma the pinned URL and keeps the rest of the environment", () => {
|
||||
const env = prismaEnv({
|
||||
DATABASE_URL: "postgresql://u:p@db/h",
|
||||
HEALTHLOG_X: "1",
|
||||
});
|
||||
expect(options(env.DATABASE_URL)).toBe("-c TimeZone=UTC");
|
||||
expect(env.HEALTHLOG_X).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the retry loop", () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
@@ -246,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(
|
||||
[
|
||||
|
||||
@@ -107,8 +107,8 @@ acknowledgement).
|
||||
`audit_logs` rows (the text lives in the `details` JSON) instead.
|
||||
- **Transient / regenerable:** idempotency_keys, rate_limits, data_backups,
|
||||
import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup
|
||||
tables (measurement_rollups, mood_entry_rollups,
|
||||
medication_compliance_rollups, strain_trimp_cache, cycle_predictions) — these
|
||||
tables (measurement_rollups, mood_entry_rollups, strain_trimp_cache,
|
||||
cycle_predictions) — these
|
||||
rebuild on edge01.
|
||||
- **No rows:** workouts / workout_routes / workout_samples (demo has none).
|
||||
- **Instance / global:** app_settings; global catalogues (mood_tags,
|
||||
|
||||
@@ -65,8 +65,7 @@
|
||||
# - Ops / transient / regenerable: idempotency_keys, rate_limits,
|
||||
# data_backups, import_jobs, host_metrics, feedback,
|
||||
# recommendation_feedback, all rollup tables (measurement_rollups,
|
||||
# mood_entry_rollups, medication_compliance_rollups, strain_trimp_cache,
|
||||
# cycle_predictions) — these REGENERATE on edge01.
|
||||
# mood_entry_rollups, strain_trimp_cache, cycle_predictions) — these REGENERATE on edge01.
|
||||
# - workouts / workout_routes / workout_samples — demo has none.
|
||||
# - app_settings — instance-level, not user data.
|
||||
# - Global catalogues (mood_tags, cycle_symptoms) — pre-exist on edge01;
|
||||
@@ -299,7 +298,6 @@ DELETE FROM audit_logs WHERE user_id = '$EDGE_DEMO_ID';
|
||||
-- regenerable / transient tables that may carry FK rows blocking the user delete
|
||||
DELETE FROM measurement_rollups WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM mood_entry_rollups WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM medication_compliance_rollups WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM strain_trimp_cache WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM cycle_predictions WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM measurements WHERE user_id = '$EDGE_DEMO_ID';
|
||||
|
||||
+87
-13
@@ -21,15 +21,31 @@
|
||||
* 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
|
||||
* after a failed one (P3009), from the `logs` Prisma stored on the failed
|
||||
* row in `_prisma_migrations`.
|
||||
*
|
||||
* Every Prisma call runs with its session pinned to UTC, the way the app's
|
||||
* own connections are (`src/lib/db-session.ts`). Almost every `DateTime`
|
||||
* column is `timestamp` without a time zone and holds UTC, and Postgres
|
||||
* converts between that and `timestamptz` / `now()` through the session time
|
||||
* zone. A migration that mixes the two (0372 does) therefore wrote hours-off
|
||||
* values on a database whose default zone is not UTC. Prisma ignores `PGTZ`,
|
||||
* so the pin rides the connection URL as the libpq `options` parameter, which
|
||||
* Prisma passes to the server. `DATABASE_SESSION_OPTIONS_DISABLED` (a pooler
|
||||
* that refuses `options`) turns it off, as it does for the app.
|
||||
*
|
||||
* Environment:
|
||||
* HEALTHLOG_PRISMA_CLI Prisma's CLI entry (default: the image's copy)
|
||||
* HEALTHLOG_MIGRATIONS_DIR where the migration files are (default:
|
||||
@@ -48,19 +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. */
|
||||
@@ -78,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) {
|
||||
@@ -104,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. */
|
||||
@@ -116,10 +142,54 @@ export function retryDelays(raw) {
|
||||
: DEFAULT_RETRY_DELAYS_S;
|
||||
}
|
||||
|
||||
/**
|
||||
* `DATABASE_URL` with the session time zone pinned to UTC, for Prisma.
|
||||
*
|
||||
* Appends to an `options` value the operator set rather than replacing it;
|
||||
* a later `-c` wins, so an operator's own `TimeZone` gives way to UTC. A URL
|
||||
* that does not parse, or a host that disabled the startup options, is
|
||||
* returned unchanged.
|
||||
*
|
||||
* @param {string | undefined} raw
|
||||
* @param {{ optionsDisabled?: boolean }} [opts]
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
export function utcSessionUrl(raw, { optionsDisabled = false } = {}) {
|
||||
if (!raw || optionsDisabled) return raw;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
const existing = url.searchParams.get("options")?.trim();
|
||||
if (existing && existing.endsWith(TIME_ZONE_OPTION)) return raw;
|
||||
url.searchParams.set(
|
||||
"options",
|
||||
existing ? `${existing} ${TIME_ZONE_OPTION}` : TIME_ZONE_OPTION,
|
||||
);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** The same switch the app reads in `src/lib/db-session.ts`. */
|
||||
function sessionOptionsDisabled(env) {
|
||||
const raw = env.DATABASE_SESSION_OPTIONS_DISABLED?.trim();
|
||||
return raw !== undefined && /^(1|true|yes)$/i.test(raw);
|
||||
}
|
||||
|
||||
/** The environment every Prisma call runs in. */
|
||||
export function prismaEnv(env = process.env) {
|
||||
const url = utcSessionUrl(env.DATABASE_URL, {
|
||||
optionsDisabled: sessionOptionsDisabled(env),
|
||||
});
|
||||
return url === undefined ? { ...env } : { ...env, DATABASE_URL: url };
|
||||
}
|
||||
|
||||
function runPrisma(cli, args) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [cli, ...args], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: prismaEnv(),
|
||||
});
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
@@ -217,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, [
|
||||
|
||||
@@ -50,14 +50,9 @@
|
||||
* CREATE/UPDATE only with `--backfill-eras`. Medications that
|
||||
* already have a revision are skipped (idempotent).
|
||||
*
|
||||
* After any mutation the affected `(user, medication, day)` compliance
|
||||
* rollups are recomputed with the SAME slot-level DISTINCT aggregation the
|
||||
* shared helper runs — see `recomputeComplianceDay` below, a verbatim
|
||||
* SQL twin of `recomputeMedicationComplianceForDay` in
|
||||
* `src/lib/rollups/medication-compliance-rollups.ts`. The rollup
|
||||
* aggregates DISTINCT slots since v1.15.19, so a recompute alone already
|
||||
* heals the counts; the tombstones additionally clear the phantom rows
|
||||
* from the history view.
|
||||
* The tombstones clear the phantom rows from the history view; every
|
||||
* compliance reader folds rows sharing a slot, so the counts need no
|
||||
* separate recompute.
|
||||
*
|
||||
* Default is a DRY-RUN that only reports. Idempotent: a second `--fix`
|
||||
* run finds zero groups and changes nothing.
|
||||
@@ -211,8 +206,8 @@ function pickWinner(rows: IntakeRow[]): IntakeRow {
|
||||
// Timezone / day-key helpers — self-contained twins of the app helpers
|
||||
// the previous revision imported. Reference implementations:
|
||||
// - `userDayKey` / `DEFAULT_TIMEZONE`: `src/lib/tz/format.ts`
|
||||
// - `tzOffsetMinutes` / `startOfDayUtcInTz` / the DST-safe day-after
|
||||
// boundary: `src/lib/rollups/medication-compliance-rollups.ts`
|
||||
// - `tzOffsetMinutes` / `startOfDayUtcInTz`: `startOfLocalDayKey` in
|
||||
// `src/lib/tz/local-day.ts`
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
@@ -287,100 +282,6 @@ function startOfDayUtcInTz(dayKey: string, tz: string): Date {
|
||||
return guess;
|
||||
}
|
||||
|
||||
/**
|
||||
* `[start, windowEnd)` UTC window of the local calendar day — the same
|
||||
* DST-safe boundary derivation `recomputeMedicationComplianceForDay`
|
||||
* runs (23 / 25-hour days fold correctly because the end anchors on
|
||||
* the NEXT day's local midnight, not `start + 24 h`).
|
||||
*/
|
||||
function dayWindowUtc(dayKey: string, tz: string): { start: Date; end: Date } {
|
||||
const start = startOfDayUtcInTz(dayKey, tz);
|
||||
const fallbackEnd = new Date(start.getTime() + 86_400_000);
|
||||
const [y, m, d] = dayKey.split("-").map(Number);
|
||||
const probeNext = new Date(Date.UTC(y, m - 1, d, 12, 0, 0) + 86_400_000);
|
||||
const nextDayKey = dayKeyForScheduledFor(probeNext, tz);
|
||||
const dstSafeEnd = startOfDayUtcInTz(nextDayKey, tz);
|
||||
return {
|
||||
start,
|
||||
end: dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : fallbackEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute one `(userId, medicationId, dayKey)` compliance-rollup row.
|
||||
*
|
||||
* VERBATIM SQL TWIN of `recomputeMedicationComplianceForDay` in
|
||||
* `src/lib/rollups/medication-compliance-rollups.ts` — keep the two in
|
||||
* sync when the rollup contract changes. v1.15.19 semantics: the inner
|
||||
* CTE folds rows sharing one `scheduled_for` instant into one DISTINCT
|
||||
* slot with taken-beats-skipped priority; the outer aggregate counts
|
||||
* slots, not rows, so a duplicated slot can never inflate `scheduled`.
|
||||
* The upsert re-aggregates inside the statement (atomic against
|
||||
* concurrent app writers); the trailing DELETE clears the row when the
|
||||
* day no longer holds any live event.
|
||||
*/
|
||||
async function recomputeComplianceDay(
|
||||
client: Client,
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
dayKey: string,
|
||||
tz: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const { start, end } = dayWindowUtc(dayKey, safeTimezone(tz));
|
||||
await client.query(
|
||||
`
|
||||
WITH slot AS (
|
||||
SELECT
|
||||
BOOL_OR("taken_at" IS NOT NULL AND NOT "skipped") AS slot_taken,
|
||||
BOOL_OR("skipped") AS slot_skipped
|
||||
FROM "medication_intake_events"
|
||||
WHERE "user_id" = $1
|
||||
AND "medication_id" = $2
|
||||
AND "deleted_at" IS NULL
|
||||
AND "scheduled_for" >= $4::timestamp
|
||||
AND "scheduled_for" < $5::timestamp
|
||||
GROUP BY "scheduled_for"
|
||||
),
|
||||
aggregate AS (
|
||||
SELECT
|
||||
COUNT(*)::int AS scheduled,
|
||||
COALESCE(SUM(CASE WHEN "slot_taken" THEN 1 ELSE 0 END), 0)::int AS taken,
|
||||
COALESCE(SUM(CASE WHEN NOT "slot_taken" AND "slot_skipped" THEN 1 ELSE 0 END), 0)::int AS skipped
|
||||
FROM slot
|
||||
)
|
||||
INSERT INTO "medication_compliance_rollups"
|
||||
("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at")
|
||||
SELECT $1, $2, $3, aggregate.scheduled, aggregate.taken, aggregate.skipped, NOW()
|
||||
FROM aggregate
|
||||
WHERE aggregate.scheduled > 0
|
||||
ON CONFLICT ("user_id", "medication_id", "day") DO UPDATE
|
||||
SET "scheduled" = EXCLUDED."scheduled",
|
||||
"taken" = EXCLUDED."taken",
|
||||
"skipped" = EXCLUDED."skipped",
|
||||
"computed_at" = EXCLUDED."computed_at"
|
||||
`,
|
||||
[userId, medicationId, dayKey, utcParam(start), utcParam(end)],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
DELETE FROM "medication_compliance_rollups"
|
||||
WHERE "user_id" = $1
|
||||
AND "medication_id" = $2
|
||||
AND "day" = $3
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "medication_intake_events"
|
||||
WHERE "user_id" = $1
|
||||
AND "medication_id" = $2
|
||||
AND "deleted_at" IS NULL
|
||||
AND "scheduled_for" >= $4::timestamp
|
||||
AND "scheduled_for" < $5::timestamp
|
||||
)
|
||||
`,
|
||||
[userId, medicationId, dayKey, utcParam(start), utcParam(end)],
|
||||
);
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
duplicateGroups: number;
|
||||
rowsTombstoned: number;
|
||||
@@ -394,7 +295,6 @@ interface Summary {
|
||||
eraRevisionsCreated: number;
|
||||
startsOnCandidates: number;
|
||||
startsOnAdjusted: number;
|
||||
rollupsRecomputed: number;
|
||||
}
|
||||
|
||||
const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
@@ -453,7 +353,6 @@ async function main(): Promise<void> {
|
||||
eraRevisionsCreated: 0,
|
||||
startsOnCandidates: 0,
|
||||
startsOnAdjusted: 0,
|
||||
rollupsRecomputed: 0,
|
||||
};
|
||||
|
||||
// Optional per-user scope. The fragment is a fixed string with a
|
||||
@@ -461,7 +360,7 @@ async function main(): Promise<void> {
|
||||
const userFilter = opts.userId ? `AND e."user_id" = $1` : "";
|
||||
const userParams = opts.userId ? [opts.userId] : [];
|
||||
|
||||
/** `userId -> timezone` cache for the rollup day-key derivation. */
|
||||
/** `userId -> timezone` cache for the local-day derivations. */
|
||||
const tzCache = new Map<string, string | null>();
|
||||
async function userTimezone(userId: string): Promise<string | null> {
|
||||
if (!tzCache.has(userId)) {
|
||||
@@ -474,10 +373,6 @@ async function main(): Promise<void> {
|
||||
return tzCache.get(userId) ?? null;
|
||||
}
|
||||
|
||||
// `(userId, medicationId, dayKey)` tuples whose compliance rollup needs
|
||||
// a recompute after the tombstones. Deduped so a busy day folds once.
|
||||
const daysToRecompute = new Set<string>();
|
||||
|
||||
try {
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 1. Slot duplicates — exact (user, medication, scheduled_for)
|
||||
@@ -568,12 +463,6 @@ async function main(): Promise<void> {
|
||||
[losers.map((r) => r.id)],
|
||||
);
|
||||
summary.rowsTombstoned += res.rowCount ?? 0;
|
||||
|
||||
const tz = await userTimezone(group.user_id);
|
||||
const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz);
|
||||
daysToRecompute.add(
|
||||
`${group.user_id}|${group.medication_id}|${dayKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,12 +540,6 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
`${TAG} tombstoned ${res.rowCount ?? 0} implausible row(s) (operator opt-in)`,
|
||||
);
|
||||
|
||||
for (const row of implausible) {
|
||||
const tz = await userTimezone(row.user_id);
|
||||
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
|
||||
daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -822,12 +705,6 @@ async function main(): Promise<void> {
|
||||
[staleRows.map((r) => r.id)],
|
||||
);
|
||||
summary.staleAnchorTombstoned = res.rowCount ?? 0;
|
||||
|
||||
for (const row of staleRows) {
|
||||
const tz = await userTimezone(row.user_id);
|
||||
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
|
||||
daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -1057,35 +934,6 @@ async function main(): Promise<void> {
|
||||
summary.startsOnAdjusted += res.rowCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 6. Recompute the affected compliance rollups so scheduled/taken
|
||||
// counts self-correct. Same DISTINCT-slot SQL the shared helper
|
||||
// runs (see `recomputeComplianceDay` above).
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
if (opts.fix && daysToRecompute.size > 0) {
|
||||
console.log(
|
||||
`\n${TAG} 6) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
|
||||
);
|
||||
for (const key of daysToRecompute) {
|
||||
const [userId, medicationId, dayKey] = key.split("|");
|
||||
try {
|
||||
await recomputeComplianceDay(
|
||||
client,
|
||||
userId,
|
||||
medicationId,
|
||||
dayKey,
|
||||
await userTimezone(userId),
|
||||
);
|
||||
summary.rollupsRecomputed += 1;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`${TAG} rollup recompute failed for ${key}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
@@ -1112,7 +960,6 @@ async function main(): Promise<void> {
|
||||
console.log(` era revisions created: ${summary.eraRevisionsCreated}`);
|
||||
console.log(` startsOn candidates: ${summary.startsOnCandidates}`);
|
||||
console.log(` startsOn adjusted: ${summary.startsOnAdjusted}`);
|
||||
console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -37,7 +37,6 @@ END $$;
|
||||
|
||||
-- ── Wipe per-user state ───────────────────────────────────────────
|
||||
DELETE FROM medication_intake_events WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM medication_compliance_rollups WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM medication_schedules WHERE medication_id IN (
|
||||
SELECT id FROM medications WHERE user_id IN (SELECT user_id FROM _demo)
|
||||
);
|
||||
|
||||
@@ -56,7 +56,11 @@ const CLIENT_FILES = NON_TEST_FILES.filter((rel) => !rel.startsWith("lib/"));
|
||||
|
||||
const FEATURE_FLAG_SWEEP = NON_TEST_FILES.filter(
|
||||
(rel) =>
|
||||
!rel.startsWith("lib/feature-flags/") && !rel.startsWith("lib/openapi/"),
|
||||
!rel.startsWith("lib/feature-flags/") &&
|
||||
!rel.startsWith("lib/openapi/") &&
|
||||
// The deprecation registry names the route to date its retirement; it
|
||||
// reads nothing from it.
|
||||
rel !== "lib/http/deprecation.ts",
|
||||
);
|
||||
|
||||
const read = (rel: string) =>
|
||||
|
||||
@@ -40,6 +40,177 @@ function filesMatching(re: RegExp): string[] {
|
||||
return sourceFiles().filter((rel) => re.test(read(rel)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The argument text of every call to `callee` in `src`, parens balanced.
|
||||
*
|
||||
* The matchers this file used to rely on stopped at the first `)` —
|
||||
* `requireRecordAuth\([^)]*scope:` read `requireRecordAuth(need(), "x", {
|
||||
* scope: X })` as having no scope at all — and demanded one spelling of a
|
||||
* Bearer lookup. A guard whose matcher misses a real call site is green
|
||||
* because it matched nothing. So calls are read whole: from the `(` after the
|
||||
* name to its matching `)`, skipping string and template contents, and a
|
||||
* function DECLARATION of the same name is not a call.
|
||||
*/
|
||||
function callArgs(src: string, callee: string): string[] {
|
||||
const code = stripComments(src);
|
||||
const out: string[] = [];
|
||||
const re = new RegExp(`(?<![\\w$.])${callee}\\s*\\(`, "g");
|
||||
for (let m = re.exec(code); m; m = re.exec(code)) {
|
||||
if (/function\s*$/.test(code.slice(Math.max(0, m.index - 12), m.index))) {
|
||||
continue;
|
||||
}
|
||||
out.push(argsFrom(code, m.index + m[0].length - 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The text between the `(` at `open` and its matching `)`. */
|
||||
function argsFrom(code: string, open: number): string {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let i = open;
|
||||
for (; i < code.length; i++) {
|
||||
const ch = code[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
||||
else if (ch === "(" || ch === "{" || ch === "[") depth++;
|
||||
else if (ch === ")" || ch === "}" || ch === "]") {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return code.slice(open + 1, i);
|
||||
}
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
/** Split an argument list at its top-level commas. */
|
||||
function topLevelArgs(args: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let start = 0;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const ch = args[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
||||
else if (ch === "(" || ch === "{" || ch === "[") depth++;
|
||||
else if (ch === ")" || ch === "}" || ch === "]") depth--;
|
||||
else if (ch === "," && depth === 0) {
|
||||
parts.push(args.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
const last = args.slice(start).trim();
|
||||
if (last) parts.push(last);
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The scope a `requireAuth` call names: its first argument, or null. */
|
||||
function requireAuthScopes(src: string): Array<string | null> {
|
||||
return callArgs(src, "requireAuth").map((args) => {
|
||||
const first = topLevelArgs(args)[0];
|
||||
return first === undefined || first === "undefined" ? null : first;
|
||||
});
|
||||
}
|
||||
|
||||
/** The scopes the `requireRecordAuth` calls in `src` name in their options. */
|
||||
function recordAuthScopes(src: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const args of callArgs(src, "requireRecordAuth")) {
|
||||
for (const m of args.matchAll(/\bscope\s*:\s*([A-Za-z_$][\w$]*)/g)) {
|
||||
out.push(m[1]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `src` turns a Bearer value into an identity: the shared resolver, or
|
||||
* any `apiToken` lookup (`findUnique`, `findFirst`, `findMany`, their
|
||||
* `OrThrow` forms, on `prisma` or a transaction) whose arguments name
|
||||
* `tokenHash`, or raw SQL reading `api_tokens` by `token_hash`.
|
||||
*/
|
||||
function resolvesBearer(src: string): boolean {
|
||||
const code = stripComments(src);
|
||||
// The shared resolver, and the MCP edge's wrapper around it. The `/mcp`
|
||||
// route was on the allowlist only because a COMMENT named the resolver;
|
||||
// with comments stripped it is found by the call it actually makes.
|
||||
if (/\bresolveBearerToken\b|\bresolveMcpAuthContext\s*\(/.test(code)) {
|
||||
return true;
|
||||
}
|
||||
const lookups =
|
||||
/\bapiToken\s*\.\s*find(?:Unique|First|Many)(?:OrThrow)?\s*\(/g;
|
||||
for (const m of code.matchAll(lookups)) {
|
||||
const args = argsFrom(code, m.index + m[0].length - 1);
|
||||
if (/\btokenHash\b/.test(args)) return true;
|
||||
}
|
||||
// A raw read of the table by hash. An UPDATE that revokes by hash turns
|
||||
// nothing into an identity and is not one.
|
||||
return /\bSELECT\b[\s\S]{0,300}?\bFROM\s+"?api_tokens"?[\s\S]{0,300}?\btoken_hash\b/i.test(
|
||||
code,
|
||||
);
|
||||
}
|
||||
|
||||
describe("the call matchers read whole calls", () => {
|
||||
it("finds a scope behind nested parentheses and across lines", () => {
|
||||
const planted = `const ctx = await requireRecordAuth(
|
||||
needFor(method()),
|
||||
"measurements",
|
||||
{ scope: MEASUREMENTS_WRITE_SCOPE },
|
||||
);`;
|
||||
expect(recordAuthScopes(planted)).toEqual(["MEASUREMENTS_WRITE_SCOPE"]);
|
||||
// The old matcher stopped at the first ")" and saw nothing.
|
||||
expect(/requireRecordAuth\([^)]*\bscope:/.test(planted)).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the first requireAuth argument and ignores the options", () => {
|
||||
expect(
|
||||
requireAuthScopes(
|
||||
'await requireAuth(WORKOUTS_WRITE_SCOPE, { writeBucket: "own" });\n' +
|
||||
'await requireAuth(undefined, { writeBucket: "own" });\n' +
|
||||
"await requireAuth();\n" +
|
||||
"export async function requireAuth(requiredPermission?: string) {}\n" +
|
||||
'await requireAuth("health:write");',
|
||||
),
|
||||
).toEqual(["WORKOUTS_WRITE_SCOPE", null, null, '"health:write"']);
|
||||
});
|
||||
|
||||
it("recognises every spelling of a Bearer lookup", () => {
|
||||
for (const planted of [
|
||||
"prisma.apiToken.findUnique({ where: { tokenHash: h } })",
|
||||
"prisma.apiToken\n .findUnique({\n where: { userId, tokenHash: hash },\n })",
|
||||
"tx.apiToken.findFirst({ where: { revokedAt: null, tokenHash: digest(x) } })",
|
||||
"await prisma.apiToken.findUniqueOrThrow({ where: { tokenHash } })",
|
||||
"prisma.$queryRaw`SELECT user_id FROM api_tokens WHERE token_hash = ${h}`",
|
||||
]) {
|
||||
expect(resolvesBearer(planted), planted).toBe(true);
|
||||
}
|
||||
// A lookup by something else is not a Bearer resolution, even with the
|
||||
// word elsewhere in the file, and neither is a revocation by hash.
|
||||
expect(
|
||||
resolvesBearer(
|
||||
"prisma.apiToken.findMany({ where: { userId } });\nconst tokenHash = 1;",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolvesBearer(
|
||||
"prisma.$executeRaw`UPDATE api_tokens SET revoked = true WHERE token_hash = ${h}`",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T1 — the Bearer resolution set is frozen", () => {
|
||||
/**
|
||||
* Files allowed to turn a raw `Authorization: Bearer` value into a user.
|
||||
@@ -58,6 +229,13 @@ describe("T1 — the Bearer resolution set is frozen", () => {
|
||||
"lib/mcp/auth.ts",
|
||||
// The `/mcp` transport, via `resolveMcpAuthContext`.
|
||||
"app/mcp/route.ts",
|
||||
// The local stdio MCP entrypoint, via the same `resolveMcpAuthContext`: a
|
||||
// pasted token for a power-user's own MCP client, run in a plain Node
|
||||
// process outside the web server. Same edge, same posture as `/mcp`. It
|
||||
// was a Bearer resolution this guard never saw, because the matcher only
|
||||
// knew the resolver's own name and the `/mcp` entry above was matched
|
||||
// through a comment.
|
||||
"cli/mcp-stdio.ts",
|
||||
// Hand-rolled Bearer authentication: the external medication-ingest
|
||||
// surface, which gates on BOTH `medication:ingest` and the per-medication
|
||||
// `medication:<id>:ingest` grant and never touches `requireAuth`.
|
||||
@@ -90,9 +268,7 @@ describe("T1 — the Bearer resolution set is frozen", () => {
|
||||
// `tokenHash` may sit anywhere inside the `where` object — demanding it
|
||||
// first would let a formatting change (another key ahead of it) walk a
|
||||
// resolver out of the guard's sight.
|
||||
const resolvers = filesMatching(
|
||||
/resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{[^}]*?tokenHash/,
|
||||
);
|
||||
const resolvers = sourceFiles().filter((rel) => resolvesBearer(read(rel)));
|
||||
|
||||
// Non-zero proof: an empty match set must fail rather than agree with an
|
||||
// emptied allowlist.
|
||||
@@ -207,18 +383,12 @@ describe("T3 — the mint sites are frozen", () => {
|
||||
});
|
||||
|
||||
describe("T4 — declared scopes are exported constants, never string literals", () => {
|
||||
it("every requireAuth argument is an identifier", () => {
|
||||
it("every requireAuth scope argument is an identifier", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const rel of sourceFiles()) {
|
||||
const src = read(rel);
|
||||
// Call sites only — skip the declaration in `api-handler.ts`, whose
|
||||
// parameter list is not an argument.
|
||||
for (const m of src.matchAll(
|
||||
/(?<!function\s)requireAuth\(\s*([^)\s][^)]*)\)/g,
|
||||
)) {
|
||||
const arg = m[1].trim();
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(arg)) {
|
||||
offenders.push(`${rel}: requireAuth(${arg})`);
|
||||
for (const scope of requireAuthScopes(read(rel))) {
|
||||
if (scope !== null && !/^[A-Za-z_$][\w$]*$/.test(scope)) {
|
||||
offenders.push(`${rel}: requireAuth(${scope})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,16 +413,10 @@ describe("T4 — declared scopes are exported constants, never string literals",
|
||||
const used = new Set<string>();
|
||||
for (const rel of sourceFiles()) {
|
||||
const src = read(rel);
|
||||
for (const m of src.matchAll(
|
||||
/(?<!function\s)requireAuth\(\s*([A-Za-z_$][\w$]*)\s*\)/g,
|
||||
)) {
|
||||
used.add(m[1]);
|
||||
}
|
||||
for (const m of src.matchAll(
|
||||
/requireRecordAuth\([^)]*\bscope:\s*([A-Za-z_$][\w$]*)/g,
|
||||
)) {
|
||||
used.add(m[1]);
|
||||
for (const scope of requireAuthScopes(src)) {
|
||||
if (scope !== null) used.add(scope);
|
||||
}
|
||||
for (const scope of recordAuthScopes(src)) used.add(scope);
|
||||
}
|
||||
expect([...used].sort()).toEqual(DECLARED_SCOPES);
|
||||
});
|
||||
@@ -266,9 +430,13 @@ describe("T4b — `documents:write` opens exactly two doors", () => {
|
||||
// an importer can skip a download. Nothing else: not the list or detail,
|
||||
// not the original or thumbnail, not bulk, not the AI legs. A third route
|
||||
// here is a widening and belongs in review.
|
||||
const declaring = filesMatching(
|
||||
/requireAuth\(\s*DOCUMENTS_WRITE_SCOPE\s*\)|requireRecordAuth\([^)]*\bscope:\s*DOCUMENTS_WRITE_SCOPE\b/,
|
||||
);
|
||||
const declaring = sourceFiles().filter((rel) => {
|
||||
const src = read(rel);
|
||||
return (
|
||||
requireAuthScopes(src).includes("DOCUMENTS_WRITE_SCOPE") ||
|
||||
recordAuthScopes(src).includes("DOCUMENTS_WRITE_SCOPE")
|
||||
);
|
||||
});
|
||||
expect(declaring).toEqual([
|
||||
"app/api/documents/inbound/route.ts",
|
||||
"app/api/documents/inbound/source/route.ts",
|
||||
@@ -277,7 +445,7 @@ describe("T4b — `documents:write` opens exactly two doors", () => {
|
||||
// name it.
|
||||
const upload = read("app/api/documents/inbound/route.ts");
|
||||
expect(
|
||||
upload.match(/requireAuth\(\s*DOCUMENTS_WRITE_SCOPE\s*\)/g),
|
||||
requireAuthScopes(upload).filter((x) => x === "DOCUMENTS_WRITE_SCOPE"),
|
||||
).toHaveLength(1);
|
||||
// And the lookup is a read of one key and nothing else: a single GET.
|
||||
const lookup = read("app/api/documents/inbound/source/route.ts");
|
||||
@@ -294,13 +462,17 @@ describe("T4c — `workouts:write` opens exactly one door", () => {
|
||||
// The set of files naming the scope IS its reach. The batch ingest and
|
||||
// nothing else: not the workout list or detail, which would hand a
|
||||
// bridge the history it has no business reading back.
|
||||
const declaring = filesMatching(
|
||||
/requireAuth\(\s*WORKOUTS_WRITE_SCOPE\s*\)|requireRecordAuth\([^)]*\bscope:\s*WORKOUTS_WRITE_SCOPE\b/,
|
||||
);
|
||||
const declaring = sourceFiles().filter((rel) => {
|
||||
const src = read(rel);
|
||||
return (
|
||||
requireAuthScopes(src).includes("WORKOUTS_WRITE_SCOPE") ||
|
||||
recordAuthScopes(src).includes("WORKOUTS_WRITE_SCOPE")
|
||||
);
|
||||
});
|
||||
expect(declaring).toEqual(["app/api/workouts/batch/route.ts"]);
|
||||
const batch = read("app/api/workouts/batch/route.ts");
|
||||
expect(
|
||||
batch.match(/requireAuth\(\s*WORKOUTS_WRITE_SCOPE\s*\)/g),
|
||||
requireAuthScopes(batch).filter((x) => x === "WORKOUTS_WRITE_SCOPE"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
[...batch.matchAll(/export const (GET|POST|PUT|PATCH|DELETE)\b/g)].map(
|
||||
@@ -330,8 +502,8 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => {
|
||||
};
|
||||
|
||||
it("only the known routes pass a scope to the record resolver", () => {
|
||||
const naming = filesMatching(
|
||||
/requireRecordAuth\([^)]*\bscope:\s*[A-Za-z_$][\w$]*/,
|
||||
const naming = sourceFiles().filter(
|
||||
(rel) => recordAuthScopes(read(rel)).length > 0,
|
||||
);
|
||||
// Non-zero proof: an empty match set must fail rather than agree with an
|
||||
// emptied table. This is the leg most likely to rot — the matcher spans a
|
||||
@@ -343,11 +515,7 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => {
|
||||
|
||||
it("each names the scope this table says it does", () => {
|
||||
for (const [rel, scope] of Object.entries(SCOPED_RECORD_ROUTES)) {
|
||||
const named = [
|
||||
...read(rel).matchAll(
|
||||
/requireRecordAuth\([^)]*\bscope:\s*([A-Za-z_$][\w$]*)/g,
|
||||
),
|
||||
].map((m) => m[1]);
|
||||
const named = recordAuthScopes(read(rel));
|
||||
expect(named).toEqual([scope]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -77,16 +77,10 @@ const ALLOWLIST: readonly Allowed[] = [
|
||||
"Search-backed picker: the query narrows the whole document vault server-side.",
|
||||
},
|
||||
{
|
||||
file: "components/settings/share-document-picker.tsx",
|
||||
file: "components/documents/document-picker-sheet.tsx",
|
||||
limit: 50,
|
||||
reason:
|
||||
"Search-backed picker: the query narrows the whole document vault server-side.",
|
||||
},
|
||||
{
|
||||
file: "components/documents/document-link-picker.tsx",
|
||||
limit: 50,
|
||||
reason:
|
||||
"Search-backed picker: the query narrows the whole document vault server-side.",
|
||||
"Search-backed picker (episode links and share links): the query narrows the whole document vault server-side.",
|
||||
},
|
||||
{
|
||||
file: "hooks/use-mood-day.ts",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The documents vault and the Coach load no schema library in the browser.
|
||||
*
|
||||
* Both routes reached zod through a constant: the list of document kinds, the
|
||||
* bulk limit, the attachment cap, the Coach data clusters, a conversation
|
||||
* title length, the ordering of report leaves. Each of those sat in a schema
|
||||
* module, so importing the number imported the library with it, on two of the
|
||||
* heaviest client routes (up to 89 KB gzip per route). The constants live in
|
||||
* zod-free modules now, re-exported by the schema modules for server code, and
|
||||
* the server parses what the client sends anyway.
|
||||
*
|
||||
* What this walks: the static import graph from each route's `page.tsx`, with
|
||||
* TypeScript's own parser. From the first `"use client"` module on, every
|
||||
* module it reaches statically ships to the browser; none of them may import
|
||||
* `zod`. Type-only imports are skipped (they vanish at build). A dynamic
|
||||
* `import()` is skipped too: it is a separate chunk, loaded when the code
|
||||
* that asks for it runs (the chart runtime is one), and not part of the
|
||||
* route's first load. That is the limit of this guard, stated: a lazily
|
||||
* loaded chunk may still carry zod.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, normalize } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
|
||||
function resolve(spec: string, from: string): string | null {
|
||||
let base: string;
|
||||
if (spec.startsWith("@/")) base = join(ROOT, "src", spec.slice(2));
|
||||
else if (spec.startsWith(".")) base = normalize(join(dirname(from), spec));
|
||||
else return null;
|
||||
for (const ext of ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]) {
|
||||
const candidate = base + ext;
|
||||
if (/\.tsx?$/.test(candidate) && existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Value-level static imports and re-exports of one module. */
|
||||
function staticImports(source: string, file: string): string[] {
|
||||
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false);
|
||||
const specs: string[] = [];
|
||||
for (const node of sf.statements) {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
const clause = node.importClause;
|
||||
if (clause?.isTypeOnly) continue;
|
||||
const named = clause?.namedBindings;
|
||||
if (
|
||||
clause &&
|
||||
!clause.name &&
|
||||
named &&
|
||||
ts.isNamedImports(named) &&
|
||||
named.elements.length > 0 &&
|
||||
named.elements.every((e) => e.isTypeOnly)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
specs.push((node.moduleSpecifier as ts.StringLiteral).text);
|
||||
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
||||
if (node.isTypeOnly) continue;
|
||||
const clause = node.exportClause;
|
||||
if (
|
||||
clause &&
|
||||
ts.isNamedExports(clause) &&
|
||||
clause.elements.length > 0 &&
|
||||
clause.elements.every((e) => e.isTypeOnly)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
specs.push((node.moduleSpecifier as ts.StringLiteral).text);
|
||||
}
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
function isClient(source: string): boolean {
|
||||
return /^\s*["']use client["']/.test(source);
|
||||
}
|
||||
|
||||
/** Client modules reached from `entry` that import zod, with one path each. */
|
||||
function clientZodImports(entry: string): { file: string; path: string[] }[] {
|
||||
const seen = new Map<string, boolean>();
|
||||
const parent = new Map<string, string>();
|
||||
const stack: [string, boolean][] = [[join(ROOT, entry), false]];
|
||||
const hits: string[] = [];
|
||||
let walked = 0;
|
||||
while (stack.length > 0) {
|
||||
const [file, inClient] = stack.pop()!;
|
||||
const prior = seen.get(file);
|
||||
if (prior === true || (prior === false && !inClient)) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const client = inClient || isClient(source);
|
||||
seen.set(file, client);
|
||||
walked += 1;
|
||||
for (const spec of staticImports(source, file)) {
|
||||
if (client && (spec === "zod" || spec.startsWith("zod/"))) {
|
||||
hits.push(file);
|
||||
}
|
||||
const next = resolve(spec, file);
|
||||
if (!next) continue;
|
||||
if (!parent.has(next)) parent.set(next, file);
|
||||
stack.push([next, client]);
|
||||
}
|
||||
}
|
||||
if (walked < 20)
|
||||
throw new Error(`walked only ${walked} modules from ${entry}`);
|
||||
return [...new Set(hits)].map((file) => {
|
||||
const path: string[] = [];
|
||||
let cursor: string | undefined = file;
|
||||
while (cursor && path.length < 10) {
|
||||
path.push(cursor.slice(ROOT.length + 1));
|
||||
cursor = parent.get(cursor);
|
||||
}
|
||||
return { file: file.slice(ROOT.length + 1), path };
|
||||
});
|
||||
}
|
||||
|
||||
describe("client routes without the schema library", () => {
|
||||
for (const route of [
|
||||
"src/app/documents/page.tsx",
|
||||
"src/app/coach/page.tsx",
|
||||
"src/app/coach/conversations/page.tsx",
|
||||
]) {
|
||||
it(`${route} ships no zod in its static client graph`, () => {
|
||||
const hits = clientZodImports(route);
|
||||
expect(
|
||||
hits.map((h) => h.path.join(" <- ")),
|
||||
"Move the constant the client needs into a zod-free module (see src/lib/documents/inbound-constants.ts) and re-export it from the schema module.",
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("sees a zod import where one is", () => {
|
||||
// The schema module is zod by definition; walked as a client entry it
|
||||
// must be reported, or the walk above proves nothing.
|
||||
const probe = join(ROOT, "src/lib/validations/inbound-documents.ts");
|
||||
const source = readFileSync(probe, "utf8");
|
||||
expect(staticImports(source, probe).some((s) => s.startsWith("zod"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
staticImports(
|
||||
'import type { A } from "zod";\nimport { type B } from "zod";\nimport { c } from "zod/v4";\nexport { d } from "./x";\nexport type { E } from "./y";',
|
||||
"probe.ts",
|
||||
),
|
||||
).toEqual(["zod/v4", "./x"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* An honest no-data state goes through `EmptyState` (or `ChartEmptyState` on
|
||||
* a chart body), UI standards §6.
|
||||
*
|
||||
* 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 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`, `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
|
||||
* the idiom, not every way to say nothing.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(__dirname, "..");
|
||||
|
||||
/**
|
||||
* 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 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|nothing|Nothing)[A-Za-z0-9]*$|(?:^|\.)no[A-Z][A-Za-z0-9]*Yet$/;
|
||||
|
||||
function findHandWrittenEmptyStates(
|
||||
files: { path: string; source: string }[],
|
||||
): string[] {
|
||||
const hits: string[] = [];
|
||||
for (const { path, source } of files) {
|
||||
for (const match of source.matchAll(ELEMENT_RE)) {
|
||||
const [, , attrs, key] = match;
|
||||
if (!attrs.includes("text-muted-foreground")) continue;
|
||||
if (!EMPTY_KEY_RE.test(key) || key.endsWith("Hint")) continue;
|
||||
hits.push(`${path}: ${key}`);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
describe("empty states go through EmptyState (ratchet)", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 2500 })
|
||||
.filter(
|
||||
(p) =>
|
||||
p.endsWith(".tsx") &&
|
||||
(p.startsWith("components/") || p.startsWith("app/")) &&
|
||||
!p.startsWith("components/ui/") &&
|
||||
!p.includes("__tests__/"),
|
||||
)
|
||||
.map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") }));
|
||||
|
||||
it("walks the UI tree", () => {
|
||||
expect(files.length).toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
it("adds no hand-written empty state, and lists none that moved over", () => {
|
||||
const hits = findHandWrittenEmptyStates(files).sort();
|
||||
expect(
|
||||
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", () => {
|
||||
expect(
|
||||
findHandWrittenEmptyStates([
|
||||
{
|
||||
path: "components/a.tsx",
|
||||
source:
|
||||
'<p className="text-muted-foreground text-sm">{t("admin.aiQuality.empty")}</p>',
|
||||
},
|
||||
{
|
||||
path: "components/b.tsx",
|
||||
source:
|
||||
'<p className="text-muted-foreground text-xs">{t("cycle.sheet.emptyHint")}</p>',
|
||||
},
|
||||
{
|
||||
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",
|
||||
"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 };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* A real `UnitDisplay` for one fixed preference, for component tests that
|
||||
* render without a QueryClient (`useUnitDisplay` reads the account through
|
||||
* react-query). Built on the production transforms, so a test asserts the
|
||||
* same numbers and symbols the hook would give.
|
||||
* react-query). Built on the production transforms and unit labels, so a
|
||||
* test asserts the same numbers and symbols the hook would give (labels in
|
||||
* English, the bundle the component tests render in).
|
||||
*
|
||||
* vi.mock("@/hooks/use-unit-display", async () => {
|
||||
* const { unitDisplayFor } = await import("@/__tests__/helpers/unit-display-mock");
|
||||
@@ -19,6 +20,22 @@ import {
|
||||
invertDisplayTransform,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { measurementUnitLabel } from "@/lib/measurements/unit-label";
|
||||
|
||||
import en from "../../../messages/en.json";
|
||||
|
||||
function english(key: string): string {
|
||||
const leaf = key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
en,
|
||||
);
|
||||
return typeof leaf === "string" ? leaf : key;
|
||||
}
|
||||
|
||||
export function unitDisplayFor(
|
||||
preference: UnitPreference,
|
||||
@@ -35,7 +52,8 @@ export function unitDisplayFor(
|
||||
fromDisplay: (type, value) => invertDisplayTransform(value, forType(type)),
|
||||
toDisplayDelta: (type, value) =>
|
||||
applyDisplayTransformDelta(value, forType(type)),
|
||||
unitFor: (type) => forType(type).displayUnit,
|
||||
unitFor: (type) =>
|
||||
measurementUnitLabel(type, forType(type).displayUnit, english),
|
||||
decimalsFor: (type) => forType(type).decimals,
|
||||
isTransformed: (type) => hasDisplayTransform(type),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* A small text action inside a line of content goes through `InlineAction`
|
||||
* (`src/components/ui/inline-action.tsx`).
|
||||
*
|
||||
* Thirty-odd hand-rolled copies of the same "underlined small text link"
|
||||
* carried three different focus rings, none at all on half of them, and a
|
||||
* 16 px touch target on a phone. The primitive owns the 44 px floor on a
|
||||
* coarse pointer and the ring; this guard keeps a new copy from appearing
|
||||
* beside it.
|
||||
*
|
||||
* Matched: a `<button>`, `<Link>` or `<a>` whose static `className` combines
|
||||
* the inline-action signature — an underline (`underline` / `hover:underline`)
|
||||
* with a small text size (`text-xs` / `text-sm`) and an action tone
|
||||
* (`text-primary` / `text-muted-foreground`). A full-width row link or a
|
||||
* framed button does not have that combination.
|
||||
*
|
||||
* Not matched (stated, not hidden): a class list assembled at runtime, and a
|
||||
* copy without an underline. The guard catches the idiom, not every way to
|
||||
* draw a small link.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(__dirname, "..");
|
||||
|
||||
/**
|
||||
* Sites that look like an inline action but are something else, each with
|
||||
* the reason. Keyed by `path:tag`.
|
||||
*/
|
||||
const ALLOWED: Record<string, string> = {
|
||||
// A full-width footer row of the glucose clinical panel: icon plus a
|
||||
// wrapping sentence under a border, read as part of the card body.
|
||||
"components/insights/glucose/glucose-clinical-panel.tsx:Link":
|
||||
"full-width footer row, not an inline action",
|
||||
};
|
||||
|
||||
const OPENER_RE = /<(button|Link|a)\b((?:[^>{]|\{[^}]*\})*?)>/g;
|
||||
const CLASS_RE = /className=(?:"([^"]*)"|\{cn\(\s*"([^"]*)")/;
|
||||
|
||||
function isInlineActionShape(cls: string): boolean {
|
||||
return (
|
||||
/(?:^|\s)(?:hover:)?underline(?:\s|$)/.test(cls) &&
|
||||
/(?:^|\s)text-(?:xs|sm)(?:\s|$)/.test(cls) &&
|
||||
/(?:^|\s)text-(?:primary|muted-foreground)(?:\s|$)/.test(cls)
|
||||
);
|
||||
}
|
||||
|
||||
function findHandRolledInlineActions(
|
||||
files: { path: string; source: string }[],
|
||||
): string[] {
|
||||
const hits: string[] = [];
|
||||
for (const { path, source } of files) {
|
||||
for (const match of source.matchAll(OPENER_RE)) {
|
||||
const cls = CLASS_RE.exec(match[2]);
|
||||
if (!cls) continue;
|
||||
if (!isInlineActionShape(cls[1] ?? cls[2] ?? "")) continue;
|
||||
hits.push(`${path}:${match[1]}`);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
describe("small text actions go through InlineAction", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 1200, extensions: [".tsx"] })
|
||||
.filter(
|
||||
(p) =>
|
||||
(p.startsWith("components/") || p.startsWith("app/")) &&
|
||||
!p.startsWith("components/ui/") &&
|
||||
!p.includes("__tests__/"),
|
||||
)
|
||||
.map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") }));
|
||||
|
||||
it("walks the UI tree", () => {
|
||||
expect(files.length).toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
it("has no hand-rolled copy outside the allowlist", () => {
|
||||
const hits = findHandRolledInlineActions(files).filter(
|
||||
(key) => !(key in ALLOWED),
|
||||
);
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every allowlist entry live", () => {
|
||||
const hits = new Set(findHandRolledInlineActions(files));
|
||||
for (const key of Object.keys(ALLOWED)) {
|
||||
expect(hits.has(key), key).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("recognises the idiom it exists to refuse", () => {
|
||||
const planted = [
|
||||
{
|
||||
path: "components/x.tsx",
|
||||
source:
|
||||
'<Link href="/a" className="text-primary text-xs underline-offset-2 hover:underline">Go</Link>',
|
||||
},
|
||||
{
|
||||
path: "components/y.tsx",
|
||||
source:
|
||||
'<button type="button" onClick={() => go()} className="text-muted-foreground hover:text-foreground text-sm underline">Go</button>',
|
||||
},
|
||||
{
|
||||
path: "components/z.tsx",
|
||||
source: '<InlineAction tone="muted">Go</InlineAction>',
|
||||
},
|
||||
];
|
||||
expect(findHandRolledInlineActions(planted)).toEqual([
|
||||
"components/x.tsx:Link",
|
||||
"components/y.tsx:button",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is used where the copies were", () => {
|
||||
const users = files.filter((f) => f.source.includes("<InlineAction"));
|
||||
expect(users.length).toBeGreaterThanOrEqual(25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Structural guard: every capped or paged list gets its page from one helper,
|
||||
* and every such read has a total order.
|
||||
*
|
||||
* The list routes had five answers to "is there more", and two of them lied:
|
||||
* a `hasMore` computed as `rows.length === limit` reported more on a page that
|
||||
* was exactly full, and a list cut at its `take` with no signal at all let the
|
||||
* last row read as the oldest one there is. `listPage`
|
||||
* (`src/lib/http/list-query.ts`) reads one row past the limit and says
|
||||
* exactly whether that row exists. This guard holds the tree to it:
|
||||
*
|
||||
* 1. a route that answers with `apiListSuccess` gets its cap from
|
||||
* `listPage`;
|
||||
* 2. nothing outside the two list modules calls `capRows` or `overfetch` by
|
||||
* hand;
|
||||
* 3. a `hasMore` is never a length comparison;
|
||||
* 4. every read passed to `listPage` orders by something that ends in the
|
||||
* row id, so a page boundary between two rows that share a sort value
|
||||
* neither repeats one nor drops one.
|
||||
*
|
||||
* The routes are derived from the tree, never listed by hand. Comments are
|
||||
* stripped first, and the planted cases at the top prove each matcher fires.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** The modules allowed to cut a list by hand: the helper and its primitive. */
|
||||
const LIST_MODULES = new Set([
|
||||
"lib/api/list-cap.ts",
|
||||
"lib/http/list-query.ts",
|
||||
// The MCP tools' name for `listPage`; its reads are checked at its callers.
|
||||
"lib/mcp/capped.ts",
|
||||
]);
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
/** The text of a call's argument list, from the `(` after `name` to its match. */
|
||||
function callBodies(code: string, name: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re = new RegExp(`\\b${name}\\s*\\(`, "g");
|
||||
for (let m = re.exec(code); m; m = re.exec(code)) {
|
||||
let depth = 0;
|
||||
let i = m.index + m[0].length - 1;
|
||||
const start = i;
|
||||
for (; i < code.length; i++) {
|
||||
const ch = code[i];
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
out.push(code.slice(start, i + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The value of the first `orderBy` in a read, up to its closing bracket. */
|
||||
function orderByValue(body: string): string | null {
|
||||
const m = /\borderBy\b\s*(:)?\s*/.exec(body);
|
||||
if (!m) return null;
|
||||
// Shorthand `orderBy,` names a variable of the same name.
|
||||
if (!m[1]) return "orderBy";
|
||||
let i = m.index + m[0].length;
|
||||
const open = body[i];
|
||||
if (open !== "[" && open !== "{") {
|
||||
const ident = /^[A-Za-z_$][\w$]*/.exec(body.slice(i));
|
||||
return ident ? ident[0] : null;
|
||||
}
|
||||
const close = open === "[" ? "]" : "}";
|
||||
let depth = 0;
|
||||
const start = i;
|
||||
for (; i < body.length; i++) {
|
||||
if (body[i] === open) depth++;
|
||||
else if (body[i] === close) {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return body.slice(start, i + 1);
|
||||
}
|
||||
|
||||
/** Whether an order ends in the row id, following one variable if named. */
|
||||
function endsInId(order: string, file: string): boolean {
|
||||
const literal = (text: string) =>
|
||||
/^\[[\s\S]*\{\s*id\s*:\s*[^}]+\}\s*,?\s*\]$/.test(text.trim());
|
||||
if (order.startsWith("[") || order.startsWith("{")) return literal(order);
|
||||
const decl = new RegExp(
|
||||
`\\b(?:const|let)\\s+${order}\\b[^=]*=\\s*(\\[[\\s\\S]*?\\]);`,
|
||||
).exec(file);
|
||||
return decl ? literal(decl[1]) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `distinct` read returns one row per key, so ordering by that key alone is
|
||||
* already total: `distinct: ["analyte"]` with `orderBy: { analyte: "asc" }`.
|
||||
*/
|
||||
function ordersByDistinctKey(body: string, order: string): boolean {
|
||||
const distinct = /\bdistinct\s*:\s*\[\s*["'](\w+)["']\s*\]/.exec(body);
|
||||
if (!distinct) return false;
|
||||
return new RegExp(`^\\{\\s*${distinct[1]}\\s*:[^,}]+\\}$`).test(order.trim());
|
||||
}
|
||||
|
||||
describe("capped lists go through listPage", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
|
||||
(file) =>
|
||||
!file.startsWith("generated/") &&
|
||||
!file.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(file),
|
||||
);
|
||||
const read = (file: string) =>
|
||||
stripComments(readFileSync(join(SRC, file), "utf8"));
|
||||
const routes = files.filter((f) => /^app\/api\/.+\/route\.tsx?$/.test(f));
|
||||
|
||||
it("the matchers fire on planted sources", () => {
|
||||
const handRolled = `
|
||||
const found = await prisma.allergy.findMany({ take: overfetch(limit) });
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
return apiListSuccess(rows, cap);`;
|
||||
expect(/\bcapRows\s*\(/.test(handRolled)).toBe(true);
|
||||
expect(/\blistPage\s*\(/.test(handRolled)).toBe(false);
|
||||
|
||||
expect(
|
||||
/\bhasMore\s*:[^,\n]*\.length\b/.test(
|
||||
"hasMore: rows.length === q.limit,",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
/\bhasMore\s*:[^,\n]*\.length\b/.test("hasMore: cap.truncated,"),
|
||||
).toBe(false);
|
||||
|
||||
const noTiebreak = `listPage(limit, (take) =>
|
||||
prisma.x.findMany({ orderBy: { createdAt: "desc" }, take }))`;
|
||||
const [body] = callBodies(noTiebreak, "listPage");
|
||||
expect(endsInId(orderByValue(body)!, noTiebreak)).toBe(false);
|
||||
|
||||
const tiebreak = `listPage(limit, (take) =>
|
||||
prisma.x.findMany({ orderBy: [{ createdAt: "desc" }, { id: "desc" }], take }))`;
|
||||
const [good] = callBodies(tiebreak, "listPage");
|
||||
expect(endsInId(orderByValue(good)!, tiebreak)).toBe(true);
|
||||
|
||||
// A shorthand `orderBy` resolves through its declaration.
|
||||
const viaVariable = `const orderBy: X[] = [primaryOrder, { id: order }];
|
||||
listPage(limit, (take) => prisma.x.findMany({ where, orderBy, take }))`;
|
||||
const [short] = callBodies(viaVariable, "listPage");
|
||||
expect(endsInId(orderByValue(short)!, viaVariable)).toBe(true);
|
||||
// A distinct read is ordered whole by its own key.
|
||||
const distinctRead = `listPage(limit, (take) =>
|
||||
prisma.x.findMany({ distinct: ["analyte"], orderBy: { analyte: "asc" }, take }))`;
|
||||
const [dist] = callBodies(distinctRead, "listPage");
|
||||
expect(ordersByDistinctKey(dist, orderByValue(dist)!)).toBe(true);
|
||||
expect(ordersByDistinctKey(dist, '{ createdAt: "desc" }')).toBe(false);
|
||||
const viaBadVariable = `const orderBy = [primaryOrder];
|
||||
listPage(limit, (take) => prisma.x.findMany({ where, orderBy, take }))`;
|
||||
const [bad] = callBodies(viaBadVariable, "listPage");
|
||||
expect(endsInId(orderByValue(bad)!, viaBadVariable)).toBe(false);
|
||||
});
|
||||
|
||||
it("finds the capped routes", () => {
|
||||
const capped = routes.filter((f) => /\bapiListSuccess\s*\(/.test(read(f)));
|
||||
expect(capped.length).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it("every route answering a capped list gets its cap from listPage", () => {
|
||||
const unbound = routes.filter((f) => {
|
||||
const code = read(f);
|
||||
return (
|
||||
/\bapiListSuccess\s*\(/.test(code) && !/\blistPage\s*\(/.test(code)
|
||||
);
|
||||
});
|
||||
expect(unbound).toEqual([]);
|
||||
});
|
||||
|
||||
it("nothing cuts a list by hand outside the list modules", () => {
|
||||
const handRolled = files.filter(
|
||||
(f) =>
|
||||
!LIST_MODULES.has(f) && /\b(?:capRows|overfetch)\s*\(/.test(read(f)),
|
||||
);
|
||||
expect(handRolled).toEqual([]);
|
||||
});
|
||||
|
||||
it("no hasMore is a length comparison", () => {
|
||||
const guessed = routes.filter((f) =>
|
||||
/\bhasMore\s*:[^,\n]*\.length\b/.test(read(f)),
|
||||
);
|
||||
expect(guessed).toEqual([]);
|
||||
});
|
||||
|
||||
it("every listPage or capped read orders by something that ends in the row id", () => {
|
||||
const unordered: string[] = [];
|
||||
let reads = 0;
|
||||
for (const f of files) {
|
||||
if (LIST_MODULES.has(f)) continue;
|
||||
const code = read(f);
|
||||
for (const body of [
|
||||
...callBodies(code, "listPage"),
|
||||
...callBodies(code, "capped"),
|
||||
]) {
|
||||
reads += 1;
|
||||
const order = orderByValue(body);
|
||||
if (
|
||||
!order ||
|
||||
!(endsInId(order, code) || ordersByDistinctKey(body, order))
|
||||
) {
|
||||
unordered.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(reads).toBeGreaterThanOrEqual(18);
|
||||
expect(unordered).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
@@ -89,6 +91,109 @@ function violations(name: string, sql: string): string[] {
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* `CREATE INDEX CONCURRENTLY` / `DROP INDEX CONCURRENTLY` refuse to run in a
|
||||
* transaction block, and under `migrate deploy` they do so whenever the file
|
||||
* holds any other statement (SQLSTATE 25001, seen against Prisma 7 and
|
||||
* Postgres 16). Such a statement therefore stands alone in its file.
|
||||
*/
|
||||
function concurrentViolations(name: string, sql: string): string[] {
|
||||
const statements = topLevelStatements(sql);
|
||||
const concurrent = statements.filter((statement) =>
|
||||
/\bCONCURRENTLY\b/i.test(statement),
|
||||
);
|
||||
if (concurrent.length === 0 || statements.length === 1) return [];
|
||||
return [
|
||||
`${name}: a CONCURRENTLY statement must be the only statement in its file`,
|
||||
];
|
||||
}
|
||||
|
||||
describe("concurrent index statements", () => {
|
||||
it("stand alone in their migration file", () => {
|
||||
const found = migrations().flatMap(({ name, sql }) =>
|
||||
concurrentViolations(name, sql),
|
||||
);
|
||||
expect(found, found.join("\n")).toEqual([]);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(
|
||||
concurrentViolations(
|
||||
"a",
|
||||
'DROP INDEX CONCURRENTLY IF EXISTS "x";\nDROP INDEX CONCURRENTLY IF EXISTS "y";',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
concurrentViolations(
|
||||
"b",
|
||||
'-- CONCURRENTLY in a comment\nDROP INDEX CONCURRENTLY IF EXISTS "x";',
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `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 }) =>
|
||||
@@ -97,11 +202,14 @@ describe("migration lock waits", () => {
|
||||
expect(found, found.join("\n")).toEqual([]);
|
||||
});
|
||||
|
||||
it("sees the one migration that bounds its wait today", () => {
|
||||
it("sees the migrations that bound their wait", () => {
|
||||
const marked = migrations()
|
||||
.filter(({ sql }) => RETRY_MARKER.test(sql))
|
||||
.map(({ name }) => name);
|
||||
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", () => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* A module page's title is a `PageHeader` (UI standards §1, §5): the H1 on
|
||||
* one tier (`text-2xl font-bold tracking-tight`), its explainer muted under
|
||||
* it, its actions beside it.
|
||||
*
|
||||
* Four in-app headings were hand-rolled beside it — the insights sub-page
|
||||
* shell (`text-xl sm:text-2xl font-semibold`), the medication detail, the
|
||||
* check-in and its result (`font-semibold`) — and each read a step lighter
|
||||
* than every other page title. They go through `PageHeader` now; this guard
|
||||
* 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`, 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 { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(__dirname, "..");
|
||||
|
||||
const ALLOWED: Record<string, string> = {
|
||||
"components/ui/page-header.tsx": "the primitive",
|
||||
// Hero identity surfaces, not page titles.
|
||||
"components/insights/hero-strip.tsx": "insights hero greeting",
|
||||
"components/insights/coach-panel/coach-hero.tsx": "Coach hero",
|
||||
"components/insights/coach-panel/coach-conversation.tsx":
|
||||
"screen-reader title while the hero is folded away",
|
||||
// The first-run flow: full-screen steps with their own focus management.
|
||||
"components/onboarding/step-heading.tsx": "first-run step heading",
|
||||
// Outside the app shell: sign-in, register, claim, invite, the share-link
|
||||
// gate, the key-mismatch gate.
|
||||
"app/auth/login/page.tsx": "sign-in card",
|
||||
"app/auth/register/page.tsx": "register card",
|
||||
"components/auth/claim-profile.tsx": "claim card",
|
||||
"app/invite/[token]/page.tsx": "invalid invite screen",
|
||||
"components/clinician/share-unlock-gate.tsx": "share-link unlock gate",
|
||||
"app/key-mismatch-page.tsx": "key-mismatch gate",
|
||||
// Error and not-found screens.
|
||||
"app/not-found.tsx": "not-found screen",
|
||||
"app/c/[token]/not-found.tsx": "share-link not-found screen",
|
||||
"app/c/error.tsx": "share-link error screen",
|
||||
"app/global-error.tsx": "renders without the stylesheet",
|
||||
"components/error-details.tsx": "error boundary",
|
||||
// Prose pages (the prose H1 tier, §5).
|
||||
"app/about/page.tsx": "prose page",
|
||||
"app/privacy/page.tsx": "prose page",
|
||||
};
|
||||
|
||||
describe("page titles go through PageHeader", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 2500 }).filter(
|
||||
(p) =>
|
||||
p.endsWith(".tsx") &&
|
||||
(p.startsWith("components/") || p.startsWith("app/")) &&
|
||||
!p.includes("__tests__/"),
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
it("has no hand-rolled H1 outside the allowlist", () => {
|
||||
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))
|
||||
expect(set.has(key), key).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
* The batch endpoints have been capped at 60 calls a minute since they were
|
||||
* written; the per-record creates a naive or older client loops over were not
|
||||
* capped at all, which is backwards — the batch endpoint is the one a
|
||||
* well-behaved client uses. Eleven of them share one generous per-account
|
||||
* bucket now, and the operations publish its name and its cap so a client that
|
||||
* meets the ceiling can read which one it met.
|
||||
* well-behaved client uses. Every authenticated write now shares one generous
|
||||
* per-account bucket, charged where the caller is resolved
|
||||
* (`src/lib/auth/write-meter.ts`), and the operations listed below publish
|
||||
* its name and its cap so a client that meets the ceiling can read which one
|
||||
* it met.
|
||||
*
|
||||
* A published number is only worth reading if it cannot drift from the code, so
|
||||
* this pulls the bucket key and the limit out of `prisma.$queryRaw` — the same
|
||||
@@ -15,9 +17,9 @@
|
||||
* would prove nothing: the sentence is written by hand, deliberately, and this
|
||||
* is what keeps the hand-written half honest.
|
||||
*
|
||||
* The route half is structural: every route in the list below calls the shared
|
||||
* helper. Its limit is that it reads the file rather than the running handler,
|
||||
* so a call placed after an early return would still count as present.
|
||||
* The route half is structural: every route in the list below resolves its
|
||||
* caller through a metering resolver and does not opt out. Its limit is that
|
||||
* it reads the file rather than the running handler.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
} from "@/lib/rate-limit";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
import { recordWriteRateLimitResponse } from "@/lib/openapi/routes/shared";
|
||||
import { RECORD_WRITE_RATE_LIMITED_CODE } from "@/lib/auth/write-meter";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } }));
|
||||
|
||||
@@ -121,24 +124,27 @@ describe("the shared single-record write bucket", () => {
|
||||
expect(published).toContain("Retry-After");
|
||||
});
|
||||
|
||||
it("is called by every route that publishes it", () => {
|
||||
const missing = ON_THE_SHARED_BUCKET.filter(([route]) => {
|
||||
it("is charged on the path every route that publishes it takes", () => {
|
||||
// The bucket is charged by the identity resolvers (`meterAccountWrite`),
|
||||
// not by each route, so a listed route meets it by resolving its caller
|
||||
// through one of them and not opting out. `write-rate-limit-guard`
|
||||
// holds every other mutating route to the same rule.
|
||||
const unmetered = ON_THE_SHARED_BUCKET.filter(([route]) => {
|
||||
const src = readFileSync(join(process.cwd(), route), "utf8");
|
||||
return !src.includes("checkRecordWriteRateLimit(");
|
||||
return (
|
||||
!/\brequire(?:Record)?Auth\s*\(/.test(src) ||
|
||||
/\bwriteBucket\s*:\s*["']own["']/.test(src)
|
||||
);
|
||||
}).map(([route]) => route);
|
||||
expect(missing).toEqual([]);
|
||||
expect(unmetered).toEqual([]);
|
||||
});
|
||||
|
||||
it("names the code it publishes at every site", () => {
|
||||
// The prose is not the discriminator: two of these routes consult a second
|
||||
// bucket of their own, and a client that can only read `error` cannot tell
|
||||
// which one refused it.
|
||||
const silent = ON_THE_SHARED_BUCKET.filter(([route]) => {
|
||||
const src = readFileSync(join(process.cwd(), route), "utf8");
|
||||
return !src.includes('errorCode: "record_write.rate_limited"');
|
||||
}).map(([route]) => route);
|
||||
expect(silent).toEqual([]);
|
||||
expect(published).toContain("record_write.rate_limited");
|
||||
it("names the code it publishes", () => {
|
||||
// The prose is not the discriminator: several of these routes consult a
|
||||
// second bucket of their own, and a client that can only read `error`
|
||||
// cannot tell which one refused it.
|
||||
expect(RECORD_WRITE_RATE_LIMITED_CODE).toBe("record_write.rate_limited");
|
||||
expect(published).toContain(RECORD_WRITE_RATE_LIMITED_CODE);
|
||||
});
|
||||
|
||||
it("is published on every operation that answers it", () => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* A grade chip goes through `ScorePill` (`src/components/ui/score-pill.tsx`).
|
||||
*
|
||||
* Eleven chips that state a grade beside a reading (confidence, score gain,
|
||||
* band, lab range) were hand-rolled in two looks: a /15 wash without border
|
||||
* in the crosstabs, a /10 wash with a /40 border on the correlation and trend
|
||||
* cards, and the middle confidence step was blue on one and amber on the
|
||||
* other. This guard keeps a new copy of the chip from appearing beside the
|
||||
* primitive.
|
||||
*
|
||||
* Matched: one string literal that combines the pill shape (`rounded-full`,
|
||||
* a small padding `px-1.5|2|2.5`, `text-xs|2xs`) with a semantic tone wash
|
||||
* (`bg-success|info|warning|destructive/10|15`). A neutral `bg-muted` chip is
|
||||
* `TagChip`'s business, not this guard's.
|
||||
*
|
||||
* Not matched: a tone assembled at runtime from a map whose entries live in
|
||||
* another literal. The guard catches the idiom, not every way to paint one.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(__dirname, "..");
|
||||
|
||||
const ALLOWED: Record<string, string> = {
|
||||
// A link to the release notes styled as a call to action, not a grade.
|
||||
"components/admin/version-tile-section.tsx":
|
||||
"update-available link, not a grade",
|
||||
};
|
||||
|
||||
const LITERAL_RE = /["'`]([^"'`\n]*\brounded-full\b[^"'`\n]*)["'`]/g;
|
||||
|
||||
function isGradeChip(cls: string): boolean {
|
||||
return (
|
||||
/(?:^|\s)px-(?:1\.5|2|2\.5)(?:\s|$)/.test(cls) &&
|
||||
/(?:^|\s)text-(?:xs|2xs)(?:\s|$)/.test(cls) &&
|
||||
/(?:^|\s)bg-(?:success|info|warning|destructive)\/1[05](?:\s|$)/.test(cls)
|
||||
);
|
||||
}
|
||||
|
||||
function findGradeChips(files: { path: string; source: string }[]): string[] {
|
||||
const hits = new Set<string>();
|
||||
for (const { path, source } of files) {
|
||||
for (const match of source.matchAll(LITERAL_RE)) {
|
||||
if (isGradeChip(match[1])) hits.add(path);
|
||||
}
|
||||
}
|
||||
return [...hits].sort();
|
||||
}
|
||||
|
||||
describe("grade chips go through ScorePill", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 2500 })
|
||||
.filter(
|
||||
(p) =>
|
||||
(p.startsWith("components/") || p.startsWith("app/")) &&
|
||||
!p.startsWith("components/ui/") &&
|
||||
!p.includes("__tests__/"),
|
||||
)
|
||||
.map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") }));
|
||||
|
||||
it("walks the UI tree", () => {
|
||||
expect(files.length).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("has no hand-rolled grade chip outside the allowlist", () => {
|
||||
expect(findGradeChips(files).filter((p) => !(p in ALLOWED))).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every allowlist entry live", () => {
|
||||
const hits = new Set(findGradeChips(files));
|
||||
for (const key of Object.keys(ALLOWED))
|
||||
expect(hits.has(key), key).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises the idiom it exists to refuse", () => {
|
||||
expect(
|
||||
findGradeChips([
|
||||
{
|
||||
path: "components/a.tsx",
|
||||
source:
|
||||
'const C = { high: "bg-success/15 text-success" }; <span className={cn("shrink-0 rounded-full px-2 py-0.5 text-xs font-medium bg-info/15 text-info")} />',
|
||||
},
|
||||
{
|
||||
path: "components/b.tsx",
|
||||
source:
|
||||
'<span className="bg-muted rounded-full px-2 py-0.5 text-xs" />',
|
||||
},
|
||||
]),
|
||||
).toEqual(["components/a.tsx"]);
|
||||
});
|
||||
|
||||
it("is used where the copies were", () => {
|
||||
const users = files.filter((f) => f.source.includes("<ScorePill"));
|
||||
expect(users.length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
@@ -30,10 +30,6 @@ const ALLOWED: Record<string, { hits: number; why: string }> = {
|
||||
hits: 1,
|
||||
why: "computed_at is timestamptz",
|
||||
},
|
||||
"src/lib/rollups/medication-compliance-rollups.ts": {
|
||||
hits: 1,
|
||||
why: "computed_at is timestamptz",
|
||||
},
|
||||
"src/lib/jobs/job-failures.ts": {
|
||||
hits: 2,
|
||||
why: "pg-boss tables are timestamptz",
|
||||
|
||||
@@ -164,7 +164,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
// behind the delete toast's Undo action.
|
||||
"toast.success": 3,
|
||||
},
|
||||
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
|
||||
// v1.36.x — two call sites, one decision: the intake toast routes through
|
||||
// `intakeToastOptions()`, which returns `undefined` in the caller's own
|
||||
// record, and sonner's one-argument form is what the unit tests assert. So
|
||||
@@ -214,25 +213,10 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/insights/coach-panel/suggested-action-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/confidence-badge.ts": { "text-success": 1 },
|
||||
"src/components/insights/daily-briefing.tsx": { "text-success": 1 },
|
||||
"src/components/insights/derived/band-tokens.ts": { "text-success": 1 },
|
||||
// The green `+N` chip beside the Health Score label. It reports a week that
|
||||
// went up, not an action that succeeded, and it wears the same band token
|
||||
// the score's own number does — there is no outcome here to route through
|
||||
// the outcome module.
|
||||
"src/components/insights/health-score-card.tsx": { "text-success": 1 },
|
||||
"src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 },
|
||||
"src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 },
|
||||
"src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 },
|
||||
"src/components/insights/mood/mood-factor-metric-crosstab.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/mood/mood-tag-influence.tsx": { "text-success": 1 },
|
||||
"src/components/insights/mood/mood-tag-metric-crosstab.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/personal-record-badge.tsx": { "text-success": 1 },
|
||||
"src/components/labs/biomarker-form.tsx": { "toast.success": 1 },
|
||||
"src/components/labs/biomarker-manager.tsx": { "toast.success": 2 },
|
||||
"src/components/labs/lab-biomarker-detail.tsx": { "toast.success": 1 },
|
||||
@@ -369,10 +353,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/settings/insights-pill-order-section.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/settings/integration-status-pill.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/integrations/connections-panel.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
@@ -430,6 +410,11 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/settings/webhook-card.tsx": { "text-success": 1 },
|
||||
"src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 },
|
||||
"src/components/ui/password-strength.tsx": { "text-success": 2 },
|
||||
// The success tone of the grade chip (a high confidence, a score that went
|
||||
// up). It states a grade beside a reading, never that a write succeeded;
|
||||
// the crosstab, confidence and score chips that each carried their own copy
|
||||
// of this class now render through it.
|
||||
"src/components/ui/score-pill.tsx": { "text-success": 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,7 +85,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { serialiseDocument } from "@/lib/documents/store";
|
||||
import { resolveIntakeActionability } from "@/lib/medications/intake-actionable";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { dayKeyForScheduledFor } from "@/lib/medications/dose-day-refresh";
|
||||
import {
|
||||
dateOnlyAtNoonUtc,
|
||||
dayKeyAsUtcMidnight,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Structural guard: every mutating route meets a rate limit.
|
||||
*
|
||||
* 169 of 364 mutating handlers met no limiter at all: every PATCH and DELETE
|
||||
* on an `[id]`, and most single-record creates. The shared per-account write
|
||||
* bucket now sits on the identity resolvers (`meterAccountWrite`,
|
||||
* `src/lib/auth/write-meter.ts`), so a route that resolves its caller through
|
||||
* one of them is metered without knowing it. This guard is what keeps that
|
||||
* true for the routes added next year:
|
||||
*
|
||||
* - a route file exporting POST, PUT, PATCH or DELETE must either resolve
|
||||
* its caller through a metering resolver, call a limiter of its own, or
|
||||
* stand in {@link UNMETERED} with the reason;
|
||||
* - a route that opts out of the shared bucket (`writeBucket: "own"`) must
|
||||
* stand in {@link OWN_BUCKET} with the reason AND call its own limiter, so
|
||||
* the opt-out can never mean "no limit";
|
||||
* - a helper that limits on a route's behalf is accepted only while its own
|
||||
* source calls a limiter.
|
||||
*
|
||||
* The routes are derived from the tree, never listed by hand. The check is
|
||||
* per file, not per handler: a file whose GET resolves through a metering
|
||||
* resolver while its POST does not would pass. Comments are stripped first;
|
||||
* the planted cases at the top prove the matchers fire.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** The resolvers in `api-handler.ts` that charge the shared write bucket. */
|
||||
const METERING_RESOLVERS = [
|
||||
"requireAuth",
|
||||
"requireRecordAuth",
|
||||
"requireActorAuth",
|
||||
"requireGuardianAuth",
|
||||
"requireAdmin",
|
||||
"requireCookieAuth",
|
||||
"requireFreshMfa",
|
||||
"requireFreshMfaIfEnrolled",
|
||||
"requireFreshMfaOrElevationIfEnrolled",
|
||||
"requireRecentProof",
|
||||
"requireBearerAuth",
|
||||
"requireMfaManagementAuth",
|
||||
];
|
||||
|
||||
/** Helpers that call a limiter on the route's behalf, and where they live. */
|
||||
const LIMITING_HELPERS: Record<string, string[]> = {
|
||||
claimGate: ["lib/managed-profiles/claim-gate.ts"],
|
||||
applyWebhookRateLimit: [
|
||||
"lib/withings/webhook-handler.ts",
|
||||
"lib/whoop/webhook-handler.ts",
|
||||
],
|
||||
};
|
||||
|
||||
/** Mutating routes with no limit, each with the reason it needs none. */
|
||||
const UNMETERED: Record<string, string> = {
|
||||
"app/api/auth/logout/route.ts":
|
||||
"Ends the caller's own session or token and nothing else. It is idempotent, writes no record data, and refusing it would keep alive a credential its holder asked to end.",
|
||||
};
|
||||
|
||||
/** Routes metered by a dedicated bucket instead of the shared one. */
|
||||
const OWN_BUCKET: Record<string, string> = {
|
||||
"app/api/measurements/batch/route.ts":
|
||||
"The HealthKit uploader sends up to 60 batches of 500 a minute, and an outbox replay of parked pages bypasses the client pacer; the route's own 60-a-minute batch bucket meters it.",
|
||||
"app/api/workouts/batch/route.ts":
|
||||
"The workout importer sends page after page during a catch-up pass; the route's own batch bucket meters it.",
|
||||
"app/api/medications/intake/bulk/route.ts":
|
||||
"The Apple Health dose import and the standalone adoption send 200 to 500 intakes per request in sequence; the route's own bulk bucket meters it.",
|
||||
"app/api/mood-entries/bulk/route.ts":
|
||||
"The standalone adoption sends 200 entries per request in sequence; the route's own bulk bucket meters it.",
|
||||
"app/api/cycle/day-logs/bulk/route.ts":
|
||||
"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.",
|
||||
};
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
const WRITES =
|
||||
/export\s+(?:const|async\s+function)\s+(POST|PUT|PATCH|DELETE)\b/;
|
||||
const OWN_LIMITER = /\b(?:check\w*RateLimit|checkRateLimit)\s*\(/;
|
||||
const OPTS_OUT = /\bwriteBucket\s*:\s*["']own["']/;
|
||||
|
||||
function callsAny(code: string, names: readonly string[]): boolean {
|
||||
return names.some((name) => new RegExp(`\\b${name}\\s*\\(`).test(code));
|
||||
}
|
||||
|
||||
function limited(code: string): boolean {
|
||||
return (
|
||||
OWN_LIMITER.test(code) || callsAny(code, Object.keys(LIMITING_HELPERS))
|
||||
);
|
||||
}
|
||||
|
||||
describe("every mutating route meets a rate limit", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
|
||||
(file) => !file.startsWith("generated/") && !file.includes("__tests__/"),
|
||||
);
|
||||
const read = (file: string) =>
|
||||
stripComments(readFileSync(join(SRC, file), "utf8"));
|
||||
const mutating = files
|
||||
.filter((f) => /^app\/.+\/route\.tsx?$/.test(f))
|
||||
.filter((f) => WRITES.test(read(f)));
|
||||
|
||||
it("the matchers fire on planted sources", () => {
|
||||
expect(WRITES.test("export const PATCH = apiHandler(async () => {")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(WRITES.test("export async function DELETE(req) {")).toBe(true);
|
||||
expect(WRITES.test("export const GET = apiHandler(async () => {")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
callsAny(
|
||||
'const { user } = await requireRecordAuth("write", "labs");',
|
||||
METERING_RESOLVERS,
|
||||
),
|
||||
).toBe(true);
|
||||
// A resolver named in a comment or an import does not count.
|
||||
expect(
|
||||
callsAny(
|
||||
stripComments(
|
||||
"import { requireAuth } from 'x';\n// requireAuth() here",
|
||||
),
|
||||
METERING_RESOLVERS,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(OPTS_OUT.test('requireAuth(SCOPE, { writeBucket: "own" })')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(limited("const rl = await checkRateLimit(`k:${id}`, 5, 60)")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(limited("const rl = rateLimitHeaders(x)")).toBe(false);
|
||||
});
|
||||
|
||||
it("finds the mutating routes", () => {
|
||||
expect(mutating.length).toBeGreaterThanOrEqual(300);
|
||||
});
|
||||
|
||||
it("meters every mutating route, or names why not", () => {
|
||||
const unlimited = mutating.filter((f) => {
|
||||
if (f in UNMETERED) return false;
|
||||
const code = read(f);
|
||||
const metered =
|
||||
callsAny(code, METERING_RESOLVERS) && !OPTS_OUT.test(code);
|
||||
return !metered && !limited(code);
|
||||
});
|
||||
expect(unlimited).toEqual([]);
|
||||
});
|
||||
|
||||
it("holds every opt-out to the reviewed list, and each to its own limiter", () => {
|
||||
const optedOut = mutating.filter((f) => OPTS_OUT.test(read(f))).sort();
|
||||
expect(optedOut).toEqual(Object.keys(OWN_BUCKET).sort());
|
||||
const withoutOwn = optedOut.filter((f) => !OWN_LIMITER.test(read(f)));
|
||||
expect(withoutOwn).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a limiting helper only while it calls a limiter", () => {
|
||||
for (const [helper, homes] of Object.entries(LIMITING_HELPERS)) {
|
||||
for (const home of homes) {
|
||||
const code = read(home);
|
||||
expect(code, home).toMatch(
|
||||
new RegExp(`export\\s+async\\s+function\\s+${helper}\\b`),
|
||||
);
|
||||
expect(OWN_LIMITER.test(code), home).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the exceptions to routes that exist and still need them", () => {
|
||||
for (const route of Object.keys(UNMETERED)) {
|
||||
expect(mutating).toContain(route);
|
||||
const code = read(route);
|
||||
expect(callsAny(code, METERING_RESOLVERS) || limited(code), route).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("charges the bucket in each metering resolver", () => {
|
||||
const handler = read("lib/api-handler.ts");
|
||||
for (const name of [
|
||||
"requireAuth",
|
||||
"requireActorAuth",
|
||||
"requireRecordAuth",
|
||||
"requireGuardianAuth",
|
||||
"requireAdmin",
|
||||
"requireCookieAuth",
|
||||
"requireFreshMfa",
|
||||
"requireBearerAuth",
|
||||
]) {
|
||||
const start = handler.search(
|
||||
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
|
||||
);
|
||||
expect(start, name).toBeGreaterThanOrEqual(0);
|
||||
const next = handler.indexOf("\nexport ", start + 1);
|
||||
const body = handler.slice(start, next === -1 ? undefined : next);
|
||||
expect(body, name).toMatch(/\bmeterAccountWrite\s*\(/);
|
||||
}
|
||||
// The three that only delegate reach the meter through `requireAuth`.
|
||||
for (const name of [
|
||||
"requireFreshMfaIfEnrolled",
|
||||
"requireFreshMfaOrElevationIfEnrolled",
|
||||
"requireRecentProof",
|
||||
]) {
|
||||
const start = handler.search(
|
||||
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
|
||||
);
|
||||
const next = handler.indexOf("\nexport ", start + 1);
|
||||
expect(handler.slice(start, next), name).toMatch(/\brequireAuth\s*\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type AchievementCategory,
|
||||
type AchievementProgress,
|
||||
} from "@/lib/gamification/achievements";
|
||||
import { TrendTile } from "@/components/ui/trend-tile";
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Activity,
|
||||
@@ -351,7 +352,7 @@ export default function AchievementsPage() {
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
|
||||
<TrendTile className="min-h-34 justify-between">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.points")}
|
||||
</p>
|
||||
@@ -370,8 +371,8 @@ export default function AchievementsPage() {
|
||||
className="h-1.5"
|
||||
aria-label={t("achievements.points")}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
|
||||
</TrendTile>
|
||||
<TrendTile className="min-h-34 justify-between">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.unlocked")}
|
||||
</p>
|
||||
@@ -384,8 +385,8 @@ export default function AchievementsPage() {
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("achievements.remainingUnlocks", { count: remainingUnlocks })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card border-border min-h-34 rounded-xl border p-4 sm:col-span-2 md:p-6 lg:col-span-1">
|
||||
</TrendTile>
|
||||
<TrendTile className="min-h-34 sm:col-span-2 lg:col-span-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.nextGoal")}
|
||||
</p>
|
||||
@@ -428,7 +429,7 @@ export default function AchievementsPage() {
|
||||
className="mt-3 h-1.5"
|
||||
aria-label={t("achievements.nextProgressLabel")}
|
||||
/>
|
||||
</div>
|
||||
</TrendTile>
|
||||
</div>
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
|
||||
@@ -40,7 +40,8 @@ import { prisma } from "@/lib/db";
|
||||
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -99,31 +100,31 @@ export const GET = apiHandler(async () => {
|
||||
const retentionDays = getAuditLogRetentionDays();
|
||||
const windowStart = new Date(Date.now() - retentionDays * 86_400_000);
|
||||
|
||||
const found = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
actorUserId: { not: null },
|
||||
// A switch into one's own record is not a delegation and has nothing to
|
||||
// report. `actingActorFor` already refuses to stamp one, so this is the
|
||||
// second end of that rule rather than its only enforcement.
|
||||
NOT: { actorUserId: user.id },
|
||||
// The window the copy promises. The purge job makes it true eventually;
|
||||
// this makes it true now, which matters on an instance whose retention
|
||||
// was shortened after rows were already written.
|
||||
createdAt: { gte: windowStart },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: overfetch(MAX_ROWS),
|
||||
select: {
|
||||
id: true,
|
||||
actorUserId: true,
|
||||
action: true,
|
||||
details: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { rows, cap } = capRows(found, MAX_ROWS);
|
||||
const { rows, cap } = await listPage(MAX_ROWS, (take) =>
|
||||
prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
actorUserId: { not: null },
|
||||
// A switch into one's own record is not a delegation and has nothing to
|
||||
// report. `actingActorFor` already refuses to stamp one, so this is the
|
||||
// second end of that rule rather than its only enforcement.
|
||||
NOT: { actorUserId: user.id },
|
||||
// The window the copy promises. The purge job makes it true eventually;
|
||||
// this makes it true now, which matters on an instance whose retention
|
||||
// was shortened after rows were already written.
|
||||
createdAt: { gte: windowStart },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
actorUserId: true,
|
||||
action: true,
|
||||
details: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const actorIds = [
|
||||
...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)),
|
||||
|
||||
@@ -73,7 +73,8 @@ import {
|
||||
} from "@/lib/sharing/grant-view";
|
||||
import { GrantError, inviteGrant } from "@/lib/sharing/grants";
|
||||
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { combinedCap, listPage } from "@/lib/http/list-query";
|
||||
|
||||
/**
|
||||
* Ten invitations an hour, per account.
|
||||
@@ -92,28 +93,25 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "sharing.grants.list" } });
|
||||
|
||||
const [givenFound, receivedFound] = await Promise.all([
|
||||
prisma.accountGrant.findMany({
|
||||
where: { grantorId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: overfetch(MAX_GRANTS_PER_DIRECTION),
|
||||
include: { grantee: { select: GRANT_PARTY_SELECT } },
|
||||
}),
|
||||
prisma.accountGrant.findMany({
|
||||
where: { granteeId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: overfetch(MAX_GRANTS_PER_DIRECTION),
|
||||
include: { grantor: { select: GRANT_PARTY_SELECT } },
|
||||
}),
|
||||
]);
|
||||
const { rows: given, cap: givenCap } = capRows(
|
||||
givenFound,
|
||||
MAX_GRANTS_PER_DIRECTION,
|
||||
);
|
||||
const { rows: received, cap: receivedCap } = capRows(
|
||||
receivedFound,
|
||||
MAX_GRANTS_PER_DIRECTION,
|
||||
);
|
||||
const [{ rows: given, cap: givenCap }, { rows: received, cap: receivedCap }] =
|
||||
await Promise.all([
|
||||
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
|
||||
prisma.accountGrant.findMany({
|
||||
where: { grantorId: user.id },
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
include: { grantee: { select: GRANT_PARTY_SELECT } },
|
||||
}),
|
||||
),
|
||||
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
|
||||
prisma.accountGrant.findMany({
|
||||
where: { granteeId: user.id },
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
include: { grantor: { select: GRANT_PARTY_SELECT } },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
// Revoked and expired rows come back too. They are the consent record — "who
|
||||
// had access, from when to when, and who ended it" is the question the table
|
||||
@@ -122,10 +120,7 @@ export const GET = apiHandler(async () => {
|
||||
const now = new Date();
|
||||
// One cap per direction, one signal: either list cut short is a partial
|
||||
// answer.
|
||||
const cap = {
|
||||
truncated: givenCap.truncated || receivedCap.truncated,
|
||||
limit: MAX_GRANTS_PER_DIRECTION,
|
||||
};
|
||||
const cap = combinedCap(givenCap, receivedCap);
|
||||
return apiListSuccess(
|
||||
{
|
||||
given: given.map((g) => toGrantView(g, g.grantee, now)),
|
||||
|
||||
@@ -15,13 +15,11 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -29,7 +27,8 @@ import {
|
||||
allergyListQuerySchema,
|
||||
} from "@/lib/validations/allergy";
|
||||
import { toAllergyDTO } from "@/lib/records/dto";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
@@ -46,16 +45,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const limit = parsed.data.limit ?? 100;
|
||||
const found = await prisma.allergy.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: overfetch(limit),
|
||||
});
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
const { rows, cap } = await listPage(limit, (take) =>
|
||||
prisma.allergy.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(parsed.data.includeInactive === "false"
|
||||
? { status: "ACTIVE" }
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
}),
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.list", entity_type: "allergy" },
|
||||
@@ -88,17 +90,7 @@ async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
// A READ or WRITE delegate reaches neither: the destination is not on their
|
||||
// Settings list, and this line refuses them with `sharing.access.denied`
|
||||
// regardless.
|
||||
const { user, actor } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { analyticsQuerySchema } from "@/lib/validations/analytics";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { summarize, wireSummary, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read";
|
||||
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
|
||||
@@ -500,8 +500,10 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
glucoseRows,
|
||||
(r) => r.glucoseContext,
|
||||
)) {
|
||||
glucoseByContext[bucket] = summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
glucoseByContext[bucket] = wireSummary(
|
||||
summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { findMany: vi.fn(), create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const req = (qs = "") =>
|
||||
new NextRequest(`http://localhost/api/auth/me/security-activity${qs}`);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/security-activity", () => {
|
||||
it("reads one row past the requested page, in a total order", async () => {
|
||||
const res = await GET(req("?limit=10"));
|
||||
expect(res.status).toBe(200);
|
||||
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0] as {
|
||||
take: number;
|
||||
orderBy: unknown;
|
||||
};
|
||||
expect(call.take).toBe(11);
|
||||
expect(call.orderBy).toEqual([{ createdAt: "desc" }, { id: "desc" }]);
|
||||
const body = (await res.json()) as {
|
||||
meta: { truncated: boolean; limit: number };
|
||||
};
|
||||
expect(body.meta).toMatchObject({ truncated: false, limit: 10 });
|
||||
});
|
||||
|
||||
it("answers a limit past the published ceiling with a 422 naming it", async () => {
|
||||
const res = await GET(req("?limit=500"));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
meta: { errorCode: string };
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(body.meta.errorCode).toBe("security_activity.invalid");
|
||||
expect(body.details.issues.map((i) => i.path)).toEqual(["limit"]);
|
||||
expect(prisma.auditLog.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,9 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { securityActivityWhere } from "@/lib/auth/security-activity";
|
||||
import { maskIp } from "@/lib/auth/device-fingerprint";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { listPage, parseListQuery } from "@/lib/http/list-query";
|
||||
import { returnAllZodIssues } from "@/lib/api-response";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -32,28 +34,34 @@ export interface SecurityActivityDTO {
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const limitParam = Number(
|
||||
request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT,
|
||||
);
|
||||
const limit =
|
||||
Number.isFinite(limitParam) && limitParam > 0
|
||||
? Math.min(Math.floor(limitParam), MAX_LIMIT)
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
const found = await prisma.auditLog.findMany({
|
||||
where: securityActivityWhere(user.id),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: overfetch(limit),
|
||||
select: {
|
||||
action: true,
|
||||
createdAt: true,
|
||||
location: true,
|
||||
ipAddress: true,
|
||||
carrier: true,
|
||||
},
|
||||
// The shared list query: an out-of-range limit is a 422 naming it, as on
|
||||
// every other list, rather than a silent clamp to something not asked for.
|
||||
const query = parseListQuery(request.nextUrl.searchParams, {
|
||||
defaultLimit: DEFAULT_LIMIT,
|
||||
maxLimit: MAX_LIMIT,
|
||||
});
|
||||
if (!query.success) {
|
||||
return returnAllZodIssues(query.error, 422, {
|
||||
errorCode: "security_activity.invalid",
|
||||
});
|
||||
}
|
||||
const { limit } = query.data;
|
||||
|
||||
const { rows, cap } = await listPage(limit, (take) =>
|
||||
prisma.auditLog.findMany({
|
||||
where: securityActivityWhere(user.id),
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
select: {
|
||||
action: true,
|
||||
createdAt: true,
|
||||
location: true,
|
||||
ipAddress: true,
|
||||
carrier: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
const events: SecurityActivityDTO[] = rows.map((r) => ({
|
||||
action: r.action,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
|
||||
@@ -23,14 +23,6 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// v1.37.19 — the zone change enqueues a compliance re-fold.
|
||||
const refoldMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ enqueued: true, error: null })),
|
||||
);
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
enqueueUserMedicationComplianceBackfill: refoldMock,
|
||||
}));
|
||||
|
||||
// The supplement nutrient rows are keyed by the same local day.
|
||||
const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true })));
|
||||
vi.mock("@/lib/medications/ingredients/recompute-job", () => ({
|
||||
@@ -110,11 +102,9 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
// Watched red: with the re-fold enqueue removed from the route (the
|
||||
// pre-v1.37.19 write), the changed-zone case fails — the compliance
|
||||
// rollup's day keys were minted under the old zone and nothing ever
|
||||
// re-bucketed them (the coverage probe compares counts, not keys).
|
||||
it("enqueues a compliance re-fold when the zone actually changes", async () => {
|
||||
// The supplement nutrient rows' day keys were minted under the old zone;
|
||||
// without the enqueue nothing re-buckets them.
|
||||
it("enqueues the nutrient recompute when the zone actually changes", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
@@ -123,11 +113,10 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "America/New_York" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(refoldMock).toHaveBeenCalledWith("user-1");
|
||||
expect(nutrientMock).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("does not re-fold when the zone is unchanged", async () => {
|
||||
it("does not recompute when the zone is unchanged", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
@@ -136,7 +125,6 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(refoldMock).not.toHaveBeenCalled();
|
||||
expect(nutrientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
|
||||
import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -62,24 +61,13 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
invalidateUserTimezone(user.id);
|
||||
|
||||
// v1.37.19 — the medication-compliance rollup keys its rows by
|
||||
// the LOCAL day the dose fell on, minted under the timezone in force at
|
||||
// write time. A zone change re-buckets those days, so the stored keys go
|
||||
// stale (and the coverage probe compares counts, not keys — it cannot
|
||||
// notice). Re-fold the trailing window through the ordinary backfill
|
||||
// job; best-effort, the singleton key collapses rapid re-picks.
|
||||
// The supplement nutrient rows are keyed by the LOCAL day the dose fell
|
||||
// on, minted under the timezone in force at write time. A zone change
|
||||
// re-buckets those days, so the account's rows are recomputed through
|
||||
// the ordinary job; best-effort, the singleton key collapses rapid
|
||||
// re-picks.
|
||||
if ((before?.timezone ?? null) !== tz) {
|
||||
const refold = await enqueueUserMedicationComplianceBackfill(user.id);
|
||||
// The supplement nutrient rows are keyed by the same local day.
|
||||
await enqueueSupplementNutrientRecompute(user.id);
|
||||
annotate({
|
||||
meta: {
|
||||
timezone_compliance_refold_enqueued: refold.enqueued,
|
||||
...(refold.error
|
||||
? { timezone_compliance_refold_error: refold.error }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("user.timezone.update", {
|
||||
|
||||
@@ -31,7 +31,6 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { mergeBiomarkers as runMerge } from "@/lib/labs/biomarker-merge";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { destroyedDetails } from "@/lib/sharing/audit-details";
|
||||
import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge";
|
||||
|
||||
@@ -45,16 +44,9 @@ async function mergeBiomarkers(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user, actor } = await requireRecordAuth("manage", "labs");
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
@@ -76,17 +75,7 @@ async function postBiomarker(request: NextRequest) {
|
||||
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
|
||||
// to the same resolved id, so a delegate adding a marker the owner already
|
||||
// tracks gets the ordinary 409 rather than a second copy.
|
||||
const { user, actor } = await requireRecordAuth("write", "labs");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -53,6 +53,22 @@ const EXISTING = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* The row as the reads load it: with the newest live entry and the live entry
|
||||
* count joined on, which the published row carries as `latest` and
|
||||
* `entryCount`.
|
||||
*/
|
||||
const JOINS = {
|
||||
entries: [
|
||||
{
|
||||
value: 42.5,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-06-03T07:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
_count: { entries: 7 },
|
||||
};
|
||||
|
||||
const params = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
|
||||
function patchReq(body: unknown): NextRequest {
|
||||
@@ -77,17 +93,54 @@ beforeEach(() => {
|
||||
describe("GET /api/custom-metrics/[id]", () => {
|
||||
it("returns the metric for its owner", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(
|
||||
EXISTING as never,
|
||||
);
|
||||
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({
|
||||
...EXISTING,
|
||||
...JOINS,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/custom-metrics/cm-1"),
|
||||
params("cm-1"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { name: string } | null };
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
name: string;
|
||||
latest: { value: number; measuredAt: string } | null;
|
||||
entryCount: number;
|
||||
} | null;
|
||||
};
|
||||
expect(body.data?.name).toBe("Grip strength");
|
||||
// The published row carries the latest value and the count on its own,
|
||||
// as it does in the catalogue; the read asks for both joins.
|
||||
expect(body.data?.latest).toEqual({
|
||||
value: 42.5,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-06-03T07:00:00.000Z",
|
||||
});
|
||||
expect(body.data?.entryCount).toBe(7);
|
||||
const findCall = vi.mocked(prisma.customMetric.findFirst).mock
|
||||
.calls[0][0] as { include?: Record<string, unknown> };
|
||||
expect(Object.keys(findCall.include ?? {})).toEqual(["entries", "_count"]);
|
||||
});
|
||||
|
||||
it("answers latest null and a zero count for a metric with no entries", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({
|
||||
...EXISTING,
|
||||
entries: [],
|
||||
_count: { entries: 0 },
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/custom-metrics/cm-1"),
|
||||
params("cm-1"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { latest: unknown; entryCount: number };
|
||||
};
|
||||
expect(body.data.latest).toBeNull();
|
||||
expect(body.data.entryCount).toBe(0);
|
||||
});
|
||||
|
||||
it("404s a cross-user metric", async () => {
|
||||
@@ -113,6 +166,7 @@ describe("PATCH /api/custom-metrics/[id]", () => {
|
||||
);
|
||||
vi.mocked(prisma.customMetric.update).mockResolvedValue({
|
||||
...EXISTING,
|
||||
...JOINS,
|
||||
targetLow: 20,
|
||||
targetHigh: 50,
|
||||
} as never);
|
||||
@@ -126,6 +180,12 @@ describe("PATCH /api/custom-metrics/[id]", () => {
|
||||
.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(updateCall.data.targetLow).toBe(20);
|
||||
expect(updateCall.data.targetHigh).toBe(50);
|
||||
// The edit echo is the same published row as the read.
|
||||
const body = (await res.json()) as {
|
||||
data: { entryCount: number; latest: { value: number } | null };
|
||||
};
|
||||
expect(body.data.entryCount).toBe(7);
|
||||
expect(body.data.latest?.value).toBe(42.5);
|
||||
});
|
||||
|
||||
it("persists only the explicit correlation opt-in field", async () => {
|
||||
@@ -135,6 +195,7 @@ describe("PATCH /api/custom-metrics/[id]", () => {
|
||||
);
|
||||
vi.mocked(prisma.customMetric.update).mockResolvedValue({
|
||||
...EXISTING,
|
||||
...JOINS,
|
||||
correlationEnabled: true,
|
||||
} as never);
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
|
||||
@@ -108,15 +107,6 @@ async function postCustomMetricEntry(
|
||||
// was never wrong, and the GET arm above stays delegable meanwhile.
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
const { id } = await params;
|
||||
|
||||
const metric = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetric } from "@/lib/custom-metrics/custom-metric-store";
|
||||
import {
|
||||
CUSTOM_METRIC_LATEST_INCLUDE,
|
||||
serialiseCustomMetricWithLatest,
|
||||
} from "@/lib/custom-metrics/custom-metric-store";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { updateCustomMetricSchema } from "@/lib/validations/custom-metrics";
|
||||
@@ -41,8 +44,11 @@ export const GET = apiHandler(
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
// The same joins as the list read: the published row carries the latest
|
||||
// value and the entry count, on its own as in the catalogue.
|
||||
const row = await prisma.customMetric.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: CUSTOM_METRIC_LATEST_INCLUDE,
|
||||
});
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Custom metric not found", 404);
|
||||
@@ -53,7 +59,13 @@ export const GET = apiHandler(
|
||||
meta: { customMetricId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseCustomMetric(row));
|
||||
return apiSuccess(
|
||||
serialiseCustomMetricWithLatest({
|
||||
...row,
|
||||
latest: row.entries[0] ?? null,
|
||||
entryCount: row._count.entries,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -135,7 +147,11 @@ export const PATCH = apiHandler(
|
||||
return apiError("targetLow must not exceed targetHigh", 422);
|
||||
}
|
||||
|
||||
const updated = await prisma.customMetric.update({ where: { id }, data });
|
||||
const updated = await prisma.customMetric.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: CUSTOM_METRIC_LATEST_INCLUDE,
|
||||
});
|
||||
|
||||
await auditLog("customMetric.update", {
|
||||
userId: user.id,
|
||||
@@ -149,7 +165,13 @@ export const PATCH = apiHandler(
|
||||
});
|
||||
invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
return apiSuccess(serialiseCustomMetric(updated));
|
||||
return apiSuccess(
|
||||
serialiseCustomMetricWithLatest({
|
||||
...updated,
|
||||
latest: updated.entries[0] ?? null,
|
||||
entryCount: updated._count.entries,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store";
|
||||
import {
|
||||
CUSTOM_METRIC_LATEST_INCLUDE,
|
||||
serialiseCustomMetricWithLatest,
|
||||
} from "@/lib/custom-metrics/custom-metric-store";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -34,15 +37,7 @@ export const GET = apiHandler(async () => {
|
||||
const rows = await prisma.customMetric.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
entries: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 1,
|
||||
select: { value: true, unit: true, measuredAt: true },
|
||||
},
|
||||
_count: { select: { entries: { where: { deletedAt: null } } } },
|
||||
},
|
||||
include: CUSTOM_METRIC_LATEST_INCLUDE,
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -19,7 +19,8 @@ import { toMenstrualCycleDTO } from "@/lib/cycle/dto";
|
||||
import { median, mad } from "@/lib/cycle";
|
||||
import { dayDiff } from "@/lib/cycle/day-math";
|
||||
import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
|
||||
const DEFAULT_LIMIT = 24;
|
||||
|
||||
@@ -51,12 +52,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
const found = await prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { startDate: "desc" },
|
||||
take: overfetch(limit),
|
||||
});
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
const { rows, cap } = await listPage(limit, (take) =>
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: [{ startDate: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
}),
|
||||
);
|
||||
|
||||
// Completed lengths (non-null lengthDays) drive the length stats.
|
||||
const lengths = rows
|
||||
|
||||
@@ -57,7 +57,10 @@ interface EntryResult {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
|
||||
|
||||
async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// Metered by this route's own bulk bucket below, not the shared per-account
|
||||
// write bucket: the native client syncs through here in bursts (see
|
||||
// `meterAccountWrite`).
|
||||
const { user } = await requireAuth(undefined, { writeBucket: "own" });
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
@@ -95,21 +94,11 @@ async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
// v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means
|
||||
// here; the gate below resolves `user.gender` against the RECORD, so the
|
||||
// module follows the record rather than the caller.
|
||||
const { user, actor } = await requireRecordAuth("manage", "cycle");
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashQueryTokens } from "@/lib/documents/content-index";
|
||||
import {
|
||||
@@ -710,16 +711,17 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
|
||||
// Hardening: the list NEVER fetches the encrypted blob column — a page of
|
||||
// 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request.
|
||||
const rows = await prisma.inboundDocument.findMany({
|
||||
where,
|
||||
omit: { contentEncrypted: true },
|
||||
orderBy,
|
||||
take: limit + 1,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
});
|
||||
const { rows: page, cap } = await listPage(limit, (take) =>
|
||||
prisma.inboundDocument.findMany({
|
||||
where,
|
||||
omit: { contentEncrypted: true },
|
||||
orderBy,
|
||||
take,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const hasMore = cap.truncated;
|
||||
const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
|
||||
|
||||
// Fact tallies for the whole page in ONE grouped count — no per-document
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { combinedCap, listPage } from "@/lib/http/list-query";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -109,27 +110,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// past reads newest-first, which is the opposite direction; resolving both
|
||||
// here means the client renders two lists instead of splitting and
|
||||
// re-sorting one, and the ordering lives in one place.
|
||||
const [upcoming, past] = await Promise.all([
|
||||
prisma.encounter.findMany({
|
||||
// `gt` and `gte` are different keys, so the caller's lower bound and this
|
||||
// arm's floor both survive the spread; only the `lte` pair collided.
|
||||
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
|
||||
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
|
||||
take,
|
||||
skip,
|
||||
include: ENCOUNTER_INCLUDE,
|
||||
}),
|
||||
prisma.encounter.findMany({
|
||||
where: {
|
||||
...where,
|
||||
occurredAt: { ...where.occurredAt, lte: pastCeiling },
|
||||
},
|
||||
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
skip,
|
||||
include: ENCOUNTER_INCLUDE,
|
||||
}),
|
||||
]);
|
||||
const [{ rows: upcoming, cap: upcomingCap }, { rows: past, cap: pastCap }] =
|
||||
await Promise.all([
|
||||
listPage(take, (overTake) =>
|
||||
prisma.encounter.findMany({
|
||||
// `gt` and `gte` are different keys, so the caller's lower bound and
|
||||
// this arm's floor both survive the spread; only the `lte` pair
|
||||
// collided.
|
||||
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
|
||||
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
|
||||
take: overTake,
|
||||
skip,
|
||||
include: ENCOUNTER_INCLUDE,
|
||||
}),
|
||||
),
|
||||
listPage(take, (overTake) =>
|
||||
prisma.encounter.findMany({
|
||||
where: {
|
||||
...where,
|
||||
occurredAt: { ...where.occurredAt, lte: pastCeiling },
|
||||
},
|
||||
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
|
||||
take: overTake,
|
||||
skip,
|
||||
include: ENCOUNTER_INCLUDE,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
// The links ride the list. The card counts what a visit produced, and the
|
||||
// edit sheet seeds its pickers from the row it was handed — a list without
|
||||
@@ -152,24 +159,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))),
|
||||
past: past.map((row) => toEncounterDTO(row, links.get(row.id))),
|
||||
};
|
||||
return apiSuccess(body);
|
||||
return apiListSuccess(body, combinedCap(upcomingCap, pastCap));
|
||||
});
|
||||
|
||||
// A double-tap on "save" must not file the same visit twice.
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter));
|
||||
|
||||
async function postEncounter(request: NextRequest): Promise<Response> {
|
||||
const { user, actor, grantId } = await requireRecordAuth("write", "profile");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
const { user, grantId } = await requireRecordAuth("write", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 32 * 1024,
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
familyHistoryListQuerySchema,
|
||||
} from "@/lib/validations/family-history";
|
||||
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
@@ -44,12 +45,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const limit = parsed.data.limit ?? 100;
|
||||
const found = await prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: overfetch(limit),
|
||||
});
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
const { rows, cap } = await listPage(limit, (take) =>
|
||||
prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
}),
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "family-history.list", entity_type: "family_history" },
|
||||
|
||||
@@ -150,7 +150,10 @@ describe("GET /api/feature-flags", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
FIND.mockResolvedValue(null);
|
||||
const res = await GET(req());
|
||||
expect(res.headers.get("Deprecation")).toBe("true");
|
||||
// RFC 9745: the deprecation date as `@` and Unix seconds — 2026-09-24.
|
||||
expect(res.headers.get("Deprecation")).toBe("@1790208000");
|
||||
// RFC 8594: the first day it may stop answering.
|
||||
expect(res.headers.get("Sunset")).toBe("Wed, 31 Mar 2027 00:00:00 GMT");
|
||||
expect(res.headers.get("Link")).toBe(
|
||||
'</api/auth/me>; rel="successor-version"',
|
||||
);
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
* "insightStatus", "documentAi" } } }
|
||||
*
|
||||
* The master is applied (every sub-switch reads false when it is off), and a
|
||||
* switch read that fails answers every switch off. The response carries
|
||||
* `Deprecation: true` and a `Link` to its successor.
|
||||
* switch read that fails answers every switch off. The response carries the
|
||||
* deprecation date, the sunset and a `Link` to its successor
|
||||
* (`src/lib/http/deprecation.ts`), and the wide event names the client build
|
||||
* that still asked.
|
||||
*
|
||||
* `Cache-Control: private, max-age=60`: switches flip rarely.
|
||||
*/
|
||||
@@ -22,9 +24,10 @@ import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { deprecated } from "@/lib/http/deprecation";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// An actor surface, and the easiest call in the set: the answer comes off
|
||||
// the `AppSettings` singleton and reads no user row at all, so there is no
|
||||
// record for a switch to substitute. It stays reachable while a switch is on
|
||||
@@ -45,7 +48,5 @@ export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
|
||||
const response = apiSuccess({ assistant });
|
||||
response.headers.set("Cache-Control", "private, max-age=60");
|
||||
response.headers.set("Deprecation", "true");
|
||||
response.headers.set("Link", '</api/auth/me>; rel="successor-version"');
|
||||
return response;
|
||||
return deprecated(response, "/api/feature-flags", request);
|
||||
});
|
||||
@@ -32,7 +32,8 @@ import {
|
||||
illnessEpisodeListQuerySchema,
|
||||
} from "@/lib/validations/illness";
|
||||
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "illness");
|
||||
@@ -59,27 +60,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { from, to, q, offset, sortDir } = parsed.data;
|
||||
const limit = parsed.data.limit ?? 50;
|
||||
const order = sortDir ?? "desc";
|
||||
const found = await prisma.illnessEpisode.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}),
|
||||
...(from || to
|
||||
? {
|
||||
onsetAt: {
|
||||
...(from ? { gte: new Date(from) } : {}),
|
||||
...(to ? { lte: new Date(to) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
|
||||
},
|
||||
// `id` breaks ties so offset paging never repeats or drops a row.
|
||||
orderBy: [{ onsetAt: order }, { id: order }],
|
||||
take: overfetch(limit),
|
||||
skip: offset ?? 0,
|
||||
});
|
||||
const { rows, cap } = capRows(found, limit);
|
||||
const { rows, cap } = await listPage(limit, (take) =>
|
||||
prisma.illnessEpisode.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(parsed.data.includeResolved === "false"
|
||||
? { resolvedAt: null }
|
||||
: {}),
|
||||
...(from || to
|
||||
? {
|
||||
onsetAt: {
|
||||
...(from ? { gte: new Date(from) } : {}),
|
||||
...(to ? { lte: new Date(to) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
|
||||
},
|
||||
// `id` breaks ties so offset paging never repeats or drops a row.
|
||||
orderBy: [{ onsetAt: order }, { id: order }],
|
||||
take,
|
||||
skip: offset ?? 0,
|
||||
}),
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "illness.episode.list", entity_type: "illness_episode" },
|
||||
|
||||
@@ -38,8 +38,8 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
unstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
@@ -337,7 +337,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// v1.4.39 — refresh the compliance rollup for the ingested
|
||||
// event's user-day.
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: apiToken.userId,
|
||||
medicationId: medication.id,
|
||||
scheduledFor: event.scheduledFor,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,7 +47,8 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { persistEcgRecording } from "@/lib/ecg/persist-recording";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -134,25 +135,26 @@ export const GET = apiHandler(async () => {
|
||||
// sync.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
|
||||
const found = await prisma.ecgRecording.findMany({
|
||||
where: { userId: user.id },
|
||||
// Everything EXCEPT `waveformEncrypted` — the list never touches the
|
||||
// encrypted blob, so no decrypt happens on this path.
|
||||
select: {
|
||||
id: true,
|
||||
recordedAt: true,
|
||||
durationSeconds: true,
|
||||
samplingFrequency: true,
|
||||
sampleCount: true,
|
||||
averageHeartRate: true,
|
||||
lead: true,
|
||||
rhythmClassification: true,
|
||||
source: true,
|
||||
},
|
||||
orderBy: { recordedAt: "desc" },
|
||||
take: overfetch(MAX_RECORDINGS),
|
||||
});
|
||||
const { rows, cap } = capRows(found, MAX_RECORDINGS);
|
||||
const { rows, cap } = await listPage(MAX_RECORDINGS, (take) =>
|
||||
prisma.ecgRecording.findMany({
|
||||
where: { userId: user.id },
|
||||
// Everything EXCEPT `waveformEncrypted` — the list never touches the
|
||||
// encrypted blob, so no decrypt happens on this path.
|
||||
select: {
|
||||
id: true,
|
||||
recordedAt: true,
|
||||
durationSeconds: true,
|
||||
samplingFrequency: true,
|
||||
sampleCount: true,
|
||||
averageHeartRate: true,
|
||||
lead: true,
|
||||
rhythmClassification: true,
|
||||
source: true,
|
||||
},
|
||||
orderBy: [{ recordedAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
}),
|
||||
);
|
||||
|
||||
const recordings = rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -201,7 +203,10 @@ export const GET = apiHandler(async () => {
|
||||
* to the same row by construction rather than by a cached response.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// Metered by this route's own bulk bucket below, not the shared per-account
|
||||
// write bucket: the native client syncs through here in bursts (see
|
||||
// `meterAccountWrite`).
|
||||
const { user } = await requireAuth(undefined, { writeBucket: "own" });
|
||||
// A device ingest carries no model text: switching AI off, or the AI
|
||||
// analysis opt-out, must never refuse a recording.
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -49,24 +50,25 @@ export const GET = apiHandler(async () => {
|
||||
// The `insights` module is the AI analysis opt-out, and nothing here was
|
||||
// written by a model.
|
||||
|
||||
const found = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: EVENT_TYPE_LIST },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
rhythmClassification: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: overfetch(MAX_EVENTS),
|
||||
});
|
||||
const { rows, cap } = capRows(found, MAX_EVENTS);
|
||||
const { rows, cap } = await listPage(MAX_EVENTS, (take) =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: EVENT_TYPE_LIST },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
rhythmClassification: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
}),
|
||||
);
|
||||
|
||||
const events = rows.map((r) => ({
|
||||
id: r.id,
|
||||
|
||||
@@ -33,6 +33,12 @@ vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
resetAt: new Date(Date.now() + 60_000),
|
||||
})),
|
||||
}));
|
||||
// The real meter, so the ceiling is proven on the path it now takes: the route
|
||||
// resolves its caller, the resolver charges the bucket. The unit setup stubs
|
||||
// the meter for every other route test.
|
||||
vi.mock("@/lib/auth/write-meter", async (importOriginal) =>
|
||||
importOriginal<typeof import("@/lib/auth/write-meter")>(),
|
||||
);
|
||||
const tzMock = vi.hoisted(() => ({ zone: "UTC" }));
|
||||
vi.mock("@/lib/tz/resolver", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/tz/resolver")>()),
|
||||
@@ -216,7 +222,7 @@ describe("POST /api/labs/ocr/commit dedup across the user's day", () => {
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/commit write ceiling", () => {
|
||||
it("charges the shared per-account record-write bucket", async () => {
|
||||
it("charges the shared per-account record-write bucket where the caller is resolved", async () => {
|
||||
const res = await POST(postReq({ rows: [numericRow(95)] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(checkRecordWriteRateLimit).toHaveBeenCalledWith("user-1");
|
||||
|
||||
@@ -20,7 +20,6 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
@@ -31,7 +30,6 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { rememberImportAlias } from "@/lib/labs/biomarker-aliases";
|
||||
@@ -159,17 +157,6 @@ function inBatchKey(
|
||||
async function commitOcrRows(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Shared per-account write ceiling, the same bucket every other lab write
|
||||
// charges (`POST /api/labs`, `POST /api/biomarkers`). A commit can write a
|
||||
// whole report's rows and mint markers on the way, so it was the one lab
|
||||
// write a looping client could repeat without any ceiling.
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
@@ -156,17 +155,7 @@ async function postLabResult(request: NextRequest) {
|
||||
// it mints it under `user.id`: the marker joins the record's catalogue, not
|
||||
// the caller's, which is the only reading of "add a result to your record"
|
||||
// that leaves the owner with a usable catalogue afterwards.
|
||||
const { user, actor } = await requireRecordAuth("write", "labs");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -21,7 +21,6 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
toLifeEventDTO,
|
||||
} from "@/lib/life-events/service";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -53,13 +51,6 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postLifeEvent));
|
||||
async function postLifeEvent(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
|
||||
@@ -293,7 +293,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// a native login mints. `requireAuth` refuses any acting-account carrier for
|
||||
// every credential shape, so the scoped token is confined to its owner's own
|
||||
// record here without this route adding an arm of its own.
|
||||
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE);
|
||||
// Metered by this route's own bulk bucket below, not the shared per-account
|
||||
// write bucket: the native client syncs through here in bursts (see
|
||||
// `meterAccountWrite`).
|
||||
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE, {
|
||||
writeBucket: "own",
|
||||
});
|
||||
const { user } = auth;
|
||||
|
||||
// A scoped credential is a bridge — a scale, a home-automation rule — and not
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMeasurementSchema,
|
||||
createBatchMeasurementSchema,
|
||||
@@ -403,16 +402,6 @@ async function postMeasurement(request: NextRequest) {
|
||||
const { user } = auth;
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(auth.actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -51,9 +51,9 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
// v1.16.1 — the schedule-replace branch recomputes the compliance
|
||||
// rollups of the tombstoned slots; stub the rollup module so the route
|
||||
// test never reaches `$executeRaw`.
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
dayKeyForScheduledFor: vi.fn(() => "2026-06-10"),
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
refreshDoseDay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// v1.40 (#1024) — the course writer and reader run against their own
|
||||
// integration suite; here they answer "no course, nothing refused".
|
||||
@@ -99,8 +99,8 @@ import {
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
refreshDoseDay,
|
||||
} from "@/lib/medications/dose-day-refresh";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -297,7 +297,7 @@ describe("PUT /api/medications/[id] — schedule replace archives a revision (v1
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(refreshDoseDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
PREVIOUS_ROW,
|
||||
] as never);
|
||||
@@ -447,19 +447,19 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"m1",
|
||||
"2026-06-10",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"m1",
|
||||
"2026-06-11",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(refreshDoseDay).toHaveBeenCalledTimes(2);
|
||||
expect(refreshDoseDay).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
dayKey: "2026-06-10",
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
expect(refreshDoseDay).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
dayKey: "2026-06-11",
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT touch intake rows when the body carries no schedules array", async () => {
|
||||
@@ -865,7 +865,7 @@ describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(refreshDoseDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
LIVE_ROW,
|
||||
] as never);
|
||||
@@ -1055,7 +1055,7 @@ describe("PUT /api/medications/[id] — shipped client on a record-only medicati
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(refreshDoseDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
STORED_ROW,
|
||||
] as never);
|
||||
|
||||
@@ -37,8 +37,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
|
||||
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
@@ -392,14 +392,14 @@ export const PUT = apiHandler(
|
||||
// scheduledFor day-keys when the dose moved across a day boundary
|
||||
// (band re-attribution can move it as well as an explicit edit); if the
|
||||
// slot is unchanged the second call collapses to a no-op upsert.
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: event.scheduledFor,
|
||||
tz: user.timezone,
|
||||
});
|
||||
if (slotMoved) {
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: targetScheduledFor,
|
||||
@@ -488,7 +488,7 @@ export const DELETE = apiHandler(
|
||||
// v1.4.39 — refresh the rollup row for the day the deleted
|
||||
// event sat on. When that day now holds zero events the helper
|
||||
// drops the rollup row entirely.
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: event.scheduledFor,
|
||||
|
||||
@@ -78,8 +78,8 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -86,8 +86,8 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
||||
|
||||
@@ -46,12 +46,12 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
dayKeyForScheduledFor: vi.fn((d: Date) => {
|
||||
const iso = d.toISOString();
|
||||
return iso.slice(0, 10);
|
||||
}),
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
refreshDoseDay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
@@ -98,8 +98,8 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
refreshDoseDay,
|
||||
} from "@/lib/medications/dose-day-refresh";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -174,7 +174,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
|
||||
const json = await res.json();
|
||||
expect(json.data).toEqual({ deleted: 0 });
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled();
|
||||
expect(refreshDoseDay).not.toHaveBeenCalled();
|
||||
// (#22) — a no-op delete queues no cross-device sync fan-out.
|
||||
expect(queueMedicationIntakeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -201,7 +201,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
|
||||
|
||||
expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3);
|
||||
// Three rows, two unique dayKeys → two recompute calls.
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
|
||||
expect(refreshDoseDay).toHaveBeenCalledTimes(2);
|
||||
|
||||
// (#22) — the whole bulk delete queues exactly ONE cross-device
|
||||
// sync fan-out, not one per removed row.
|
||||
|
||||
Loaded 100 of 500 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user