mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #658 from MBombeck/release/v1.33.0
v1.33.0 — what was silently failing
This commit is contained in:
222 files changed
+21929
-2211
No files matched your search
@@ -35,7 +35,7 @@ jobs:
|
||||
COACH_EVAL_API_KEY: ${{ secrets.COACH_EVAL_API_KEY }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
# Image is built but not pushed in that case.
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -350,7 +350,7 @@ jobs:
|
||||
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
@@ -12,9 +12,18 @@ permissions:
|
||||
jobs:
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# This guard exists to kill a hung testcontainer or a deadlocked query, not
|
||||
# to hold the suite to a performance budget. At 15 it had stopped telling
|
||||
# those apart: the suite itself takes around 13 minutes of job time (128
|
||||
# files against a real Postgres), so two consecutive runs of the SAME
|
||||
# commit range came in at 12m50s and then over 15m, and the second was
|
||||
# killed mid-run with every test to that point green. A cap that a healthy
|
||||
# run clears only on a fast runner reports runner weather, not health.
|
||||
# 25 leaves real headroom and still catches a hang inside one CI cycle.
|
||||
# If the suite ever approaches this number, split it rather than raise it.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Scan src/ for TODO/FIXME/XXX/HACK markers
|
||||
run: |
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t healthlog:scan .
|
||||
|
||||
+121
@@ -2,6 +2,127 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.33.0] — 2026-07-26
|
||||
|
||||
Eight defects, and every one of them had the same shape: something failed
|
||||
reliably, the code recorded it precisely, and no person ever found out.
|
||||
|
||||
**"Delete All Data" deleted 13 tables out of 92 and reported success.** What
|
||||
survived included mental-health questionnaires, lab results, the menstrual
|
||||
record, allergies, family history, the whole document vault, every AI
|
||||
conversation, and clinician share links that kept working. On the account row
|
||||
itself, 11 of 119 columns were cleared, so name, insurer, insurance number, home
|
||||
coordinates, profile photo and every integration credential stayed. The wipe now
|
||||
covers everything the account owns except what is needed to sign in again, the
|
||||
list is derived from the schema so a newly added table cannot quietly survive,
|
||||
and the confirmation says what will go before you agree rather than after.
|
||||
|
||||
**A device sync failed 1149 times without anyone being told.** The escalation
|
||||
had no rung above "temporary", so a connection could fail hourly for weeks while
|
||||
the card read "connected". Worse, providers that sync several things separately
|
||||
had one healthy part erase the error recorded by a broken one, twice an hour,
|
||||
including a connection that had already been parked for a dead credential. A
|
||||
failing connection now shows how long it has been failing, escalates after a
|
||||
day, and stops retrying after two with a reconnect button.
|
||||
|
||||
One limit worth naming rather than leaving to be discovered: the ledger
|
||||
remembers a single failing part per provider. If two parts fail at overlapping
|
||||
times, the recovery of the later one still clears the error of the earlier. That
|
||||
is narrower than the case this fixes, and it is not closed.
|
||||
|
||||
**The Environment module had never once saved a row, for anyone.** One
|
||||
declaration in 49 was missing the line that maps it to the database, so every
|
||||
write referred to a type that does not exist. The job failed every night and
|
||||
recorded that failure faithfully, and no code anywhere reads a failed job. Both
|
||||
halves are fixed: the module writes, and a failing background job now names
|
||||
itself on the admin page and on the affected screen. Past days can be fetched
|
||||
from the weather source; the nightly job fills the last week by itself and the
|
||||
backfill button covers the rest.
|
||||
|
||||
**Sleep was counted twice.** A device that re-exports a night it already sent
|
||||
left two overlapping in-bed periods, and both were added. One night read ten
|
||||
hours in the headline and twenty in the legend. The same fault inflated the
|
||||
time-asleep figure itself. Every stage total is now the measure of its own
|
||||
period rather than the sum of overlapping ones.
|
||||
|
||||
**Twenty-two controls could destroy something without asking.** Among them a
|
||||
single tap that cleared the allergy list along with a text note, and a switch
|
||||
labelled "Custom range" that deleted the stored range when switched off. Every
|
||||
destructive control is now registered, and a new one cannot ship without being
|
||||
classified.
|
||||
|
||||
**Medication stock was written off that had not expired.** The thirty-day clock
|
||||
that applies to an opened injection pen was being applied to a blister of
|
||||
tablets, so pressing out the first tablet started a countdown and a month later
|
||||
the rest were declared unusable. The container kind was recorded and never read.
|
||||
Pens and ampoules keep the clock; blisters, bottles and inhalers do not. A
|
||||
repair returns stock that was written off, and the dates that drive it can now
|
||||
be seen and corrected on the item itself.
|
||||
|
||||
**Insights scrolled sideways on every phone.** Two guards were watching for
|
||||
exactly that and both measured the document, while the app scrolls inside an
|
||||
inner area that absorbed the overflow. They now measure what actually moves.
|
||||
|
||||
**A third value for gender was accepted by the app and missing from the
|
||||
published contract in five places, and dropped or coerced in nine more.** A
|
||||
fitness calculation scored anyone without a recorded gender using the male
|
||||
coefficients, and the doctor report labelled any unrecognised value as
|
||||
non-binary. Every place the field is read, written, documented or displayed was
|
||||
gone through.
|
||||
|
||||
### Four additions
|
||||
|
||||
Medication history from a widely used iOS export tool can now be read directly,
|
||||
as the file actually is, rather than converted by hand. The scheduled time and
|
||||
the time taken stay two separate facts, which is what a hand-mapped file
|
||||
destroys. Every column is either used, reported, or named as not read, and you
|
||||
see which before you import. Seven status values are ruled on individually: only
|
||||
"taken" and "skipped" become doses, and a snoozed reminder or an unanswered
|
||||
notification is counted and named rather than turned into a decision you never
|
||||
made.
|
||||
|
||||
The Coach can tell "you never recorded this" apart from "you recorded it, but
|
||||
outside the range I looked at". Someone with 1,597 imported glucose readings
|
||||
from April 2024 was told there was no glucose data at all, and the Coach then
|
||||
changed the subject, because one reason code covered both situations and the
|
||||
instruction to move on fired on both. It now says what is actually true and
|
||||
offers to look further back.
|
||||
|
||||
A mood tag can now be paired against the next morning's resting heart rate and
|
||||
heart-rate variability. Until now a tag such as alcohol had three channels to
|
||||
land in, and the only one that looked at the following day needed a wearable
|
||||
recovery score that plenty of accounts never record, so the question of what a
|
||||
tagged evening does overnight had no path at all. The difference is printed
|
||||
without a colour verdict, because a rise reads as worse on one row and better
|
||||
on the next, and an association cannot carry that judgement.
|
||||
|
||||
Every chart on the metric pages now carries the points it drew as a table
|
||||
underneath, collapsed by default with the count in the button. It is handed the
|
||||
same points the chart draws and the same formatter the tooltip uses, so a cell
|
||||
and a tooltip cannot print two different numbers for one reading. The caption
|
||||
names the grain, so a weekly row says it is a weekly average, and units sit in
|
||||
the header rather than in every cell, which is what you want when the rows go
|
||||
into a spreadsheet.
|
||||
|
||||
### For operators
|
||||
|
||||
Four migrations. `0276` adds retention indexes, `0277` adds failure-streak
|
||||
tracking, `0278` scopes the intake import key per account, `0279` repairs
|
||||
inventory that was wrongly expired. None deletes or rewrites a measurement.
|
||||
|
||||
### Credit
|
||||
|
||||
Reported by @lutzkind (sleep double-counting, the gender contract, the
|
||||
Environment enum, the Coach retrieval window, the next-day pairing for mood tags
|
||||
in #616, and a run of earlier reports), @Nazza01 (the intake import, plus the
|
||||
export file and the format documentation that made reading it possible),
|
||||
@doenke (#636, the table under the chart) and @DrGithubble (the unit and
|
||||
precision work that led here).
|
||||
|
||||
Two of these defects were found because someone outside this project read the
|
||||
code and said what was wrong. The Environment module had been broken for every
|
||||
user since it shipped and nobody inside noticed.
|
||||
|
||||
## [1.32.39] — 2026-07-26
|
||||
|
||||
The doctor report now carries what you chose, and nothing else.
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.32.0 after the correctness/UX architecture wave: / 425, /insights 411, /measurements 412, /insights/mood 428, total 2982, baseline 129. Route caps retain focused headroom; the aggregate cap allows normal chunk noise while still catching a new ~90 KB duplicated library. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Re-measured at v1.33.0: / 429, /insights 414, /measurements 416, /insights/mood 432, total 3060, baseline 130. The aggregate rose 78 KB from v1.32.0's 2982 because that release adds surfaces rather than libraries: a dose-history import card, inventory date editing, fourteen confirmation dialogs, and a job-failure panel. Every per-route cap still passes with headroom, and the thing this aggregate exists to catch (a newly duplicated ~90 KB library) did not happen: the Recharts chunk count is still 1 and structurally guarded. Raised 3050 to 3120 for that reason, not to make a red check green. Route caps retain focused headroom. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
|
||||
"routesKbGz": {
|
||||
"/page": 460,
|
||||
"/insights/page": 445,
|
||||
"/measurements/page": 445,
|
||||
"/insights/mood/page": 460
|
||||
},
|
||||
"totalClientKbGz": 3050,
|
||||
"totalClientKbGz": 3120,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
+34
-16
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.32.39
|
||||
version: 1.33.0
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -12943,8 +12943,9 @@ components:
|
||||
description: Units the container ships with (tablets / ampoules / puffs; 1–1000, fractional allowed for split-pill
|
||||
packs). Dose-derived readouts divide by the medication's `unitsPerDose`.
|
||||
containerType:
|
||||
description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only;
|
||||
defaults to OTHER.
|
||||
description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Defaults to OTHER. PEN and
|
||||
AMPOULE are the sealed reservoirs the first dose breaches, so they carry a 30-day post-opening window; every
|
||||
other kind expires only on its printed date.
|
||||
type: string
|
||||
enum:
|
||||
- PEN
|
||||
@@ -12991,11 +12992,15 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
markAsFirstUseAt:
|
||||
description: Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE
|
||||
flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: Opening date of the container (the user opened it without logging an intake). ACTIVE flips to IN_USE.
|
||||
`null` clears it — the container reads as unopened again, which is how a wrongly auto-opened row is
|
||||
corrected. For a PEN or an AMPOULE this also starts / stops the 30-day in-use clock; other container kinds
|
||||
carry no such clock and expire only on their printed date.
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
markAsUsedUp:
|
||||
description: "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded)."
|
||||
type: boolean
|
||||
@@ -13272,11 +13277,13 @@ components:
|
||||
- type: string
|
||||
- type: "null"
|
||||
gender:
|
||||
description: Stored gender. Null or an empty string clears it; an omitted field is left untouched.
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- MALE
|
||||
- FEMALE
|
||||
- OTHER
|
||||
- type: "null"
|
||||
displayName:
|
||||
anyOf:
|
||||
@@ -13334,8 +13341,11 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or
|
||||
empty string) clears it. `userId` is never accepted — it is narrowed from the session/token.
|
||||
description: Partial profile update. Every field is optional and an omitted field is left untouched. An explicit null
|
||||
clears any nullable field; an empty string also clears `displayName`, `dateOfBirth`, `gender`, `fullName`,
|
||||
`insurerName`, `insuranceNumber` and `insurerIkNumber`. `email` is not among them — it is validated as an
|
||||
address, so send null to clear it. `locale`, `timezone`, `timeFormat` and `dateFormat` are not clearable.
|
||||
`userId` is never accepted — it is narrowed from the session/token.
|
||||
NotificationPrefsPatchRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -20469,8 +20479,9 @@ components:
|
||||
- IN_USE
|
||||
- EXPIRED
|
||||
- USED_UP
|
||||
description: Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED =
|
||||
printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal).
|
||||
description: Container lifecycle state. ACTIVE = unopened; IN_USE = opened (on a PEN / AMPOULE the 30-day in-use clock
|
||||
is running); EXPIRED = printed expiry lapsed, or the in-use window lapsed on a clock-bearing container, with
|
||||
units left; USED_UP = drained (terminal).
|
||||
containerType:
|
||||
type: string
|
||||
enum:
|
||||
@@ -20480,8 +20491,10 @@ components:
|
||||
- INHALER
|
||||
- BOTTLE
|
||||
- OTHER
|
||||
description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level
|
||||
classification; defaults to OTHER.
|
||||
description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER); defaults to OTHER. PEN and
|
||||
AMPOULE are the sealed reservoirs the first dose breaches, so opening one starts a 30-day post-opening
|
||||
window; BLISTER / INHALER / BOTTLE / OTHER hold individually sealed units and expire only on their printed
|
||||
date.
|
||||
unitsTotal:
|
||||
anyOf:
|
||||
- type: number
|
||||
@@ -20504,14 +20517,16 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: Instant the container was first used. NULL until opened; starts the 30-day in-use clock.
|
||||
description: Instant the container was first used. NULL until opened. On a PEN / AMPOULE it starts the 30-day in-use
|
||||
clock; on the other kinds it only records that the pack is open.
|
||||
expiresAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started.
|
||||
description: Persisted MIN(firstUseAt + 30 days, printedExpiry) for a clock-bearing container (PEN / AMPOULE), else
|
||||
printedExpiry alone. NULL when no deadline applies — an opened blister with no printed date has none.
|
||||
printedExpiry:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -24028,6 +24043,7 @@ components:
|
||||
enum:
|
||||
- MALE
|
||||
- FEMALE
|
||||
- OTHER
|
||||
- type: "null"
|
||||
heightCm:
|
||||
anyOf:
|
||||
@@ -24139,6 +24155,7 @@ components:
|
||||
enum:
|
||||
- MALE
|
||||
- FEMALE
|
||||
- OTHER
|
||||
- type: "null"
|
||||
heightCm:
|
||||
anyOf:
|
||||
@@ -26037,6 +26054,7 @@ components:
|
||||
enum:
|
||||
- MALE
|
||||
- FEMALE
|
||||
- OTHER
|
||||
- type: "null"
|
||||
glucoseUnit:
|
||||
anyOf:
|
||||
|
||||
@@ -81,6 +81,31 @@ These are cited as factual public record to explain _why_ the
|
||||
"who holds the data" question is the one that matters for this category.
|
||||
HealthLog makes no claim about any other product's current practices.
|
||||
|
||||
## What deleting actually does
|
||||
|
||||
Worth knowing before you rely on it, because "deleted" means two
|
||||
different things depending on which button you press.
|
||||
|
||||
- **Deleting one cycle or day-log entry** hides it. Every read filters
|
||||
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.
|
||||
- **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.
|
||||
- **Deleting the account** removes the account row, and the database
|
||||
cascades every record with it.
|
||||
|
||||
Two consequences follow, and they matter for the seizure threat model
|
||||
above. A host imaged while a per-entry deletion is the only deletion you
|
||||
have performed still holds that entry. And a backup taken before any of
|
||||
these actions still holds whatever it captured, because a backup is a
|
||||
copy of a moment. If your reason for deleting is that the data must not
|
||||
be recoverable from the machine, use one of the two whole-record actions
|
||||
and rotate your backups.
|
||||
|
||||
## Practical hardening
|
||||
|
||||
If reproductive-health privacy is the reason you self-host, the
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
PHONE_WIDTHS,
|
||||
settleForOverflowMeasurement,
|
||||
} from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* Pixel-5 mobile smoke for the Insights overview + the cycle vertical
|
||||
* (v1.15.10). Mirrors `mobile-viewport.spec.ts`. Asserts:
|
||||
* Mobile smoke for the cycle vertical (v1.15.10). Asserts:
|
||||
*
|
||||
* 1. `/insights` has no horizontal page scroll on the Pixel-5 width — the
|
||||
* core guarantee behind the cycle-ring-width fix (the 120 px ring tile
|
||||
* used to overflow narrow grid cells).
|
||||
* 2. `/cycle` has no horizontal page scroll on the Pixel-5 width — covers
|
||||
* 1. `/cycle` has no horizontal page scroll at any phone width — covers
|
||||
* the tab-strip-overflow fix (the four long German labels used to force
|
||||
* a horizontal scroll at 375 px) and the responsive cycle ring.
|
||||
* 3. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
|
||||
* 2. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
|
||||
* height floor (44 CSS px) — the `min-h-10` → `min-h-11` bump.
|
||||
*
|
||||
* The `/insights` width case that used to live here has moved to
|
||||
* `mobile-horizontal-overflow.spec.ts`. It asserted against an EMPTY insights
|
||||
* payload and against `documentElement.scrollWidth`, so it was green through a
|
||||
* populated page that really did pan sideways inside the `AuthShell` `<main>`
|
||||
* scroll container. The replacement runs populated fixtures at several phone
|
||||
* widths and measures the scroll container the app actually uses. The cycle
|
||||
* fixture below is populated for the same reason.
|
||||
*
|
||||
* The cycle feature gate is enabled by rewriting the real `/api/auth/me`
|
||||
* response with `cycleTrackingEnabled: true` (every other field stays
|
||||
* authentic), and the `/api/cycle/*` reads are mocked so the wheel, tab strip,
|
||||
@@ -108,22 +117,48 @@ const HISTORY_BODY = {
|
||||
};
|
||||
|
||||
const INSIGHTS_BODY = {
|
||||
data: { rows: [], headline: null, symptomPatterns: [] },
|
||||
data: {
|
||||
rows: buildInsightRows(),
|
||||
headline: {
|
||||
symptom: "HEADACHE",
|
||||
phase: "LUTEAL",
|
||||
rate: 0.3,
|
||||
confidence: "high",
|
||||
},
|
||||
symptomPatterns: buildInsightRows(),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
async function assertNoHorizontalScroll(page: import("@playwright/test").Page) {
|
||||
const dims = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
/**
|
||||
* A populated cycle-insights payload. The previous fixture was
|
||||
* `{ rows: [], headline: null, symptomPatterns: [] }` — an empty page, which
|
||||
* fits any viewport by construction and proved nothing about the width
|
||||
* guarantee it was written for. Long labels are deliberate: the tab strip and
|
||||
* the summary rows are where narrow-width text pressure shows up.
|
||||
*/
|
||||
function buildInsightRows(): Array<Record<string, unknown>> {
|
||||
const symptoms = [
|
||||
"HEADACHE",
|
||||
"CRAMPS",
|
||||
"BLOATING",
|
||||
"FATIGUE",
|
||||
"MOOD_SWINGS",
|
||||
"BREAST_TENDERNESS",
|
||||
"BACK_PAIN",
|
||||
"ACNE",
|
||||
];
|
||||
return symptoms.map((symptom, i) => ({
|
||||
symptom,
|
||||
phase: ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"][i % 4],
|
||||
occurrences: 12 - i,
|
||||
totalDays: 40,
|
||||
rate: (12 - i) / 40,
|
||||
confidence: i < 4 ? "high" : "low",
|
||||
}));
|
||||
expect(
|
||||
dims.scrollWidth,
|
||||
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
}
|
||||
|
||||
test.describe("cycle + insights mobile smoke", () => {
|
||||
test.describe("cycle mobile smoke", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
@@ -175,24 +210,26 @@ test.describe("cycle + insights mobile smoke", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
await assertNoHorizontalScroll(page);
|
||||
});
|
||||
|
||||
test("/cycle has no horizontal scroll and the calendar cells clear the 44 px tap floor", async ({
|
||||
test("/cycle has no horizontal scroll at any phone width and the calendar cells clear the 44 px tap floor", async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const width of PHONE_WIDTHS) {
|
||||
await page.setViewportSize({ width, height: 851 });
|
||||
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The cycle vertical mounted (the page redirects home if the gate is off).
|
||||
const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
|
||||
await expect(wheel).toBeVisible();
|
||||
|
||||
await settleForOverflowMeasurement(page);
|
||||
await expectNoHorizontalOverflow(page, `/cycle @${width}px`);
|
||||
}
|
||||
|
||||
// Back to the Pixel-5 width for the tap-target measurement.
|
||||
await page.setViewportSize({ width: 393, height: 851 });
|
||||
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// The cycle vertical mounted (the page redirects home if the gate is off).
|
||||
const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
|
||||
await expect(wheel).toBeVisible();
|
||||
|
||||
// 1) No horizontal page scroll at the Pixel-5 width.
|
||||
await assertNoHorizontalScroll(page);
|
||||
await expect(page.locator('[data-slot="cycle-wheel-tile"]')).toBeVisible();
|
||||
await page.waitForLoadState("networkidle").catch(() => {});
|
||||
|
||||
// 2) Calendar day cells clear the 44 px tap-target height floor.
|
||||
const cells = await page.locator('[role="gridcell"]').all();
|
||||
|
||||
+24
-14
@@ -8,6 +8,7 @@ import {
|
||||
POPULATED_SUMMARIES,
|
||||
} from "./utils/mock-dashboard-snapshot";
|
||||
import { mockMoodInsights } from "./utils/mock-mood-insights";
|
||||
import { expectNoHorizontalOverflow } from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* Phone-width density guard (390×844 iPhone class, 360×780 small
|
||||
@@ -17,8 +18,12 @@ import { mockMoodInsights } from "./utils/mock-mood-insights";
|
||||
* squeezed into a one-word-per-line column beside a wide delta, the
|
||||
* delta spilling past the tile edge at 360 px):
|
||||
*
|
||||
* 1. No horizontal page overflow — `scrollWidth` never exceeds the
|
||||
* viewport width (1 px sub-pixel tolerance).
|
||||
* 1. No horizontal page overflow — neither the document nor the `AuthShell`
|
||||
* `<main>` scroll container exceeds the viewport width (1 px sub-pixel
|
||||
* tolerance). The `<main>` half is load-bearing: this guard measured only
|
||||
* the document until a report of sideways scroll on a populated Insights
|
||||
* page showed the document never widens, because `<main>` absorbs the
|
||||
* overflow into its own scrollable area.
|
||||
* 2. Nothing escapes its tile — for every card / Today hero / list
|
||||
* row, no descendant's visible box crosses the tile's left or
|
||||
* right edge (clip-aware: content genuinely hidden by an inner
|
||||
@@ -181,19 +186,24 @@ test.describe("phone-width density guard", () => {
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
const dims = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
}));
|
||||
// The document/body pair alone cannot see the app's sideways scroll:
|
||||
// `AuthShell` scrolls inside `<main id="main-content">`, whose
|
||||
// `overflow-y:auto` makes `overflow-x` compute to `auto`, so an
|
||||
// over-wide child grows `main.scrollWidth` while the document stays
|
||||
// exactly viewport-wide. The shared helper asserts both areas and
|
||||
// names the offending element.
|
||||
await expectNoHorizontalOverflow(
|
||||
page,
|
||||
`${route} @${viewport.width}x${viewport.height}`,
|
||||
);
|
||||
const bodyScrollWidth = await page.evaluate(
|
||||
() => document.body.scrollWidth,
|
||||
);
|
||||
const innerWidth = await page.evaluate(() => window.innerWidth);
|
||||
expect(
|
||||
dims.scrollWidth,
|
||||
`page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
expect(
|
||||
dims.bodyScrollWidth,
|
||||
`body scrollWidth=${dims.bodyScrollWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
bodyScrollWidth,
|
||||
`body scrollWidth=${bodyScrollWidth}`,
|
||||
).toBeLessThanOrEqual(innerWidth + 1);
|
||||
|
||||
const escapes = await page.evaluate(collectTileEscapes);
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
|
||||
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
measureHorizontalOverflow,
|
||||
PHONE_WIDTHS,
|
||||
settleForOverflowMeasurement,
|
||||
} from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* The content-heavy routes must not scroll sideways at any phone width.
|
||||
*
|
||||
* This replaces a per-surface habit of asserting
|
||||
* `documentElement.scrollWidth <= innerWidth` on whatever happened to render.
|
||||
* That assertion had two holes, and a real report walked through both:
|
||||
*
|
||||
* 1. It measured the DOCUMENT. `AuthShell` scrolls inside
|
||||
* `<main id="main-content">`, whose `overflow-y:auto` makes `overflow-x`
|
||||
* compute to `auto` — so an over-wide child grows `main.scrollWidth`, the
|
||||
* user pans the page sideways, and the document never widens by a pixel.
|
||||
* 2. It ran against an EMPTY page. A surface with no content fits any
|
||||
* viewport, so the guard proved nothing about the surface people use.
|
||||
*
|
||||
* `expectNoHorizontalOverflow` closes (1) by asserting both scroll areas and
|
||||
* naming the widest offending element on failure. The fixtures below close (2)
|
||||
* by giving each route a body before it is measured.
|
||||
*
|
||||
* Widths cover real iPhone (390 / 393 / 402 / 430) and common Android (360)
|
||||
* CSS widths — a single width hides anything whose overflow is width-dependent.
|
||||
* Runs on `chromium-mobile` only; the desktop project skips the whole file.
|
||||
*/
|
||||
|
||||
interface RouteCase {
|
||||
path: string;
|
||||
/** Something that must be on-screen before measuring — proves it is populated. */
|
||||
ready: string;
|
||||
setup?: (page: import("@playwright/test").Page) => Promise<void>;
|
||||
}
|
||||
|
||||
const ROUTE_CASES: RouteCase[] = [
|
||||
{
|
||||
path: "/insights",
|
||||
// The "signals of the day" section is the one whose heading carries the
|
||||
// long provenance sentence, so it is also the readiness signal.
|
||||
ready: '[data-slot="coincident-deviation-section"]',
|
||||
setup: mockPopulatedInsights,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
ready: "main#main-content",
|
||||
setup: (page) => mockDashboardSnapshot(page),
|
||||
},
|
||||
{ path: "/measurements", ready: "main#main-content" },
|
||||
{ path: "/medications", ready: "main#main-content" },
|
||||
];
|
||||
|
||||
test.describe("content-heavy routes have no horizontal page scroll at phone widths", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||||
});
|
||||
|
||||
for (const routeCase of ROUTE_CASES) {
|
||||
test(`${routeCase.path} does not scroll sideways on any phone width`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeCase.setup?.(page);
|
||||
|
||||
for (const width of PHONE_WIDTHS) {
|
||||
await page.setViewportSize({ width, height: 851 });
|
||||
await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(routeCase.ready).first()).toBeVisible();
|
||||
await settleForOverflowMeasurement(page);
|
||||
await expectNoHorizontalOverflow(page, `${routeCase.path} @${width}px`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixture itself has to stay honest. If `mockPopulatedInsights` ever
|
||||
* stops populating the overview — a renamed route, a changed envelope — the
|
||||
* width assertions above would keep passing against an empty page and the
|
||||
* guard would quietly go back to proving nothing. Assert the page is
|
||||
* actually carrying content, so fixture rot fails loudly instead.
|
||||
*/
|
||||
test("the /insights fixture really populates the overview", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 390, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const signals = page.locator('[data-slot="coincident-deviation-section"]');
|
||||
await expect(signals).toBeVisible();
|
||||
|
||||
// The prose in the heading's trailing action slot — the shape that widened
|
||||
// the page. It must be present AND long enough to be able to.
|
||||
const method = signals.locator('[data-slot="provenance-explainer-method"]');
|
||||
await expect(method).toBeVisible();
|
||||
const text = (await method.textContent()) ?? "";
|
||||
expect(
|
||||
text.length,
|
||||
`provenance caption too short to exercise the overflow class: "${text}"`,
|
||||
).toBeGreaterThan(60);
|
||||
|
||||
// And the surface as a whole is not the empty state.
|
||||
await expect(
|
||||
page.locator('[data-slot="section-heading"]').first(),
|
||||
).toBeVisible();
|
||||
const headingCount = await page
|
||||
.locator('[data-slot="section-heading"]')
|
||||
.count();
|
||||
expect(headingCount, "overview rendered no sections").toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* A long trailing caption must WRAP inside the row, not extend it. Asserting
|
||||
* the wrap directly (rather than only the page-level width) pins the fix at
|
||||
* the element that broke: a `shrink-0` action slot sized itself to the
|
||||
* caption's max-content width, ~1.2-1.5k px depending on locale.
|
||||
*/
|
||||
test("a long heading action caption wraps instead of widening the row", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 390, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Wait on the CAPTION, not the heading: the card first paints a skeleton
|
||||
// whose heading carries no action slot, then swaps to the resolved shell.
|
||||
// Both render `[data-slot="section-heading"]`, so gating on the heading
|
||||
// alone can measure the skeleton — or measure nothing at all, because the
|
||||
// element detaches during the swap.
|
||||
const method = page
|
||||
.locator(
|
||||
'[data-slot="coincident-deviation-section"] [data-slot="provenance-explainer-method"]',
|
||||
)
|
||||
.first();
|
||||
await expect(method).toBeVisible();
|
||||
|
||||
// Read both rects in ONE evaluate so the swap cannot land between them.
|
||||
const box = await method.evaluate((el) => {
|
||||
const headingEl = el.closest('[data-slot="section-heading"]');
|
||||
if (!headingEl) return null;
|
||||
const caption = el.getBoundingClientRect();
|
||||
const heading = headingEl.getBoundingClientRect();
|
||||
return {
|
||||
captionWidth: caption.width,
|
||||
headingWidth: heading.width,
|
||||
viewportWidth: window.innerWidth,
|
||||
};
|
||||
});
|
||||
expect(box, "caption is not inside a section heading").not.toBeNull();
|
||||
|
||||
expect(
|
||||
Math.round(box!.captionWidth),
|
||||
`caption width ${box!.captionWidth} exceeds its heading row ${box!.headingWidth} — the action slot is sizing to max-content instead of wrapping`,
|
||||
).toBeLessThanOrEqual(Math.round(box!.headingWidth) + 1);
|
||||
expect(
|
||||
Math.round(box!.headingWidth),
|
||||
"heading row is wider than the viewport",
|
||||
).toBeLessThanOrEqual(box!.viewportWidth);
|
||||
});
|
||||
|
||||
/**
|
||||
* The helper must be able to SEE overflow through the `<main>` scroll
|
||||
* container — the exact blind spot that let the previous guard pass. Inject
|
||||
* a deliberately over-wide, un-contained block and assert the measurement
|
||||
* reports it. Without this, a helper that silently measured the wrong
|
||||
* element would make every assertion above vacuous.
|
||||
*/
|
||||
test("the overflow measurement detects an over-wide block inside <main>", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 390, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
// Wait for the same readiness signal and settle step every route case
|
||||
// above uses. This was the one test in the file that measured and injected
|
||||
// straight after `domcontentloaded`, against a tree that was still
|
||||
// mounting, and it is the one that went flaky on CI: `main.scrollWidth`
|
||||
// came back at exactly `clientWidth` with a 1500px child supposedly in it.
|
||||
//
|
||||
// Two candidate mechanisms were checked against a local run rather than
|
||||
// assumed. A render swapping `main`'s children and taking the canary with
|
||||
// it does NOT happen: the node survives the skeleton-to-content swap
|
||||
// (12/12 runs, canary attached, scrollWidth 1500). That leaves `main` not
|
||||
// yet BEING the scroll container at injection time, which is what settling
|
||||
// rules out and what the assertions below now report on if it recurs.
|
||||
await expect(
|
||||
page.locator('[data-slot="coincident-deviation-section"]').first(),
|
||||
).toBeVisible();
|
||||
await settleForOverflowMeasurement(page);
|
||||
|
||||
const clean = await measureHorizontalOverflow(page);
|
||||
expect(clean.mainScrollWidth).toBeLessThanOrEqual(
|
||||
(clean.mainClientWidth ?? 0) + 1,
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const probe = document.createElement("div");
|
||||
probe.id = "overflow-canary";
|
||||
probe.style.width = "1500px";
|
||||
probe.style.height = "10px";
|
||||
document.querySelector("main#main-content")!.appendChild(probe);
|
||||
});
|
||||
|
||||
// Separate the fixture's own failure from the helper's. If the canary is
|
||||
// not inside the CURRENT `main`, the measurement below proves nothing
|
||||
// either way, and a failure that says so is a diagnosis rather than an
|
||||
// accusation.
|
||||
await expect(
|
||||
page.locator("main#main-content #overflow-canary"),
|
||||
"the canary is not inside the current <main> — the fixture failed, not the helper",
|
||||
).toBeAttached();
|
||||
|
||||
// Captured so that a recurrence names its own cause instead of costing
|
||||
// another round of guessing. `overflowX: clip` or a `main` that is not a
|
||||
// scroll container both produce the observed "scrollWidth === clientWidth
|
||||
// with a 1500px child" and are indistinguishable without this.
|
||||
const state = await page.evaluate(() => {
|
||||
const main = document.querySelector<HTMLElement>("main#main-content")!;
|
||||
const canary = document.getElementById("overflow-canary");
|
||||
const style = getComputedStyle(main);
|
||||
return {
|
||||
overflowX: style.overflowX,
|
||||
overflowY: style.overflowY,
|
||||
canaryWidth: canary?.getBoundingClientRect().width ?? null,
|
||||
canaryInMain: canary ? main.contains(canary) : false,
|
||||
};
|
||||
});
|
||||
|
||||
const dirty = await measureHorizontalOverflow(page);
|
||||
expect(
|
||||
dirty.mainScrollWidth,
|
||||
`an intentionally 1500px-wide block did not widen main's scroll area — main overflow-x:${state.overflowX} overflow-y:${state.overflowY}, canary width ${state.canaryWidth}, inside main: ${state.canaryInMain}`,
|
||||
).toBeGreaterThan((dirty.mainClientWidth ?? 0) + 1);
|
||||
expect(
|
||||
dirty.offenders.some((o) => o.selector.includes("overflow-canary")),
|
||||
`the canary was not reported as an offender; got: ${dirty.offenders
|
||||
.map((o) => o.selector)
|
||||
.join(" | ")}`,
|
||||
).toBe(true);
|
||||
// The document stayed exactly as wide as the viewport while `<main>` grew
|
||||
// by 1500px — this is the blind spot, asserted rather than described.
|
||||
expect(
|
||||
dirty.docScrollWidth,
|
||||
"document width changed, so the document-only assertion would have caught this after all",
|
||||
).toBeLessThanOrEqual(dirty.innerWidth + 1);
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,11 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/account", { waitUntil: "networkidle" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Same reason as the grid test below: gate on the element, not the
|
||||
// network, because the read underneath it happens exactly once.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /change password/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// The account page's action button(s) must live within (or above the
|
||||
// bottom of) the parent card. Since v1.18.1 the "restart onboarding"
|
||||
@@ -146,7 +150,13 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/account", { waitUntil: "networkidle" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Gate on the two elements this test is about, not on the network. An idle
|
||||
// network says nothing about whether React has rendered the profile form,
|
||||
// and the single `evaluate` below does not retry — so without this the
|
||||
// test reads an empty document and reports "language + dob fields must
|
||||
// exist", which sounds like a missing feature and is really a race.
|
||||
await expect(page.locator("#language-select")).toBeAttached();
|
||||
await expect(page.locator("#dob")).toBeAttached();
|
||||
|
||||
// The v1.4.27 R1 settings audit pairs date-of-birth with language
|
||||
// in a single `grid sm:grid-cols-2` row so the profile form keeps
|
||||
@@ -186,7 +196,10 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/ai", { waitUntil: "networkidle" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
// `evaluateAll` over an empty match set returns an empty array rather than
|
||||
// waiting, so the count assertion below would be the thing that fails and
|
||||
// it would blame the page for having no selects. Wait for the first one.
|
||||
await expect(page.locator("section select").first()).toBeVisible();
|
||||
|
||||
const heights = await page
|
||||
.locator("section select")
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Shared horizontal-overflow guard for phone widths.
|
||||
*
|
||||
* WHY THIS MEASURES `<main>` AND NOT JUST THE DOCUMENT
|
||||
* ----------------------------------------------------
|
||||
* The obvious assertion — `documentElement.scrollWidth <= innerWidth` — cannot
|
||||
* observe the app's real sideways scroll. `AuthShell` puts every route inside
|
||||
*
|
||||
* <main id="main-content" class="… overflow-y-auto …">
|
||||
*
|
||||
* and CSS says that when one of `overflow-x` / `overflow-y` is not `visible`,
|
||||
* the other computes to `auto`. So `<main>` is itself a horizontal scroll
|
||||
* container: an over-wide child grows `main.scrollWidth`, the user pans the
|
||||
* page sideways, and `documentElement.scrollWidth` never budges because
|
||||
* `<main>` absorbed the overflow. A document-only assertion is green through
|
||||
* exactly the defect a person reports as "I can scroll sideways off the page".
|
||||
*
|
||||
* Both measurements are therefore asserted. On failure the helper names the
|
||||
* widest element that is actually extending the scroll area (skipping anything
|
||||
* an ancestor already scrolls or clips — a strip that is *meant* to pan inside
|
||||
* its own `overflow-x-auto` container is not a defect), with its measured
|
||||
* width and the viewport width, so a future failure is actionable without a
|
||||
* screenshot.
|
||||
*/
|
||||
|
||||
/** Real iPhone + common Android CSS widths. */
|
||||
export const PHONE_WIDTHS = [360, 390, 393, 402, 430] as const;
|
||||
|
||||
export interface OverflowOffender {
|
||||
selector: string;
|
||||
rectWidth: number;
|
||||
rectRight: number;
|
||||
scrollWidth: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface OverflowReport {
|
||||
innerWidth: number;
|
||||
docScrollWidth: number;
|
||||
/** null when the route renders outside `AuthShell` (no `<main id>`). */
|
||||
mainScrollWidth: number | null;
|
||||
mainClientWidth: number | null;
|
||||
offenders: OverflowOffender[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure horizontal overflow of the document AND of the `AuthShell` scroll
|
||||
* container, plus the elements responsible.
|
||||
*/
|
||||
export async function measureHorizontalOverflow(
|
||||
page: Page,
|
||||
): Promise<OverflowReport> {
|
||||
return page.evaluate(() => {
|
||||
const vw = window.innerWidth;
|
||||
const de = document.documentElement;
|
||||
const main = document.querySelector<HTMLElement>("main#main-content");
|
||||
|
||||
const report = {
|
||||
innerWidth: vw,
|
||||
docScrollWidth: de.scrollWidth,
|
||||
mainScrollWidth: main ? main.scrollWidth : null,
|
||||
mainClientWidth: main ? main.clientWidth : null,
|
||||
offenders: [] as Array<{
|
||||
selector: string;
|
||||
rectWidth: number;
|
||||
rectRight: number;
|
||||
scrollWidth: number;
|
||||
path: string;
|
||||
}>,
|
||||
};
|
||||
|
||||
const overflows =
|
||||
de.scrollWidth > vw + 1 ||
|
||||
(main !== null && main.scrollWidth > main.clientWidth + 1);
|
||||
if (!overflows) return report;
|
||||
|
||||
const describe = (el: Element): string => {
|
||||
const parts = [el.tagName.toLowerCase()];
|
||||
if (el.id) parts.push(`#${el.id}`);
|
||||
const slot = el.getAttribute("data-slot");
|
||||
if (slot) parts.push(`[data-slot="${slot}"]`);
|
||||
const cls = (el.getAttribute("class") ?? "").trim();
|
||||
if (cls) parts.push(`.${cls.split(/\s+/).slice(0, 10).join(".")}`);
|
||||
return parts.join("").slice(0, 180);
|
||||
};
|
||||
|
||||
const root = main ?? document.body;
|
||||
const limit = main
|
||||
? main.getBoundingClientRect().left + main.clientWidth
|
||||
: vw;
|
||||
const scrollLeft = main ? main.scrollLeft : window.scrollX;
|
||||
|
||||
// An element whose ancestor (below `root`) scrolls or clips is contained by
|
||||
// design — the UI standards' "wide content scrolls in its own container".
|
||||
const absorbedByAncestor = (el: Element): boolean => {
|
||||
let n = el.parentElement;
|
||||
while (n && n !== root) {
|
||||
const ox = getComputedStyle(n).overflowX;
|
||||
if (
|
||||
ox === "auto" ||
|
||||
ox === "scroll" ||
|
||||
ox === "hidden" ||
|
||||
ox === "clip"
|
||||
)
|
||||
return true;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const el of Array.from(root.querySelectorAll("*"))) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
// Fixed/sticky chrome is positioned against the viewport, not the
|
||||
// scroll area, so it cannot widen it.
|
||||
const position = getComputedStyle(el).position;
|
||||
if (position === "fixed" || position === "sticky") continue;
|
||||
const right = r.right + scrollLeft;
|
||||
if (right <= limit + 1) continue;
|
||||
if (absorbedByAncestor(el)) continue;
|
||||
|
||||
const chain: string[] = [];
|
||||
let n: Element | null = el;
|
||||
while (n && n !== de) {
|
||||
chain.unshift(describe(n).slice(0, 70));
|
||||
n = n.parentElement;
|
||||
}
|
||||
report.offenders.push({
|
||||
selector: describe(el),
|
||||
rectWidth: Math.round(r.width),
|
||||
rectRight: Math.round(right),
|
||||
scrollWidth: el.scrollWidth,
|
||||
path: chain.slice(-5).join(" > "),
|
||||
});
|
||||
}
|
||||
|
||||
report.offenders.sort((a, b) => b.rectWidth - a.rectWidth);
|
||||
report.offenders = report.offenders.slice(0, 5);
|
||||
return report;
|
||||
});
|
||||
}
|
||||
|
||||
function formatReport(label: string, report: OverflowReport): string {
|
||||
const lines = [
|
||||
`${label}: horizontal overflow at viewport ${report.innerWidth}px`,
|
||||
` document.scrollWidth = ${report.docScrollWidth} (viewport ${report.innerWidth})`,
|
||||
` main.scrollWidth = ${report.mainScrollWidth} (client ${report.mainClientWidth})`,
|
||||
];
|
||||
if (report.offenders.length === 0) {
|
||||
lines.push(
|
||||
" no un-contained offender found — the overflow may come from a scroll container's own content",
|
||||
);
|
||||
}
|
||||
for (const o of report.offenders) {
|
||||
lines.push(
|
||||
` • width ${o.rectWidth}px, right edge ${o.rectRight}px — ${o.selector}`,
|
||||
);
|
||||
lines.push(` path: ${o.path}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the page does not scroll sideways — neither the document nor the
|
||||
* `AuthShell` `<main>` scroll container.
|
||||
*/
|
||||
export async function expectNoHorizontalOverflow(
|
||||
page: Page,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const report = await measureHorizontalOverflow(page);
|
||||
const message = formatReport(label, report);
|
||||
|
||||
expect(report.docScrollWidth, message).toBeLessThanOrEqual(
|
||||
report.innerWidth + 1,
|
||||
);
|
||||
if (report.mainScrollWidth !== null && report.mainClientWidth !== null) {
|
||||
expect(report.mainScrollWidth, message).toBeLessThanOrEqual(
|
||||
report.mainClientWidth + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a route before measuring: let the network go idle, pan the whole
|
||||
* scroll container so every `next/dynamic` below-the-fold block mounts, then
|
||||
* return to the top. A block that only mounts on scroll can only overflow once
|
||||
* it has mounted.
|
||||
*/
|
||||
export async function settleForOverflowMeasurement(page: Page): Promise<void> {
|
||||
await page.waitForLoadState("networkidle").catch(() => {});
|
||||
await page.evaluate(async () => {
|
||||
const main = document.querySelector<HTMLElement>("main#main-content");
|
||||
const el: HTMLElement = main ?? document.documentElement;
|
||||
const step = el.clientHeight || 800;
|
||||
for (let y = 0; y < el.scrollHeight; y += step) {
|
||||
el.scrollTop = y;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
await page.waitForLoadState("networkidle").catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import {
|
||||
SECTION_MOBILITY,
|
||||
SECTION_VITALS,
|
||||
} from "@/components/insights/derived/use-dashboard-derived";
|
||||
|
||||
/**
|
||||
* A POPULATED Insights overview fixture.
|
||||
*
|
||||
* The mobile-overflow guard exists to prove that a page carrying real content
|
||||
* does not scroll sideways. Against the shared e2e account — which owns no
|
||||
* measurements — `/insights` resolves to its empty state, and an empty page
|
||||
* trivially fits any viewport. Every section below therefore gets a body:
|
||||
* populated wellness scores, a full vitals grid, and the "signals of the day"
|
||||
* card whose heading carries the long plain-language provenance caption.
|
||||
*
|
||||
* The strings are deliberately long. Prose in a heading's trailing `action`
|
||||
* slot is exactly what widened the page in the first place, and a fixture with
|
||||
* short labels would not reproduce it.
|
||||
*/
|
||||
|
||||
const NOW = "2026-01-15T08:30:00.000Z";
|
||||
|
||||
function provenance(inputs: string[]) {
|
||||
return {
|
||||
inputs,
|
||||
source: "DAY" as const,
|
||||
windowDays: 30,
|
||||
computedAt: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
function coverage() {
|
||||
return {
|
||||
requiredInputs: 4,
|
||||
presentInputs: 4,
|
||||
historyDays: 90,
|
||||
missing: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
function confidence() {
|
||||
return { score: 88, band: "high" as const };
|
||||
}
|
||||
|
||||
function deviation(
|
||||
type: string,
|
||||
value: number,
|
||||
low: number,
|
||||
high: number,
|
||||
direction: "above" | "below" | "in",
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
value,
|
||||
center: (low + high) / 2,
|
||||
low,
|
||||
high,
|
||||
outside: direction !== "in",
|
||||
direction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The "signals of the day" payload in its `fired` state — the branch that
|
||||
* renders `SectionHeading action={<CoincidentProvenance/>}`, i.e. a full
|
||||
* sentence pinned into the heading row.
|
||||
*/
|
||||
const COINCIDENT_FIRED = {
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
fired: true,
|
||||
day: "2026-01-15",
|
||||
illnessExplained: false,
|
||||
vitals: [
|
||||
deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
|
||||
deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
|
||||
deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
|
||||
deviation("OXYGEN_SATURATION", 97, 96, 99, "in"),
|
||||
deviation("BODY_TEMPERATURE", 36.9, 36.2, 37.1, "in"),
|
||||
],
|
||||
contributing: [
|
||||
deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
|
||||
deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
|
||||
deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
|
||||
],
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance([
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"RESPIRATORY_RATE",
|
||||
]),
|
||||
reason: null,
|
||||
};
|
||||
|
||||
/** A deterministic sparkline series around `center`. */
|
||||
function series(center: number, spread: number): number[] {
|
||||
return Array.from({ length: 30 }, (_, i) =>
|
||||
Number((center + Math.sin(i / 2.5) * spread).toFixed(2)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A populated `WellnessScoreValue` entry — the exact wire shape the three
|
||||
* persisted score types share (`RECOVERY_SCORE` / `STRESS_SCORE` /
|
||||
* `STRAIN_SCORE`).
|
||||
*/
|
||||
function wellnessScore(
|
||||
metric: string,
|
||||
score: number,
|
||||
band: "green" | "yellow" | "red",
|
||||
) {
|
||||
return {
|
||||
metric,
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
score,
|
||||
band,
|
||||
trendDelta: 3.4,
|
||||
daysInWindow: 28,
|
||||
asOf: NOW,
|
||||
series: series(score, 6),
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance(["SLEEP_DURATION", "RESTING_HEART_RATE"]),
|
||||
reason: null,
|
||||
assessment: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A populated `VitalsBaselineValue` tile — exact wire shape. */
|
||||
function vitalsBaseline(type: string, center: number, spread: number) {
|
||||
return {
|
||||
metric: "VITALS_BASELINE",
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
type,
|
||||
center,
|
||||
low: Number((center - spread).toFixed(2)),
|
||||
high: Number((center + spread).toFixed(2)),
|
||||
spread,
|
||||
sampleDays: 30,
|
||||
k: 2,
|
||||
series: series(center, spread / 2),
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance([type]),
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The honest "not enough history" arm. Metrics whose value shapes this fixture
|
||||
* does not reproduce exactly are served as `insufficient` rather than as an
|
||||
* invented payload — that is a real state those tiles handle (they un-mount),
|
||||
* and a wrong-shaped `ok` payload would throw in the tile instead of testing
|
||||
* anything.
|
||||
*/
|
||||
function insufficient(metric: string) {
|
||||
return {
|
||||
metric,
|
||||
status: "insufficient" as const,
|
||||
value: null,
|
||||
coverage: {
|
||||
requiredInputs: 4,
|
||||
presentInputs: 1,
|
||||
historyDays: 3,
|
||||
missing: ["SLEEP_DURATION"],
|
||||
},
|
||||
confidence: null,
|
||||
provenance: provenance([]),
|
||||
reason: "not_enough_history",
|
||||
};
|
||||
}
|
||||
|
||||
/** Center + spread per vital, in each metric's own units. */
|
||||
const VITAL_BANDS: Record<string, [number, number]> = {
|
||||
RESTING_HEART_RATE: [57, 5],
|
||||
RESPIRATORY_RATE: [14.1, 1.8],
|
||||
OXYGEN_SATURATION: [97, 1.5],
|
||||
BODY_TEMPERATURE: [36.6, 0.45],
|
||||
BLOOD_GLUCOSE: [92, 13],
|
||||
WEIGHT: [79.4, 3],
|
||||
};
|
||||
|
||||
function buildBatchMetrics(): Record<string, unknown> {
|
||||
const metrics: Record<string, unknown> = {
|
||||
RECOVERY_SCORE: wellnessScore("RECOVERY_SCORE", 66, "yellow"),
|
||||
STRESS_SCORE: wellnessScore("STRESS_SCORE", 41, "green"),
|
||||
STRAIN_SCORE: wellnessScore("STRAIN_SCORE", 72, "yellow"),
|
||||
READINESS: insufficient("READINESS"),
|
||||
SLEEP_SCORE: insufficient("SLEEP_SCORE"),
|
||||
FITNESS_AGE: insufficient("FITNESS_AGE"),
|
||||
VASCULAR_AGE_DELTA: insufficient("VASCULAR_AGE_DELTA"),
|
||||
HRV_BALANCE: insufficient("HRV_BALANCE"),
|
||||
BMI: insufficient("BMI"),
|
||||
SIX_MINUTE_WALK_BAND: insufficient("SIX_MINUTE_WALK_BAND"),
|
||||
};
|
||||
for (const type of SECTION_VITALS) {
|
||||
if (type === "HEART_RATE_VARIABILITY") continue;
|
||||
const [center, spread] = VITAL_BANDS[type] ?? [50, 5];
|
||||
metrics[`VITALS_BASELINE:${type}`] = vitalsBaseline(type, center, spread);
|
||||
}
|
||||
for (const { metric } of SECTION_MOBILITY) {
|
||||
metrics[metric] = insufficient(metric);
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-mock a populated Insights overview onto `page`.
|
||||
*
|
||||
* Only the reads that decide whether a section renders AT ALL are stubbed;
|
||||
* everything else (the i18n bundle, every layout decision, the real component
|
||||
* tree) stays authentic, so the geometry under test is the shipped geometry.
|
||||
*/
|
||||
export async function mockPopulatedInsights(page: Page): Promise<void> {
|
||||
// Without a non-empty comprehensive payload the page short-circuits to its
|
||||
// "no data yet" empty state and no section mounts at all.
|
||||
// RegExp matchers throughout, not globs: Playwright's URL glob treats `?` as
|
||||
// a single-character wildcard, so `**/api/insights/derived?*` also matches
|
||||
// `/api/insights/derived/batch?…` and the two handlers shadow each other.
|
||||
await page.route(/\/api\/insights\/comprehensive(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
totalMeasurements: 4821,
|
||||
moodSummary: { count: 96 },
|
||||
medications: [{ id: "med-1" }, { id: "med-2" }, { id: "med-3" }],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The batched derived read behind the wellness strip + the vitals grid.
|
||||
await page.route(/\/api\/insights\/derived\/batch(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { metrics: buildBatchMetrics() },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The single-metric derived route. Only COINCIDENT_DEVIATION is stubbed —
|
||||
// it is the card whose heading carries the provenance sentence. Any other
|
||||
// metric falls through to the real handler.
|
||||
await page.route(/\/api\/insights\/derived\?/, async (route) => {
|
||||
const metric = new URL(route.request().url()).searchParams.get("metric");
|
||||
if (metric !== "COINCIDENT_DEVIATION") return route.fallback();
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: COINCIDENT_FIRED, error: null }),
|
||||
});
|
||||
});
|
||||
}
|
||||
+170
-12
@@ -455,6 +455,9 @@
|
||||
"scoreRingsTitle": "Score-Ringe",
|
||||
"scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue. Zum Umsortieren ziehen; der Health Score steht standardmäßig vorne.",
|
||||
"layoutReset": "Auf Defaults zurücksetzen",
|
||||
"layoutResetTitle": "Dashboard-Layout zurücksetzen?",
|
||||
"layoutResetBody": "Deine Kachel-Reihenfolge, die ausgeblendeten Kacheln und die gewählten Ringe kehren zu den Standardwerten zurück. Die Anordnung wird nirgends sonst gespeichert.",
|
||||
"layoutResetConfirm": "Layout zurücksetzen",
|
||||
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
|
||||
"layoutSaveError": "Layout konnte nicht gespeichert werden",
|
||||
"layoutConflictReloaded": "Layout wurde vom Server neu geladen. Bitte nimm deine Änderung erneut vor.",
|
||||
@@ -911,9 +914,6 @@
|
||||
"importFileLoaded": "Datei \"{name}\" geladen",
|
||||
"importInvalidJson": "Datei enthält kein gültiges JSON",
|
||||
"importNoArray": "Kein Array gefunden",
|
||||
"importResult": "{imported} Einnahmen importiert",
|
||||
"importDuplicatesSkipped": "{count} Duplikate übersprungen",
|
||||
"importInvalidSkipped": "{count} ungültige Einträge übersprungen",
|
||||
"importFailed": "Import fehlgeschlagen",
|
||||
"importInvalidFormat": "Ungültiges JSON-Format",
|
||||
"requestExample": "Request-Beispiel",
|
||||
@@ -1716,8 +1716,8 @@
|
||||
"addSuccess": "Bestand erfasst.",
|
||||
"addFailed": "Bestand konnte nicht erfasst werden.",
|
||||
"adjustButton": "Anpassen",
|
||||
"adjustTitle": "Bestand korrigieren",
|
||||
"adjustDescription": "Setze die verbleibenden Einheiten neu — für Korrekturen oder eine Entnahme. 0 markiert den Vorrat als aufgebraucht.",
|
||||
"adjustTitle": "Behälter korrigieren",
|
||||
"adjustDescription": "Verbleibende Einheiten setzen und die Daten dieses Behälters korrigieren. 0 Einheiten markiert ihn als aufgebraucht.",
|
||||
"adjustQuantityLabel": "Verbleibende Einheiten",
|
||||
"adjustHelper": "Kapazität: {total} Einheiten.",
|
||||
"adjustSubmit": "Speichern",
|
||||
@@ -1757,7 +1757,15 @@
|
||||
"packagingFailed": "Speichern fehlgeschlagen",
|
||||
"addManufacturerLabel": "Hersteller",
|
||||
"addDoseStrengthLabel": "Wirkstärke",
|
||||
"addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml."
|
||||
"addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml.",
|
||||
"openedOn": "Geöffnet {date}",
|
||||
"expiresOn": "Haltbar bis {date}",
|
||||
"summaryAllExpired": "Kein nutzbarer Bestand: {units} Einheiten als abgelaufen markiert",
|
||||
"editExpiryLabel": "Aufgedrucktes Haltbarkeitsdatum",
|
||||
"editExpiryHelper": "Das Datum auf der Packung. Leer lassen, wenn keines erfasst ist.",
|
||||
"editOpenedLabel": "Geöffnet am",
|
||||
"editOpenedHelper": "Leer lassen, wenn der Behälter noch verschlossen ist.",
|
||||
"editOpenedHelperClock": "Ein Pen oder eine Ampulle läuft 30 Tage nach dem Öffnen ab. Leer lassen, wenn noch verschlossen."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1909,6 +1917,32 @@
|
||||
"scaleAbsolute": "Absolut",
|
||||
"scalePercentSinceStart": "% seit Start",
|
||||
"scaleToggleLabel": "Diagrammskala"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} Einnahmen importiert.",
|
||||
"resultPartial": "{imported} importiert, {skipped} übersprungen.",
|
||||
"resultNothing": "Nichts importiert: alle {skipped} Einträge wurden übersprungen.",
|
||||
"resultEmpty": "Diese Datei enthielt keine Einnahmen.",
|
||||
"skipGroupOne": "{count} Eintrag übersprungen: {reason}",
|
||||
"skipGroupFew": "{count} Einträge übersprungen: {reason}",
|
||||
"skipGroupOther": "{count} Einträge übersprungen: {reason}",
|
||||
"reasonDuplicateInFile": "ein anderer Eintrag derselben Datei hat dasselbe Datum und dieselbe Uhrzeit",
|
||||
"reasonAlreadyRecorded": "für dieses Datum und diese Uhrzeit ist bereits eine Einnahme erfasst",
|
||||
"reasonUnknown": "Grund nicht erfasst",
|
||||
"reasonMedicationNotFound": "kein Medikament im Bestand passt zum Namen in der Datei",
|
||||
"reasonMedicationAmbiguous": "der Name in der Datei passt auf mehr als ein Medikament",
|
||||
"reasonMedicationIsMirrored": "das Medikament ist eine schreibgeschützte Kopie aus einer anderen App",
|
||||
"reasonStatusUnknown": "der Status in der Datei gehört nicht zum dokumentierten Format",
|
||||
"reasonMissingTimestamp": "die Zeile nennt keine Zeit",
|
||||
"reasonMissingOffset": "die Zeit trägt keinen UTC-Offset, der Zeitpunkt ist damit unbekannt",
|
||||
"reasonUnreadableTimestamp": "die Zeit entspricht nicht dem dokumentierten Format",
|
||||
"reasonImplausibleTimestamp": "die Zeit nennt keinen echten Zeitpunkt oder liegt in der Zukunft",
|
||||
"reasonMissingMedication": "die Zeile nennt kein Medikament",
|
||||
"reasonUnreadableDosage": "die Dosis ist keine Zahl",
|
||||
"reasonUnreadableRow": "die Zeile hat weniger Spalten als die Kopfzeile",
|
||||
"reasonStatusNoDoseInformation": "die Datei hält zu dieser Dosis nichts fest — niemand hat reagiert und nichts wurde eingetragen",
|
||||
"reasonStatusReminderEvent": "die Zeile betrifft eine verschobene Erinnerung und sagt nichts über die Dosis",
|
||||
"reasonStatusNotificationNotSent": "die Zeile hält fest, dass keine Benachrichtigung gesendet wurde — das betrifft die App, nicht die Dosis"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1992,6 +2026,14 @@
|
||||
"loadErrorTitle": "Diagramm konnte nicht geladen werden",
|
||||
"loadErrorReload": "Neu laden",
|
||||
"sparseDataCaption": "Mehr Messtage füllen den Trend.",
|
||||
"dataTable": {
|
||||
"toggle": "Datenpunkte ({count})",
|
||||
"columnDate": "Datum",
|
||||
"noValue": "Kein Wert",
|
||||
"captionDay": "Eine Zeile pro Tag für {metric} — genau die Punkte, die das Diagramm zeichnet.",
|
||||
"captionWeek": "Eine Zeile pro Woche für {metric}, der Mittelwert der Tage dieser Woche — genau die Punkte, die das Diagramm zeichnet.",
|
||||
"captionMonth": "Eine Zeile pro Monat für {metric}, der Mittelwert der Tage dieses Monats — genau die Punkte, die das Diagramm zeichnet."
|
||||
},
|
||||
"personalBaseline": "Dein Mittel",
|
||||
"deltaVsBaseline": "{delta} vs. dein Mittel",
|
||||
"deltaUnchanged": "entspricht deinem Mittel",
|
||||
@@ -2118,6 +2160,9 @@
|
||||
"editMode": {
|
||||
"done": "Fertig",
|
||||
"reset": "Zurücksetzen",
|
||||
"resetTitle": "Übersicht zurücksetzen?",
|
||||
"resetBody": "Abschnittsreihenfolge und ausgeblendete Abschnitte kehren zu den Standardwerten zurück. Deine Anordnung wird nirgends sonst gespeichert.",
|
||||
"resetConfirm": "Übersicht zurücksetzen",
|
||||
"title": "Übersicht anpassen",
|
||||
"description": "Zum Sortieren ziehen, zum Ein- oder Ausblenden auf das Auge tippen.",
|
||||
"dragHandle": "Zum Sortieren ziehen",
|
||||
@@ -3395,9 +3440,13 @@
|
||||
"metricActiveEnergy": "Aktive Energie",
|
||||
"metricSleepDuration": "Schlaf",
|
||||
"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."
|
||||
},
|
||||
@@ -3965,6 +4014,12 @@
|
||||
"overrideToggleLabel": "Eigene Werte",
|
||||
"resetAction": "Auf Default zurücksetzen",
|
||||
"resetAllAction": "Alle auf Default zurücksetzen",
|
||||
"resetAllTitle": "Alle eigenen Bereiche zurücksetzen?",
|
||||
"resetAllBody": "Jede Messgröße kehrt zu ihrem Standardbereich zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.",
|
||||
"resetAllConfirm": "Alle zurücksetzen",
|
||||
"resetTitle": "Diesen Bereich zurücksetzen?",
|
||||
"resetBody": "Die Messgröße kehrt zu ihrem Standardbereich zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.",
|
||||
"resetConfirm": "Bereich zurücksetzen",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Max",
|
||||
"unitSuffix": "({unit})",
|
||||
@@ -4122,6 +4177,7 @@
|
||||
"genderNone": "Keine Angabe",
|
||||
"genderMale": "Männlich",
|
||||
"genderFemale": "Weiblich",
|
||||
"genderOther": "Divers",
|
||||
"dateOfBirthLabel": "Geburtsdatum",
|
||||
"dateOfBirthHint": "Wird ausschließlich für altersbezogene Auswertungen genutzt.",
|
||||
"saveCta": "Speichern und weiter"
|
||||
@@ -4332,6 +4388,9 @@
|
||||
"cardTitle": "Quellen-Priorität pro Metrik",
|
||||
"help": "Liefern mehrere Quellen denselben Wert am selben Tag, zählt die oberste Quelle als kanonisch. Die übrigen bleiben für die Audit-Spur gespeichert.",
|
||||
"resetDefaults": "Auf Standard zurücksetzen",
|
||||
"resetDefaultsTitle": "Quellen-Reihenfolge zurücksetzen?",
|
||||
"resetDefaultsBody": "Jede von dir angeordnete Rangfolge kehrt zur Standardreihenfolge zurück, für alle Messgrößen und Gerätetypen auf einmal. Das wird sofort gespeichert und lässt sich nicht wiederherstellen.",
|
||||
"resetDefaultsConfirm": "Reihenfolge zurücksetzen",
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten",
|
||||
"saveSuccess": "Quellen-Priorität gespeichert.",
|
||||
@@ -4538,6 +4597,60 @@
|
||||
"reasonUnstableExternalId": "Externe ID ist über Exporte hinweg nicht stabil",
|
||||
"reasonDuplicate": "bereits importiert",
|
||||
"reasonUnknown": "übersprungen"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Einnahmeverlauf",
|
||||
"description": "Ein Einnahmeverlauf, wie ihn ein anderer Medikamenten-Tracker exportiert, direkt gelesen. Eine Datei deckt alle Medikamente darin ab.",
|
||||
"pasteLabel": "Export einfügen",
|
||||
"fileInputLabel": "Datei mit Einnahmeverlauf wählen",
|
||||
"uploadFile": "Datei wählen",
|
||||
"preview": "Vorschau",
|
||||
"import": "Importieren",
|
||||
"empty": "Erst den Export einfügen oder eine Datei wählen.",
|
||||
"readFailed": "Diese Datei konnte nicht gelesen werden.",
|
||||
"rateLimited": "Höchstens 5 Importe pro Stunde. Später erneut versuchen.",
|
||||
"tooLarge": "Diese Datei überschreitet das Limit von 16 MB.",
|
||||
"inProgress": "Ein Import des Einnahmeverlaufs läuft bereits. Auf sein Ende warten.",
|
||||
"workerUnavailable": "Der Hintergrunddienst ist nicht verfügbar, es wurde nichts importiert.",
|
||||
"failed": "Der Import ist fehlgeschlagen. Es wurde nichts geschrieben.",
|
||||
"columnsHeading": "Was mit jeder Spalte passiert",
|
||||
"jsonOnly": "nur JSON",
|
||||
"previewLine": "Zeilen gelesen: {rows} · würde importieren: {queued} · abgewiesen: {refused}",
|
||||
"submittedLine": "Zeilen gelesen: {rows} · zum Import eingereiht: {queued}",
|
||||
"unmatched": "Kein Medikament im Bestand passt zu: {names}",
|
||||
"ambiguous": "Passt auf mehr als ein Medikament: {names}",
|
||||
"mirrored": "Aus einer anderen App gespiegelt, daher keine Dosis ergänzbar: {names}",
|
||||
"unknownColumns": "Spalten ohne Regel im Importer, ungelesen: {names}",
|
||||
"codingsNotRead": "Zeilen mit einem nicht gelesenen Codings-Wert: {count}",
|
||||
"archivedRows": "Zeilen von einem in der Quell-App archivierten Medikament, trotzdem importiert: {count}",
|
||||
"verdict": {
|
||||
"honoured": "verwendet",
|
||||
"reported": "gemeldet",
|
||||
"ignored": "nicht gelesen"
|
||||
},
|
||||
"column": {
|
||||
"date": "Der Zeitpunkt der tatsächlichen Einnahme. Sein UTC-Offset wird genau so gelesen, wie er dasteht.",
|
||||
"scheduledDate": "Der Slot, zu dem die Dosis gehörte, getrennt vom Zeitpunkt der Einnahme. Leer heißt: die Dosis gehörte zu keinem Slot.",
|
||||
"medication": "Wird einem Medikament zugeordnet, das es im Bestand schon gibt. Aus der Datei wird nichts angelegt.",
|
||||
"nickname": "Wird als zweiter Name versucht, wenn der erste zu nichts passt.",
|
||||
"dosage": "Wird nur dann an der Dosis festgehalten, wenn sie von der geplanten Menge abweicht.",
|
||||
"scheduledDosage": "Wird mit der tatsächlichen Menge verglichen, um eine Abweichung zu erkennen. Nie eigenständig gespeichert.",
|
||||
"unit": "Wird mit der Menge geschrieben, geschrieben wie in der Datei. „count“ heißt Vielfache der konfigurierten Dosis.",
|
||||
"status": "„Taken“ wird eine Dosis, „Skipped“ ein bewusstes Auslassen. Jeder andere Wert hält keine Entscheidung fest und die Zeile wird abgewiesen.",
|
||||
"archived": "Wird gezählt und zurückgemeldet. Eine erfolgte Einnahme wird nicht dadurch ungültig, dass das Medikament später archiviert wurde.",
|
||||
"codings": "Die CSV dokumentiert für diese Spalte keine Kodierung, also bleibt sie unberührt statt geraten. Gefüllte Zellen werden gezählt.",
|
||||
"form": "Beschreibt das Medikament, nicht die Dosis.",
|
||||
"start": "Das Datum des Behandlungsbeginns, nicht der Zeitpunkt dieser Dosis.",
|
||||
"end": "Das Datum des Behandlungsendes, nicht der Zeitpunkt dieser Dosis."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "Diese Datei enthält keine Zeilen.",
|
||||
"missingColumns": "Dieser Datei fehlt eine Spalte, die der Importer braucht: Date, Medication oder Status.",
|
||||
"unreadableJson": "Diese Datei ist kein gültiges JSON.",
|
||||
"jsonNotAnArray": "Dieses JSON enthält keine Liste von Dosen.",
|
||||
"jsonNoIntakeTime": "Der JSON-Export hält fest, wann eine Dosis fällig war, nicht wann sie genommen wurde, und eine geplante Zeit wird nicht als Einnahmezeit geschrieben. Stattdessen die CSV exportieren, die beides trägt.",
|
||||
"tooManyRows": "Diese Datei enthält mehr Zeilen, als ein Import aufnehmen kann. Teilen und in Teilen importieren."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung."
|
||||
@@ -4661,7 +4774,10 @@
|
||||
"description": "Rufe Wetter für einen vergangenen Zeitraum ab und speichere es, damit ältere Tage in die Korrelationen einfließen. Der Start steht standardmäßig auf dem Gültigkeitsdatum deines Wohnorts; um weiter zurückzureichen, füge zuerst einen Ortszeitraum für diesen Bereich hinzu.",
|
||||
"run": "Nacherfassen"
|
||||
},
|
||||
"noResults": "Keine passenden Orte gefunden."
|
||||
"noResults": "Keine passenden Orte gefunden.",
|
||||
"fetchFailedOne": "Die letzte Wetteraktualisierung im Hintergrund ist fehlgeschlagen ({when}).",
|
||||
"fetchFailedFew": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}.",
|
||||
"fetchFailedOther": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4706,6 +4822,9 @@
|
||||
"upload": "Foto hochladen",
|
||||
"replace": "Foto ersetzen",
|
||||
"remove": "Entfernen",
|
||||
"removeTitle": "Profilbild entfernen?",
|
||||
"removeBody": "Das Bild wird von deinem Server gelöscht, an seine Stelle treten deine Initialen. Zum erneuten Hochladen brauchst du die Originaldatei.",
|
||||
"removeConfirm": "Bild entfernen",
|
||||
"hint": "JPEG, PNG oder WebP. Bis zu 2 MB und 2048×2048 Pixel.",
|
||||
"uploaded": "Profilfoto aktualisiert",
|
||||
"removed": "Profilfoto entfernt",
|
||||
@@ -4865,6 +4984,9 @@
|
||||
"statusExpired": "Verbindung abgelaufen",
|
||||
"connectButton": "Mit ChatGPT verbinden",
|
||||
"disconnectButton": "Trennen",
|
||||
"disconnectTitle": "ChatGPT trennen?",
|
||||
"disconnectBody": "Die gespeicherte Anmeldung wird zusammen mit deinem zwischengespeicherten Insight-Text verworfen. Für eine neue Verbindung musst du dich erneut über den Gerätecode-Ablauf anmelden.",
|
||||
"disconnectConfirm": "Trennen",
|
||||
"modelSlugLabel": "Modell-Slug",
|
||||
"modelSlugBody": "Codex nutzt das Modell, auf das dein ChatGPT-Abo routet. Die Umgebungsvariable CODEX_MODEL kann das instanzweit überschreiben.",
|
||||
"lastInsight": "Letzte Analyse: {when}"
|
||||
@@ -4895,6 +5017,9 @@
|
||||
"categoryContext": "Kontext",
|
||||
"forget": "Vergessen",
|
||||
"forgetAria": "Diese Angabe vergessen",
|
||||
"forgetTitle": "Diese Notiz vergessen?",
|
||||
"forgetBody": "Der Coach nutzt sie ab der nächsten Nachricht nicht mehr. Eine vergessene Notiz lässt sich nicht wiederherstellen.",
|
||||
"forgetConfirm": "Vergessen",
|
||||
"forgetAll": "Alles vergessen",
|
||||
"forgetAllConfirmTitle": "Alles vergessen, woran sich der Coach erinnert?",
|
||||
"forgetAllConfirmBody": "Dadurch werden alle gespeicherten Angaben entfernt. Der Coach beginnt beim nächsten Gespräch von vorn. Das lässt sich nicht rückgängig machen.",
|
||||
@@ -4938,6 +5063,9 @@
|
||||
"placeholder": "Z. B. Schichtarbeit, vegetarische Ernährung, Knieverletzung, Training für einen Halbmarathon…",
|
||||
"charCount": "{used} / {max} Zeichen",
|
||||
"clear": "Leeren",
|
||||
"clearTitle": "Über-mich-Notiz leeren?",
|
||||
"clearBody": "Entfernt nur den Freitext. Allergien und Unverträglichkeiten bleiben unverändert.",
|
||||
"clearConfirm": "Notiz leeren",
|
||||
"save": "Speichern",
|
||||
"savedToast": "Über-mich-Notiz gespeichert.",
|
||||
"clearedToast": "Über-mich-Notiz gelöscht.",
|
||||
@@ -5155,7 +5283,7 @@
|
||||
"integrationPill": {
|
||||
"connected": "Verbunden",
|
||||
"errorReconnect": "Fehler — neu verbinden",
|
||||
"warningServerError": "Verbunden, Sync-Problem",
|
||||
"warningServerError": "Sync fehlgeschlagen",
|
||||
"parkedReconnect": "Pausiert — manuell wieder verbinden",
|
||||
"notConnected": "Nicht verbunden",
|
||||
"justNow": "gerade eben",
|
||||
@@ -5217,10 +5345,10 @@
|
||||
"apiEndpointAuth": "Authentifizierung",
|
||||
"apiEndpointExample": "Body-Beispiel",
|
||||
"dangerZone": "Alle Daten löschen",
|
||||
"dangerZoneDescription": "Löscht alle deine Gesundheitsdaten und Integrationen. Dein Nutzerkonto bleibt erhalten.",
|
||||
"dangerZoneDescription": "Löscht deine gesamte Gesundheitsakte und jede Integrationsverbindung. Dein Konto und deine Anmeldedaten bleiben erhalten.",
|
||||
"dangerZoneConfirm": "Alle Daten löschen?",
|
||||
"dangerZoneConfirmDescription": "Dieser Vorgang löscht alle deine Gesundheitsdaten und Integrationen unwiderruflich. Dein Nutzerkonto bleibt erhalten.",
|
||||
"dangerZoneSuccess": "Alle persönlichen Daten wurden gelöscht",
|
||||
"dangerZoneConfirmDescription": "Dieser Vorgang löscht unwiderruflich alles in deiner Akte: Messwerte, Medikamente, Stimmungseinträge, Laborwerte, Zykluseinträge, Dokumente, KI-Gespräche und jede Integrationsverbindung, dazu alles daraus Abgeleitete und alles, was du früher gelöscht hast. Freigabe-Links, die du an eine Praxis gegeben hast, funktionieren sofort nicht mehr. Dein Konto bleibt bestehen, ebenso deine Anmeldedaten und Anzeigeeinstellungen wie Sprache, Zeitzone und Einheiten. Das lässt sich nicht rückgängig machen.",
|
||||
"dangerZoneSuccess": "Deine Gesundheitsakte wurde gelöscht. Dein Konto und deine Anmeldung bleiben unverändert.",
|
||||
"dangerZoneDeleteFailed": "Löschen fehlgeschlagen",
|
||||
"finalDelete": "Endgültig löschen",
|
||||
"deleteAccountCardTitle": "Konto vollständig löschen",
|
||||
@@ -5763,6 +5891,9 @@
|
||||
"baseUrlLabel": "Basis-URL",
|
||||
"save": "Speichern",
|
||||
"remove": "Schlüssel entfernen",
|
||||
"removeTitle": "Server-Schlüssel entfernen?",
|
||||
"removeBody": "Konten ohne eigenen Schlüssel verlieren die KI-Funktionen sofort. Der Schlüssel wird nach dem Speichern nirgends mehr angezeigt — zum Wiederherstellen brauchst du das Original.",
|
||||
"removeConfirm": "Schlüssel entfernen",
|
||||
"saved": "KI-Server-Schlüssel gespeichert",
|
||||
"error": "Speichern des KI-Server-Schlüssels fehlgeschlagen"
|
||||
},
|
||||
@@ -6285,10 +6416,23 @@
|
||||
"statusExpired": "Abgelaufen",
|
||||
"connectButton": "ChatGPT-Konto verbinden",
|
||||
"disconnectButton": "Trennen",
|
||||
"disconnectTitle": "Gemeinsames KI-Konto trennen?",
|
||||
"disconnectBody": "Alle Nutzer auf diesem Server, die die gemeinsame Verbindung nutzen, verlieren die KI-Funktionen, bis wieder ein Konto verknüpft wird. Die gespeicherte Anmeldung lässt sich nicht wiederherstellen.",
|
||||
"disconnectConfirm": "Trennen",
|
||||
"connectedSince": "Verbunden seit {when}",
|
||||
"connected": "Gemeinsame KI-Verbindung verknüpft",
|
||||
"disconnected": "Gemeinsame KI-Verbindung entfernt",
|
||||
"error": "Etwas ist schiefgelaufen — bitte erneut versuchen"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Hintergrundjobs",
|
||||
"description": "Queues, deren Jobs nach allen Wiederholungen aufgegeben haben.",
|
||||
"none": "In den letzten {hours} Stunden ist kein Job fehlgeschlagen.",
|
||||
"unavailable": "Keine Job-Queue lesbar — dieses Deployment läuft ohne Hintergrund-Worker.",
|
||||
"countOne": "{count} Fehlschlag",
|
||||
"countFew": "{count} Fehlschläge",
|
||||
"countOther": "{count} Fehlschläge",
|
||||
"lastFailed": "Letzter Fehlschlag {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -6751,6 +6895,10 @@
|
||||
"diastolicMax": "Diastolisch max",
|
||||
"boundsHint": "Erlaubter Bereich: {min}–{max} {unit}",
|
||||
"resetToDefault": "Auf Standard zurücksetzen",
|
||||
"resetTitle": "Dieses Ziel zurücksetzen?",
|
||||
"resetBody": "Der Bereich kehrt zum Standard zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.",
|
||||
"resetBodyBp": "Sowohl der systolische als auch der diastolische Bereich kehren zum Standard zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.",
|
||||
"resetConfirm": "Ziel zurücksetzen",
|
||||
"saveSuccess": "Zielbereich aktualisiert",
|
||||
"saveError": "Zielbereich konnte nicht gespeichert werden",
|
||||
"resetSuccess": "Zielbereich auf Standard zurückgesetzt",
|
||||
@@ -6880,7 +7028,8 @@
|
||||
"title": "Vorrat niedrig: {medName}",
|
||||
"bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig).",
|
||||
"bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht — bestelle bis zum {reorderBy} nach.",
|
||||
"bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}."
|
||||
"bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}.",
|
||||
"bodyExpired": "{medName}: kein nutzbarer Bestand mehr. {units} Einheiten sind als abgelaufen markiert."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Stimmung erfassen",
|
||||
@@ -6983,6 +7132,9 @@
|
||||
"icon": "Symbol",
|
||||
"limitReached": "Höchstzahl an eigenen Symptomen erreicht.",
|
||||
"remove": "{label} entfernen",
|
||||
"removeTitle": "Dieses Symptom entfernen?",
|
||||
"removeBody": "„{label}“ verschwindet aus der Symptomliste. Bereits damit markierte Tage behalten es, du kannst es aber nicht mehr auswählen.",
|
||||
"removeConfirm": "Entfernen",
|
||||
"fallbackLabel": "Eigenes Symptom"
|
||||
}
|
||||
},
|
||||
@@ -7215,6 +7367,9 @@
|
||||
"severity": "Schweregrad",
|
||||
"severityLevel": "Schweregrad {level} von 4",
|
||||
"delete": "Löschen",
|
||||
"deleteTitle": "Diesen Tag löschen?",
|
||||
"deleteBody": "Alles, was für diesen Tag erfasst ist, verschwindet: Blutung, Temperatur, Symptome und deine Notiz. Es lässt sich nicht wiederherstellen.",
|
||||
"deleteConfirm": "Tag löschen",
|
||||
"temperatureSection": "Temperatur & Eisprung",
|
||||
"intimacySection": "Intimität & Verhütung",
|
||||
"testsSection": "Tests",
|
||||
@@ -8008,6 +8163,9 @@
|
||||
"markMet": "Ziel erreicht",
|
||||
"abandon": "Plan beenden",
|
||||
"remove": "Entfernen",
|
||||
"removeTitle": "Diesen Plan entfernen?",
|
||||
"removeBody": "Der Plan und sein Verlauf verschwinden aus deiner Historie. Er lässt sich nicht wiederherstellen.",
|
||||
"removeConfirm": "Plan entfernen",
|
||||
"empty": "Noch keine Pläne. Wenn du dich im Gespräch zu einer konkreten Wenn-dann-Absicht bekennst, schlägt der Coach sie hier zur Bestätigung vor.",
|
||||
"groupProposed": "Vorschläge",
|
||||
"groupActive": "Aktiv",
|
||||
|
||||
+170
-12
@@ -455,6 +455,9 @@
|
||||
"scoreRingsTitle": "Score rings",
|
||||
"scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence. Drag to reorder; the health score leads by default.",
|
||||
"layoutReset": "Reset to defaults",
|
||||
"layoutResetTitle": "Reset the dashboard layout?",
|
||||
"layoutResetBody": "Your tile order, the tiles you hid and the rings you chose all go back to defaults. The arrangement is not stored anywhere else.",
|
||||
"layoutResetConfirm": "Reset layout",
|
||||
"layoutSaveSuccess": "Dashboard layout saved",
|
||||
"layoutSaveError": "Could not save dashboard layout",
|
||||
"layoutConflictReloaded": "Layout reloaded from the server. Please make your change again.",
|
||||
@@ -911,9 +914,6 @@
|
||||
"importFileLoaded": "File \"{name}\" loaded",
|
||||
"importInvalidJson": "File does not contain valid JSON",
|
||||
"importNoArray": "No array found",
|
||||
"importResult": "{imported} intakes imported",
|
||||
"importDuplicatesSkipped": "{count} duplicates skipped",
|
||||
"importInvalidSkipped": "{count} invalid entries skipped",
|
||||
"importFailed": "Import failed",
|
||||
"importInvalidFormat": "Invalid JSON format",
|
||||
"requestExample": "Request example",
|
||||
@@ -1716,8 +1716,8 @@
|
||||
"addSuccess": "Supply registered.",
|
||||
"addFailed": "The supply could not be registered.",
|
||||
"adjustButton": "Adjust",
|
||||
"adjustTitle": "Correct stock",
|
||||
"adjustDescription": "Set the remaining units — for corrections or a withdrawal. 0 marks the item as used up.",
|
||||
"adjustTitle": "Correct container",
|
||||
"adjustDescription": "Set the remaining units and correct the dates printed on or recorded for this container. 0 units marks it as used up.",
|
||||
"adjustQuantityLabel": "Remaining units",
|
||||
"adjustHelper": "Capacity: {total} units.",
|
||||
"adjustSubmit": "Save",
|
||||
@@ -1757,7 +1757,15 @@
|
||||
"packagingFailed": "Saving failed",
|
||||
"addManufacturerLabel": "Manufacturer",
|
||||
"addDoseStrengthLabel": "Strength",
|
||||
"addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml."
|
||||
"addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml.",
|
||||
"openedOn": "Opened {date}",
|
||||
"expiresOn": "Expires {date}",
|
||||
"summaryAllExpired": "No usable supply: {units} units marked expired",
|
||||
"editExpiryLabel": "Printed expiry date",
|
||||
"editExpiryHelper": "The date printed on the carton. Leave empty if none is recorded.",
|
||||
"editOpenedLabel": "Opened on",
|
||||
"editOpenedHelper": "Leave empty if the container is still sealed.",
|
||||
"editOpenedHelperClock": "A pen or ampoule expires 30 days after it is opened. Leave empty if it is still sealed."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1909,6 +1917,32 @@
|
||||
"scaleAbsolute": "Absolute",
|
||||
"scalePercentSinceStart": "% since start",
|
||||
"scaleToggleLabel": "Chart scale"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} intakes imported.",
|
||||
"resultPartial": "{imported} imported, {skipped} skipped.",
|
||||
"resultNothing": "Nothing was imported: all {skipped} entries were skipped.",
|
||||
"resultEmpty": "That file carried no intakes.",
|
||||
"skipGroupOne": "{count} entry skipped: {reason}",
|
||||
"skipGroupFew": "{count} entries skipped: {reason}",
|
||||
"skipGroupOther": "{count} entries skipped: {reason}",
|
||||
"reasonDuplicateInFile": "another entry in the same file has the same date and time",
|
||||
"reasonAlreadyRecorded": "an intake is already recorded for that date and time",
|
||||
"reasonUnknown": "reason not recorded",
|
||||
"reasonMedicationNotFound": "no medication on record matches the name in the file",
|
||||
"reasonMedicationAmbiguous": "the name in the file matches more than one medication on record",
|
||||
"reasonMedicationIsMirrored": "the medication is a read-only copy of another app's list",
|
||||
"reasonStatusUnknown": "the status in the file is not one the format defines",
|
||||
"reasonMissingTimestamp": "the row states no time",
|
||||
"reasonMissingOffset": "the time carries no UTC offset, so the instant is unknown",
|
||||
"reasonUnreadableTimestamp": "the time is not in the format the export documents",
|
||||
"reasonImplausibleTimestamp": "the time names no real instant, or lies in the future",
|
||||
"reasonMissingMedication": "the row names no medication",
|
||||
"reasonUnreadableDosage": "the dosage is not a number",
|
||||
"reasonUnreadableRow": "the row has fewer columns than the header",
|
||||
"reasonStatusNoDoseInformation": "the file records nothing about that dose — nobody answered and nothing was logged",
|
||||
"reasonStatusReminderEvent": "the row is about a snoozed reminder, which says nothing about the dose",
|
||||
"reasonStatusNotificationNotSent": "the row records that no notification was sent, which is about the app rather than the dose"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1992,6 +2026,14 @@
|
||||
"loadErrorTitle": "Chart could not be loaded",
|
||||
"loadErrorReload": "Reload",
|
||||
"sparseDataCaption": "More days will fill out the trend.",
|
||||
"dataTable": {
|
||||
"toggle": "Data points ({count})",
|
||||
"columnDate": "Date",
|
||||
"noValue": "No value",
|
||||
"captionDay": "One row per day of {metric}, the same points the chart draws.",
|
||||
"captionWeek": "One row per week of {metric}, the average of that week's days — the same points the chart draws.",
|
||||
"captionMonth": "One row per month of {metric}, the average of that month's days — the same points the chart draws."
|
||||
},
|
||||
"personalBaseline": "Your normal",
|
||||
"deltaVsBaseline": "{delta} vs. your normal",
|
||||
"deltaUnchanged": "matches your normal",
|
||||
@@ -2120,6 +2162,9 @@
|
||||
"editMode": {
|
||||
"done": "Done",
|
||||
"reset": "Reset",
|
||||
"resetTitle": "Reset the overview?",
|
||||
"resetBody": "Section order and the sections you hid go back to defaults. Your arrangement is not stored anywhere else.",
|
||||
"resetConfirm": "Reset overview",
|
||||
"title": "Customize overview",
|
||||
"description": "Drag to reorder, tap the eye to show or hide.",
|
||||
"dragHandle": "Drag to reorder",
|
||||
@@ -3395,9 +3440,13 @@
|
||||
"metricActiveEnergy": "Active energy",
|
||||
"metricSleepDuration": "Sleep",
|
||||
"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."
|
||||
},
|
||||
@@ -3965,6 +4014,12 @@
|
||||
"overrideToggleLabel": "Custom range",
|
||||
"resetAction": "Reset to default",
|
||||
"resetAllAction": "Reset all to default",
|
||||
"resetAllTitle": "Reset every custom range?",
|
||||
"resetAllBody": "Every metric goes back to its default range. The values you set are not stored anywhere else and cannot be brought back.",
|
||||
"resetAllConfirm": "Reset all",
|
||||
"resetTitle": "Reset this range?",
|
||||
"resetBody": "The metric goes back to its default range. The values you set are not stored anywhere else and cannot be brought back.",
|
||||
"resetConfirm": "Reset range",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Max",
|
||||
"unitSuffix": "({unit})",
|
||||
@@ -4122,6 +4177,7 @@
|
||||
"genderNone": "Prefer not to say",
|
||||
"genderMale": "Male",
|
||||
"genderFemale": "Female",
|
||||
"genderOther": "Other",
|
||||
"dateOfBirthLabel": "Date of birth",
|
||||
"dateOfBirthHint": "Used only for age-aware analytics.",
|
||||
"saveCta": "Save and continue"
|
||||
@@ -4332,6 +4388,9 @@
|
||||
"cardTitle": "Source priority by metric",
|
||||
"help": "When several sources deliver the same value on the same day, the top source counts as canonical. The others stay stored for the audit trail.",
|
||||
"resetDefaults": "Reset to defaults",
|
||||
"resetDefaultsTitle": "Reset the source order?",
|
||||
"resetDefaultsBody": "Every ladder you arranged goes back to the default order, for all metrics and device types at once. This is saved immediately and cannot be brought back.",
|
||||
"resetDefaultsConfirm": "Reset order",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"saveSuccess": "Source priority saved.",
|
||||
@@ -4538,6 +4597,60 @@
|
||||
"reasonUnstableExternalId": "external ID is not stable across exports",
|
||||
"reasonDuplicate": "already imported",
|
||||
"reasonUnknown": "skipped"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Medication dose history",
|
||||
"description": "A dose history exported from another medication tracker, read as it comes. One file covers every medication in it.",
|
||||
"pasteLabel": "Paste the export",
|
||||
"fileInputLabel": "Choose a dose-history export file",
|
||||
"uploadFile": "Choose file",
|
||||
"preview": "Preview",
|
||||
"import": "Import",
|
||||
"empty": "Paste the export or choose a file first.",
|
||||
"readFailed": "That file could not be read.",
|
||||
"rateLimited": "Maximum 5 imports per hour. Try again later.",
|
||||
"tooLarge": "That file is over the 16 MB limit.",
|
||||
"inProgress": "A dose-history import is already running. Wait for it to finish.",
|
||||
"workerUnavailable": "The background worker is not available, so nothing was imported.",
|
||||
"failed": "The import failed. Nothing was written.",
|
||||
"columnsHeading": "What each column does",
|
||||
"jsonOnly": "JSON only",
|
||||
"previewLine": "Rows read: {rows} · would import: {queued} · refused: {refused}",
|
||||
"submittedLine": "Rows read: {rows} · queued for import: {queued}",
|
||||
"unmatched": "No medication on record matches: {names}",
|
||||
"ambiguous": "Matches more than one medication: {names}",
|
||||
"mirrored": "Mirrored from another app, so no dose can be added: {names}",
|
||||
"unknownColumns": "Columns the importer has no rule for, left unread: {names}",
|
||||
"codingsNotRead": "Rows carrying a Codings value that was not read: {count}",
|
||||
"archivedRows": "Rows from a medication archived in the source app, imported anyway: {count}",
|
||||
"verdict": {
|
||||
"honoured": "used",
|
||||
"reported": "reported",
|
||||
"ignored": "not read"
|
||||
},
|
||||
"column": {
|
||||
"date": "The time the dose was actually taken. Its UTC offset is read exactly as written.",
|
||||
"scheduledDate": "The slot the dose belonged to, kept apart from the time it was taken. Empty means the dose belonged to no slot.",
|
||||
"medication": "Matched against a medication already on your record. Nothing is created from the file.",
|
||||
"nickname": "Tried as a second name when the first one matches nothing.",
|
||||
"dosage": "Recorded on the dose only where it differs from the scheduled amount.",
|
||||
"scheduledDosage": "Compared against the actual amount to decide whether that dose deviated. Never stored on its own.",
|
||||
"unit": "Written with the amount, spelled as the file spells it. \"count\" means multiples of the configured dose.",
|
||||
"status": "\"Taken\" becomes a dose and \"Skipped\" a deliberate skip. Any other value records no decision and the row is refused.",
|
||||
"archived": "Counted and reported back. A dose that happened is not undone by archiving the medication afterwards.",
|
||||
"codings": "The CSV gives this column no documented encoding, so it is left alone rather than guessed at. Populated cells are counted.",
|
||||
"form": "Describes the medication, not the dose.",
|
||||
"start": "The date you started the medication, not the time of this dose.",
|
||||
"end": "The date you stopped the medication, not the time of this dose."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "That file holds no rows.",
|
||||
"missingColumns": "That file is missing a column the importer needs: Date, Medication or Status.",
|
||||
"unreadableJson": "That file is not valid JSON.",
|
||||
"jsonNotAnArray": "That JSON holds no list of doses.",
|
||||
"jsonNoIntakeTime": "The JSON export records when a dose was due but not when it was taken, and a scheduled time will not be written as if it were the take. Export the CSV instead, which carries both.",
|
||||
"tooManyRows": "That file holds more rows than one import can take. Split it and import the parts."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Export your data or import from a backup."
|
||||
@@ -4661,7 +4774,10 @@
|
||||
"description": "Fetch and store weather for a past date range so older days join the correlations. The start defaults to your home's effective date; to reach further back, add a location period for that range first.",
|
||||
"run": "Backfill"
|
||||
},
|
||||
"noResults": "No matching places found."
|
||||
"noResults": "No matching places found.",
|
||||
"fetchFailedOne": "The last background weather update failed ({when}).",
|
||||
"fetchFailedFew": "The last {count} background weather updates failed, most recently {when}.",
|
||||
"fetchFailedOther": "The last {count} background weather updates failed, most recently {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -4706,6 +4822,9 @@
|
||||
"upload": "Upload photo",
|
||||
"replace": "Replace photo",
|
||||
"remove": "Remove",
|
||||
"removeTitle": "Remove the profile photo?",
|
||||
"removeBody": "The image is deleted from your server and your initials take its place. You would need the original file to upload it again.",
|
||||
"removeConfirm": "Remove photo",
|
||||
"hint": "JPEG, PNG, or WebP. Up to 2 MB and 2048×2048 pixels.",
|
||||
"uploaded": "Profile photo updated",
|
||||
"removed": "Profile photo removed",
|
||||
@@ -4865,6 +4984,9 @@
|
||||
"statusExpired": "Connection expired",
|
||||
"connectButton": "Connect with ChatGPT",
|
||||
"disconnectButton": "Disconnect",
|
||||
"disconnectTitle": "Disconnect ChatGPT?",
|
||||
"disconnectBody": "The stored sign-in is dropped along with your cached insight text. Reconnecting means signing in through the device-code flow again.",
|
||||
"disconnectConfirm": "Disconnect",
|
||||
"modelSlugLabel": "Model slug",
|
||||
"modelSlugBody": "Codex uses the model your ChatGPT subscription routes to. The CODEX_MODEL environment variable lets ops override this on the instance.",
|
||||
"lastInsight": "Last insight: {when}"
|
||||
@@ -4895,6 +5017,9 @@
|
||||
"categoryContext": "Context",
|
||||
"forget": "Forget",
|
||||
"forgetAria": "Forget this fact",
|
||||
"forgetTitle": "Forget this fact?",
|
||||
"forgetBody": "The Coach stops using it from the next message on. There is no way to bring a forgotten fact back.",
|
||||
"forgetConfirm": "Forget",
|
||||
"forgetAll": "Forget everything",
|
||||
"forgetAllConfirmTitle": "Forget everything the Coach remembers?",
|
||||
"forgetAllConfirmBody": "This removes every stored fact. The Coach will start fresh the next time you chat. This cannot be undone.",
|
||||
@@ -4938,6 +5063,9 @@
|
||||
"placeholder": "E.g. shift work, vegetarian diet, knee injury, training for a half marathon…",
|
||||
"charCount": "{used} / {max} characters",
|
||||
"clear": "Clear",
|
||||
"clearTitle": "Clear the about-me note?",
|
||||
"clearBody": "This removes the free-text note only. Your allergies and intolerances stay exactly as they are.",
|
||||
"clearConfirm": "Clear note",
|
||||
"save": "Save",
|
||||
"savedToast": "About-me note saved.",
|
||||
"clearedToast": "About-me note cleared.",
|
||||
@@ -5155,7 +5283,7 @@
|
||||
"integrationPill": {
|
||||
"connected": "Connected",
|
||||
"errorReconnect": "Error — reconnect",
|
||||
"warningServerError": "Connected, sync issue",
|
||||
"warningServerError": "Sync failing",
|
||||
"parkedReconnect": "Paused — reconnect manually",
|
||||
"notConnected": "Not connected",
|
||||
"justNow": "just now",
|
||||
@@ -5217,10 +5345,10 @@
|
||||
"apiEndpointAuth": "Authentication",
|
||||
"apiEndpointExample": "Body example",
|
||||
"dangerZone": "Delete All Data",
|
||||
"dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.",
|
||||
"dangerZoneDescription": "Erases your whole health record and every integration connection. Your account and the credentials you sign in with are kept.",
|
||||
"dangerZoneConfirm": "Delete all data?",
|
||||
"dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.",
|
||||
"dangerZoneSuccess": "All personal data has been deleted",
|
||||
"dangerZoneConfirmDescription": "This permanently deletes everything in your record: measurements, medications, mood entries, lab results, cycle entries, documents, AI conversations and every integration connection, together with everything derived from them and anything you deleted earlier. Share links you gave to a doctor stop working immediately. Your account stays, along with the credentials you sign in with and display settings such as language, timezone and units. This cannot be undone.",
|
||||
"dangerZoneSuccess": "Your health record has been deleted. Your account and sign-in are unchanged.",
|
||||
"dangerZoneDeleteFailed": "Deletion failed",
|
||||
"finalDelete": "Delete permanently",
|
||||
"deleteAccountCardTitle": "Delete account entirely",
|
||||
@@ -5763,6 +5891,9 @@
|
||||
"baseUrlLabel": "Base URL",
|
||||
"save": "Save",
|
||||
"remove": "Remove key",
|
||||
"removeTitle": "Remove the server key?",
|
||||
"removeBody": "Accounts without their own key lose AI features straight away. The key is not shown anywhere after it is stored, so you will need the original to put it back.",
|
||||
"removeConfirm": "Remove key",
|
||||
"saved": "AI server key settings saved",
|
||||
"error": "Saving the AI server key failed"
|
||||
},
|
||||
@@ -6285,10 +6416,23 @@
|
||||
"statusExpired": "Expired",
|
||||
"connectButton": "Connect a ChatGPT account",
|
||||
"disconnectButton": "Disconnect",
|
||||
"disconnectTitle": "Disconnect the shared AI account?",
|
||||
"disconnectBody": "Every user on this server who relies on the shared connection loses AI features until someone links an account again. The stored sign-in cannot be recovered.",
|
||||
"disconnectConfirm": "Disconnect",
|
||||
"connectedSince": "Connected since {when}",
|
||||
"connected": "Shared AI connection linked",
|
||||
"disconnected": "Shared AI connection removed",
|
||||
"error": "Something went wrong — please try again"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Background jobs",
|
||||
"description": "Queues whose jobs gave up after exhausting their retries.",
|
||||
"none": "No job has failed in the last {hours} hours.",
|
||||
"unavailable": "No job queue to read — this deployment runs without the background worker.",
|
||||
"countOne": "{count} failure",
|
||||
"countFew": "{count} failures",
|
||||
"countOther": "{count} failures",
|
||||
"lastFailed": "Last failure {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -6751,6 +6895,10 @@
|
||||
"diastolicMax": "Diastolic max",
|
||||
"boundsHint": "Allowed range: {min}–{max} {unit}",
|
||||
"resetToDefault": "Reset to default",
|
||||
"resetTitle": "Reset this target?",
|
||||
"resetBody": "The range goes back to the default. The values you set are not stored anywhere else and cannot be brought back.",
|
||||
"resetBodyBp": "Both the systolic and the diastolic range go back to the default. The values you set are not stored anywhere else and cannot be brought back.",
|
||||
"resetConfirm": "Reset target",
|
||||
"saveSuccess": "Target range updated",
|
||||
"saveError": "Could not save the target range",
|
||||
"resetSuccess": "Target range reset to default",
|
||||
@@ -6880,7 +7028,8 @@
|
||||
"title": "Low supply: {medName}",
|
||||
"bodyToday": "{medName}: {units} units left — less than one day of supply.",
|
||||
"bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn} — reorder by {reorderBy}.",
|
||||
"bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}."
|
||||
"bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}.",
|
||||
"bodyExpired": "{medName}: no usable supply left. {units} units are marked expired."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Log your mood",
|
||||
@@ -6983,6 +7132,9 @@
|
||||
"icon": "Icon",
|
||||
"limitReached": "Custom symptom limit reached.",
|
||||
"remove": "Remove {label}",
|
||||
"removeTitle": "Remove this symptom?",
|
||||
"removeBody": "“{label}” disappears from the symptom list. Days you already tagged with it keep it, but you cannot pick it again.",
|
||||
"removeConfirm": "Remove",
|
||||
"fallbackLabel": "Custom symptom"
|
||||
}
|
||||
},
|
||||
@@ -7209,6 +7361,9 @@
|
||||
"severity": "Severity",
|
||||
"severityLevel": "Severity {level} of 4",
|
||||
"delete": "Delete",
|
||||
"deleteTitle": "Delete this day?",
|
||||
"deleteBody": "Everything logged for this day goes: flow, temperature, symptoms and your note. There is no way to bring it back.",
|
||||
"deleteConfirm": "Delete day",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Anything you want to remember about today…",
|
||||
"save": "Save",
|
||||
@@ -8008,6 +8163,9 @@
|
||||
"markMet": "Goal met",
|
||||
"abandon": "End plan",
|
||||
"remove": "Remove",
|
||||
"removeTitle": "Remove this plan?",
|
||||
"removeBody": "The plan and how it went disappear from your history. There is no way to bring it back.",
|
||||
"removeConfirm": "Remove plan",
|
||||
"empty": "No plans yet. When you commit to a concrete if-then intention in a conversation, the Coach proposes it here for confirmation.",
|
||||
"groupProposed": "Suggestions",
|
||||
"groupActive": "Active",
|
||||
|
||||
+170
-12
@@ -455,6 +455,9 @@
|
||||
"scoreRingsTitle": "Anillos de puntuación",
|
||||
"scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento. Arrastra para reordenar; la puntuación de salud va primero de forma predeterminada.",
|
||||
"layoutReset": "Restablecer valores por defecto",
|
||||
"layoutResetTitle": "¿Restablecer el diseño del panel?",
|
||||
"layoutResetBody": "El orden de tus tarjetas, las que ocultaste y los anillos que elegiste vuelven a los valores predeterminados. La disposición no se guarda en ningún otro sitio.",
|
||||
"layoutResetConfirm": "Restablecer diseño",
|
||||
"layoutSaveSuccess": "Diseño del panel guardado",
|
||||
"layoutSaveError": "No se ha podido guardar el diseño",
|
||||
"layoutConflictReloaded": "Se ha recargado el diseño desde el servidor. Vuelve a aplicar tu cambio.",
|
||||
@@ -911,9 +914,6 @@
|
||||
"importFileLoaded": "Archivo \"{name}\" cargado",
|
||||
"importInvalidJson": "El archivo no contiene un JSON válido",
|
||||
"importNoArray": "No se encontró ningún array",
|
||||
"importResult": "{imported} tomas importadas",
|
||||
"importDuplicatesSkipped": "{count} duplicados omitidos",
|
||||
"importInvalidSkipped": "{count} entradas no válidas omitidas",
|
||||
"importFailed": "Falló la importación",
|
||||
"importInvalidFormat": "Formato JSON no válido",
|
||||
"requestExample": "Ejemplo de solicitud",
|
||||
@@ -1716,8 +1716,8 @@
|
||||
"addSuccess": "Existencias registradas.",
|
||||
"addFailed": "No se pudieron registrar las existencias.",
|
||||
"adjustButton": "Ajustar",
|
||||
"adjustTitle": "Corregir existencias",
|
||||
"adjustDescription": "Establece las unidades restantes — para correcciones o una retirada. 0 marca el envase como agotado.",
|
||||
"adjustTitle": "Corregir envase",
|
||||
"adjustDescription": "Ajusta las unidades restantes y corrige las fechas de este envase. 0 unidades lo marca como agotado.",
|
||||
"adjustQuantityLabel": "Unidades restantes",
|
||||
"adjustHelper": "Capacidad: {total} unidades.",
|
||||
"adjustSubmit": "Guardar",
|
||||
@@ -1757,7 +1757,15 @@
|
||||
"packagingFailed": "Error al guardar",
|
||||
"addManufacturerLabel": "Fabricante",
|
||||
"addDoseStrengthLabel": "Concentración",
|
||||
"addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml."
|
||||
"addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml.",
|
||||
"openedOn": "Abierto el {date}",
|
||||
"expiresOn": "Caduca el {date}",
|
||||
"summaryAllExpired": "Sin existencias utilizables: {units} unidades marcadas como caducadas",
|
||||
"editExpiryLabel": "Fecha de caducidad impresa",
|
||||
"editExpiryHelper": "La fecha impresa en el envase. Déjalo vacío si no hay ninguna registrada.",
|
||||
"editOpenedLabel": "Abierto el",
|
||||
"editOpenedHelper": "Déjalo vacío si el envase sigue cerrado.",
|
||||
"editOpenedHelperClock": "Una pluma o una ampolla caduca 30 días después de abrirse. Déjalo vacío si sigue cerrada."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1909,6 +1917,32 @@
|
||||
"scaleAbsolute": "Absoluto",
|
||||
"scalePercentSinceStart": "% desde el inicio",
|
||||
"scaleToggleLabel": "Escala del gráfico"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} tomas importadas.",
|
||||
"resultPartial": "{imported} importadas, {skipped} omitidas.",
|
||||
"resultNothing": "No se importó nada: se omitieron las {skipped} entradas.",
|
||||
"resultEmpty": "Ese archivo no contenía ninguna toma.",
|
||||
"skipGroupOne": "{count} entrada omitida: {reason}",
|
||||
"skipGroupFew": "{count} entradas omitidas: {reason}",
|
||||
"skipGroupOther": "{count} entradas omitidas: {reason}",
|
||||
"reasonDuplicateInFile": "otra entrada del mismo archivo tiene la misma fecha y hora",
|
||||
"reasonAlreadyRecorded": "ya hay una toma registrada para esa fecha y hora",
|
||||
"reasonUnknown": "motivo no registrado",
|
||||
"reasonMedicationNotFound": "ningún medicamento registrado coincide con el nombre del archivo",
|
||||
"reasonMedicationAmbiguous": "el nombre del archivo coincide con más de un medicamento",
|
||||
"reasonMedicationIsMirrored": "el medicamento es una copia de solo lectura de otra aplicación",
|
||||
"reasonStatusUnknown": "el estado del archivo no es uno de los que define el formato",
|
||||
"reasonMissingTimestamp": "la fila no indica ninguna hora",
|
||||
"reasonMissingOffset": "la hora no lleva desplazamiento UTC, así que el instante es desconocido",
|
||||
"reasonUnreadableTimestamp": "la hora no tiene el formato que documenta la exportación",
|
||||
"reasonImplausibleTimestamp": "la hora no nombra un instante real, o está en el futuro",
|
||||
"reasonMissingMedication": "la fila no nombra ningún medicamento",
|
||||
"reasonUnreadableDosage": "la dosis no es un número",
|
||||
"reasonUnreadableRow": "la fila tiene menos columnas que la cabecera",
|
||||
"reasonStatusNoDoseInformation": "el archivo no registra nada sobre esa dosis: nadie respondió y nada se anotó",
|
||||
"reasonStatusReminderEvent": "la fila trata de un recordatorio postergado, que no dice nada sobre la dosis",
|
||||
"reasonStatusNotificationNotSent": "la fila registra que no se envió ninguna notificación, lo cual habla de la aplicación y no de la dosis"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1992,6 +2026,14 @@
|
||||
"loadErrorTitle": "No se pudo cargar el gráfico",
|
||||
"loadErrorReload": "Recargar",
|
||||
"sparseDataCaption": "Más días completarán la tendencia.",
|
||||
"dataTable": {
|
||||
"toggle": "Puntos de datos ({count})",
|
||||
"columnDate": "Fecha",
|
||||
"noValue": "Sin valor",
|
||||
"captionDay": "Una fila por día de {metric}: los mismos puntos que dibuja el gráfico.",
|
||||
"captionWeek": "Una fila por semana de {metric}, la media de los días de esa semana: los mismos puntos que dibuja el gráfico.",
|
||||
"captionMonth": "Una fila por mes de {metric}, la media de los días de ese mes: los mismos puntos que dibuja el gráfico."
|
||||
},
|
||||
"personalBaseline": "Tu media",
|
||||
"deltaVsBaseline": "{delta} vs. tu media",
|
||||
"deltaUnchanged": "coincide con tu media",
|
||||
@@ -2120,6 +2162,9 @@
|
||||
"editMode": {
|
||||
"done": "Listo",
|
||||
"reset": "Restablecer",
|
||||
"resetTitle": "¿Restablecer la vista general?",
|
||||
"resetBody": "El orden de las secciones y las que ocultaste vuelven a los valores predeterminados. Tu disposición no se guarda en ningún otro sitio.",
|
||||
"resetConfirm": "Restablecer vista",
|
||||
"title": "Personalizar resumen",
|
||||
"description": "Arrastra para reordenar, toca el ojo para mostrar u ocultar.",
|
||||
"dragHandle": "Arrastra para reordenar",
|
||||
@@ -3395,9 +3440,13 @@
|
||||
"metricActiveEnergy": "Energía activa",
|
||||
"metricSleepDuration": "Sueño",
|
||||
"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."
|
||||
},
|
||||
@@ -3965,6 +4014,12 @@
|
||||
"overrideToggleLabel": "Valores propios",
|
||||
"resetAction": "Restablecer valor por defecto",
|
||||
"resetAllAction": "Restablecer todos los valores",
|
||||
"resetAllTitle": "¿Restablecer todos los rangos personalizados?",
|
||||
"resetAllBody": "Cada métrica vuelve a su rango predeterminado. Los valores que definiste no se guardan en ningún otro sitio y no se pueden recuperar.",
|
||||
"resetAllConfirm": "Restablecer todo",
|
||||
"resetTitle": "¿Restablecer este rango?",
|
||||
"resetBody": "La métrica vuelve a su rango predeterminado. Los valores que definiste no se guardan en ningún otro sitio y no se pueden recuperar.",
|
||||
"resetConfirm": "Restablecer rango",
|
||||
"minLabel": "Mín",
|
||||
"maxLabel": "Máx",
|
||||
"unitSuffix": "({unit})",
|
||||
@@ -4122,6 +4177,7 @@
|
||||
"genderNone": "Prefiero no decirlo",
|
||||
"genderMale": "Hombre",
|
||||
"genderFemale": "Mujer",
|
||||
"genderOther": "Otro",
|
||||
"dateOfBirthLabel": "Fecha de nacimiento",
|
||||
"dateOfBirthHint": "Se utiliza solo para análisis sensibles a la edad.",
|
||||
"saveCta": "Guardar y continuar"
|
||||
@@ -4332,6 +4388,9 @@
|
||||
"cardTitle": "Prioridad de fuentes por métrica",
|
||||
"help": "Si varias fuentes entregan el mismo valor el mismo día, la fuente superior cuenta como canónica. Las demás se conservan para la traza de auditoría.",
|
||||
"resetDefaults": "Restablecer valores predeterminados",
|
||||
"resetDefaultsTitle": "¿Restablecer el orden de fuentes?",
|
||||
"resetDefaultsBody": "Cada orden que configuraste vuelve al predeterminado, para todas las métricas y tipos de dispositivo a la vez. Se guarda de inmediato y no se puede recuperar.",
|
||||
"resetDefaultsConfirm": "Restablecer orden",
|
||||
"moveUp": "Subir",
|
||||
"moveDown": "Bajar",
|
||||
"saveSuccess": "Prioridad de fuentes guardada.",
|
||||
@@ -4538,6 +4597,60 @@
|
||||
"reasonUnstableExternalId": "el ID externo no es estable entre exportaciones",
|
||||
"reasonDuplicate": "ya importada",
|
||||
"reasonUnknown": "omitida"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Historial de dosis",
|
||||
"description": "Un historial de dosis exportado desde otra aplicación de seguimiento, leído tal cual. Un archivo abarca todos los medicamentos que contiene.",
|
||||
"pasteLabel": "Pega la exportación",
|
||||
"fileInputLabel": "Elegir un archivo de historial de dosis",
|
||||
"uploadFile": "Elegir archivo",
|
||||
"preview": "Previsualizar",
|
||||
"import": "Importar",
|
||||
"empty": "Pega la exportación o elige un archivo primero.",
|
||||
"readFailed": "No se pudo leer ese archivo.",
|
||||
"rateLimited": "Máximo 5 importaciones por hora. Inténtalo más tarde.",
|
||||
"tooLarge": "Ese archivo supera el límite de 16 MB.",
|
||||
"inProgress": "Ya hay una importación de historial en curso. Espera a que termine.",
|
||||
"workerUnavailable": "El proceso en segundo plano no está disponible, así que no se importó nada.",
|
||||
"failed": "La importación falló. No se escribió nada.",
|
||||
"columnsHeading": "Qué hace cada columna",
|
||||
"jsonOnly": "solo JSON",
|
||||
"previewLine": "Filas leídas: {rows} · se importarían: {queued} · rechazadas: {refused}",
|
||||
"submittedLine": "Filas leídas: {rows} · en cola para importar: {queued}",
|
||||
"unmatched": "Ningún medicamento registrado coincide con: {names}",
|
||||
"ambiguous": "Coincide con más de un medicamento: {names}",
|
||||
"mirrored": "Copiado de otra aplicación, así que no se puede añadir ninguna dosis: {names}",
|
||||
"unknownColumns": "Columnas sin regla en el importador, no leídas: {names}",
|
||||
"codingsNotRead": "Filas con un valor de Codings no leído: {count}",
|
||||
"archivedRows": "Filas de un medicamento archivado en la aplicación de origen, importadas igualmente: {count}",
|
||||
"verdict": {
|
||||
"honoured": "usada",
|
||||
"reported": "informada",
|
||||
"ignored": "no leída"
|
||||
},
|
||||
"column": {
|
||||
"date": "La hora en que se tomó realmente la dosis. Su desplazamiento UTC se lee tal como está escrito.",
|
||||
"scheduledDate": "La franja a la que pertenecía la dosis, separada de la hora en que se tomó. Vacío significa que no pertenecía a ninguna franja.",
|
||||
"medication": "Se empareja con un medicamento que ya está en tu registro. Nada se crea a partir del archivo.",
|
||||
"nickname": "Se prueba como segundo nombre cuando el primero no coincide con nada.",
|
||||
"dosage": "Se registra en la dosis solo cuando difiere de la cantidad prevista.",
|
||||
"scheduledDosage": "Se compara con la cantidad real para decidir si esa dosis se desvió. Nunca se guarda por sí sola.",
|
||||
"unit": "Se escribe con la cantidad, tal como la escribe el archivo. \"count\" significa múltiplos de la dosis configurada.",
|
||||
"status": "\"Taken\" se convierte en una dosis y \"Skipped\" en una omisión deliberada. Cualquier otro valor no registra decisión alguna y la fila se rechaza.",
|
||||
"archived": "Se cuenta y se informa. Una dosis que ocurrió no se deshace al archivar el medicamento después.",
|
||||
"codings": "El CSV no documenta ninguna codificación para esta columna, así que se deja intacta en vez de adivinarla. Las celdas con contenido se cuentan.",
|
||||
"form": "Describe el medicamento, no la dosis.",
|
||||
"start": "La fecha en que empezaste el medicamento, no la hora de esta dosis.",
|
||||
"end": "La fecha en que dejaste el medicamento, no la hora de esta dosis."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "Ese archivo no contiene filas.",
|
||||
"missingColumns": "A ese archivo le falta una columna que el importador necesita: Date, Medication o Status.",
|
||||
"unreadableJson": "Ese archivo no es JSON válido.",
|
||||
"jsonNotAnArray": "Ese JSON no contiene ninguna lista de dosis.",
|
||||
"jsonNoIntakeTime": "La exportación JSON registra cuándo tocaba una dosis pero no cuándo se tomó, y una hora prevista no se escribirá como si fuera la toma. Exporta el CSV, que lleva ambas.",
|
||||
"tooManyRows": "Ese archivo tiene más filas de las que admite una importación. Divídelo e importa las partes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exporta tus datos o importa desde una copia de seguridad."
|
||||
@@ -4661,7 +4774,10 @@
|
||||
"description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días antiguos entren en las correlaciones. El inicio toma por defecto la fecha de vigencia de tu hogar; para llegar más atrás, añade primero un periodo de ubicación para ese rango.",
|
||||
"run": "Rellenar"
|
||||
},
|
||||
"noResults": "No se encontraron lugares coincidentes."
|
||||
"noResults": "No se encontraron lugares coincidentes.",
|
||||
"fetchFailedOne": "La última actualización meteorológica en segundo plano falló ({when}).",
|
||||
"fetchFailedFew": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}.",
|
||||
"fetchFailedOther": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -4706,6 +4822,9 @@
|
||||
"upload": "Subir foto",
|
||||
"replace": "Reemplazar foto",
|
||||
"remove": "Quitar",
|
||||
"removeTitle": "¿Eliminar la foto de perfil?",
|
||||
"removeBody": "La imagen se elimina de tu servidor y tus iniciales ocupan su lugar. Necesitarás el archivo original para volver a subirla.",
|
||||
"removeConfirm": "Eliminar foto",
|
||||
"hint": "JPEG, PNG o WebP. Hasta 2 MB y 2048×2048 píxeles.",
|
||||
"uploaded": "Foto de perfil actualizada",
|
||||
"removed": "Foto de perfil eliminada",
|
||||
@@ -4865,6 +4984,9 @@
|
||||
"statusExpired": "Conexión caducada",
|
||||
"connectButton": "Conectar con ChatGPT",
|
||||
"disconnectButton": "Desconectar",
|
||||
"disconnectTitle": "¿Desconectar ChatGPT?",
|
||||
"disconnectBody": "Se descarta el inicio de sesión guardado junto con el texto de tus análisis en caché. Volver a conectar exige repetir el flujo de código de dispositivo.",
|
||||
"disconnectConfirm": "Desconectar",
|
||||
"modelSlugLabel": "Slug del modelo",
|
||||
"modelSlugBody": "Codex usa el modelo al que enruta tu suscripción de ChatGPT. La variable de entorno CODEX_MODEL permite al operador sobrescribirlo en la instancia.",
|
||||
"lastInsight": "Último análisis: {when}"
|
||||
@@ -4895,6 +5017,9 @@
|
||||
"categoryContext": "Contexto",
|
||||
"forget": "Olvidar",
|
||||
"forgetAria": "Olvidar este dato",
|
||||
"forgetTitle": "¿Olvidar este dato?",
|
||||
"forgetBody": "El Coach dejará de usarlo a partir del próximo mensaje. Un dato olvidado no se puede recuperar.",
|
||||
"forgetConfirm": "Olvidar",
|
||||
"forgetAll": "Olvidar todo",
|
||||
"forgetAllConfirmTitle": "¿Olvidar todo lo que recuerda el Coach?",
|
||||
"forgetAllConfirmBody": "Esto elimina todos los datos guardados. El Coach empezará de cero la próxima vez que converses. No se puede deshacer.",
|
||||
@@ -4938,6 +5063,9 @@
|
||||
"placeholder": "P. ej. trabajo por turnos, dieta vegetariana, lesión de rodilla, entrenamiento para una media maratón…",
|
||||
"charCount": "{used} / {max} caracteres",
|
||||
"clear": "Vaciar",
|
||||
"clearTitle": "¿Vaciar la nota «Sobre mí»?",
|
||||
"clearBody": "Solo se elimina el texto libre. Tus alergias e intolerancias permanecen tal cual.",
|
||||
"clearConfirm": "Vaciar nota",
|
||||
"save": "Guardar",
|
||||
"savedToast": "Nota «Sobre mí» guardada.",
|
||||
"clearedToast": "Nota «Sobre mí» eliminada.",
|
||||
@@ -5155,7 +5283,7 @@
|
||||
"integrationPill": {
|
||||
"connected": "Conectado",
|
||||
"errorReconnect": "Error — reconectar",
|
||||
"warningServerError": "Conectado, problema de sincronización",
|
||||
"warningServerError": "Sincronización con errores",
|
||||
"parkedReconnect": "Pausado — reconectar manualmente",
|
||||
"notConnected": "No conectado",
|
||||
"justNow": "ahora mismo",
|
||||
@@ -5217,10 +5345,10 @@
|
||||
"apiEndpointAuth": "Autenticación",
|
||||
"apiEndpointExample": "Ejemplo de cuerpo",
|
||||
"dangerZone": "Eliminar todos los datos",
|
||||
"dangerZoneDescription": "Elimina todos tus datos de salud e integraciones. Tu cuenta de usuario se conservará.",
|
||||
"dangerZoneDescription": "Borra todo tu historial de salud y todas las conexiones de integración. Tu cuenta y las credenciales con las que inicias sesión se conservan.",
|
||||
"dangerZoneConfirm": "¿Eliminar todos los datos?",
|
||||
"dangerZoneConfirmDescription": "Esto eliminará de forma permanente todos tus datos de salud e integraciones. Tu cuenta de usuario se conservará.",
|
||||
"dangerZoneSuccess": "Se han eliminado todos los datos personales",
|
||||
"dangerZoneConfirmDescription": "Esto elimina de forma permanente todo lo que hay en tu historial: mediciones, medicamentos, entradas de ánimo, resultados de laboratorio, entradas de ciclo, documentos, conversaciones con la IA y todas las conexiones de integración, junto con todo lo derivado de ello y todo lo que eliminaste antes. Los enlaces compartidos que diste a una consulta dejan de funcionar de inmediato. Tu cuenta se mantiene, junto con las credenciales con las que inicias sesión y los ajustes de presentación como el idioma, la zona horaria y las unidades. Esto no se puede deshacer.",
|
||||
"dangerZoneSuccess": "Se ha eliminado tu historial de salud. Tu cuenta y tu inicio de sesión no han cambiado.",
|
||||
"dangerZoneDeleteFailed": "Falló la eliminación",
|
||||
"finalDelete": "Eliminar permanentemente",
|
||||
"deleteAccountCardTitle": "Eliminar la cuenta por completo",
|
||||
@@ -5763,6 +5891,9 @@
|
||||
"baseUrlLabel": "URL base",
|
||||
"save": "Guardar",
|
||||
"remove": "Eliminar clave",
|
||||
"removeTitle": "¿Eliminar la clave del servidor?",
|
||||
"removeBody": "Las cuentas sin clave propia pierden las funciones de IA de inmediato. La clave no se muestra en ningún sitio una vez guardada, así que necesitarás el original para volver a ponerla.",
|
||||
"removeConfirm": "Eliminar clave",
|
||||
"saved": "Ajustes de la clave de IA del servidor guardados",
|
||||
"error": "No se pudo guardar la clave de IA del servidor"
|
||||
},
|
||||
@@ -6285,10 +6416,23 @@
|
||||
"statusExpired": "Caducado",
|
||||
"connectButton": "Conectar una cuenta de ChatGPT",
|
||||
"disconnectButton": "Desconectar",
|
||||
"disconnectTitle": "¿Desconectar la cuenta de IA compartida?",
|
||||
"disconnectBody": "Todos los usuarios de este servidor que dependan de la conexión compartida pierden las funciones de IA hasta que alguien vuelva a vincular una cuenta. El inicio de sesión guardado no se puede recuperar.",
|
||||
"disconnectConfirm": "Desconectar",
|
||||
"connectedSince": "Conectado desde {when}",
|
||||
"connected": "Conexión de IA compartida vinculada",
|
||||
"disconnected": "Conexión de IA compartida eliminada",
|
||||
"error": "Algo salió mal: inténtalo de nuevo"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Trabajos en segundo plano",
|
||||
"description": "Colas cuyos trabajos se rindieron tras agotar sus reintentos.",
|
||||
"none": "Ningún trabajo ha fallado en las últimas {hours} horas.",
|
||||
"unavailable": "No hay ninguna cola que consultar: este despliegue funciona sin el worker en segundo plano.",
|
||||
"countOne": "{count} fallo",
|
||||
"countFew": "{count} fallos",
|
||||
"countOther": "{count} fallos",
|
||||
"lastFailed": "Último fallo {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -6751,6 +6895,10 @@
|
||||
"diastolicMax": "Diastólica máx",
|
||||
"boundsHint": "Rango permitido: {min}–{max} {unit}",
|
||||
"resetToDefault": "Restablecer por defecto",
|
||||
"resetTitle": "¿Restablecer este objetivo?",
|
||||
"resetBody": "El rango vuelve al predeterminado. Los valores que definiste no se guardan en ningún otro sitio y no se pueden recuperar.",
|
||||
"resetBodyBp": "Tanto el rango sistólico como el diastólico vuelven al predeterminado. Los valores que definiste no se guardan en ningún otro sitio y no se pueden recuperar.",
|
||||
"resetConfirm": "Restablecer objetivo",
|
||||
"saveSuccess": "Rango objetivo actualizado",
|
||||
"saveError": "No se ha podido guardar el rango objetivo",
|
||||
"resetSuccess": "Rango objetivo restablecido por defecto",
|
||||
@@ -6880,7 +7028,8 @@
|
||||
"title": "Quedan pocas existencias: {medName}",
|
||||
"bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro.",
|
||||
"bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn} — vuelve a pedir antes del {reorderBy}.",
|
||||
"bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}."
|
||||
"bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}.",
|
||||
"bodyExpired": "{medName}: no quedan existencias utilizables. {units} unidades están marcadas como caducadas."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Registrar el ánimo",
|
||||
@@ -6983,6 +7132,9 @@
|
||||
"icon": "Icono",
|
||||
"limitReached": "Se alcanzó el límite de síntomas personalizados.",
|
||||
"remove": "Eliminar {label}",
|
||||
"removeTitle": "¿Eliminar este síntoma?",
|
||||
"removeBody": "«{label}» desaparece de la lista de síntomas. Los días que ya lo tienen lo conservan, pero no podrás volver a elegirlo.",
|
||||
"removeConfirm": "Eliminar",
|
||||
"fallbackLabel": "Síntoma personalizado"
|
||||
}
|
||||
},
|
||||
@@ -7215,6 +7367,9 @@
|
||||
"severity": "Intensidad",
|
||||
"severityLevel": "Intensidad {level} de 4",
|
||||
"delete": "Eliminar",
|
||||
"deleteTitle": "¿Eliminar este día?",
|
||||
"deleteBody": "Se pierde todo lo registrado ese día: flujo, temperatura, síntomas y tu nota. No se puede recuperar.",
|
||||
"deleteConfirm": "Eliminar día",
|
||||
"temperatureSection": "Temperatura y ovulación",
|
||||
"intimacySection": "Intimidad y anticoncepción",
|
||||
"testsSection": "Pruebas",
|
||||
@@ -8008,6 +8163,9 @@
|
||||
"markMet": "Objetivo cumplido",
|
||||
"abandon": "Finalizar plan",
|
||||
"remove": "Eliminar",
|
||||
"removeTitle": "¿Eliminar este plan?",
|
||||
"removeBody": "El plan y cómo fue desaparecen de tu historial. No se puede recuperar.",
|
||||
"removeConfirm": "Eliminar plan",
|
||||
"empty": "Aún no hay planes. Cuando te comprometas con una intención concreta de tipo «si… entonces» en una conversación, el Coach la propondrá aquí para confirmarla.",
|
||||
"groupProposed": "Propuestas",
|
||||
"groupActive": "Activos",
|
||||
|
||||
+170
-12
@@ -455,6 +455,9 @@
|
||||
"scoreRingsTitle": "Anneaux de score",
|
||||
"scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance. Glissez pour réordonner ; le score de santé est placé en premier par défaut.",
|
||||
"layoutReset": "Rétablir les valeurs par défaut",
|
||||
"layoutResetTitle": "Réinitialiser la disposition du tableau de bord ?",
|
||||
"layoutResetBody": "L’ordre de vos tuiles, celles que vous avez masquées et les anneaux choisis reviennent aux valeurs par défaut. Cette disposition n’est conservée nulle part ailleurs.",
|
||||
"layoutResetConfirm": "Réinitialiser la disposition",
|
||||
"layoutSaveSuccess": "Mise en page enregistrée",
|
||||
"layoutSaveError": "Impossible d’enregistrer la mise en page",
|
||||
"layoutConflictReloaded": "Mise en page rechargée depuis le serveur. Veuillez refaire votre modification.",
|
||||
@@ -911,9 +914,6 @@
|
||||
"importFileLoaded": "Fichier « {name} » chargé",
|
||||
"importInvalidJson": "Le fichier ne contient pas de JSON valide",
|
||||
"importNoArray": "Aucun tableau trouvé",
|
||||
"importResult": "{imported} prises importées",
|
||||
"importDuplicatesSkipped": "{count} doublons ignorés",
|
||||
"importInvalidSkipped": "{count} entrées non valides ignorées",
|
||||
"importFailed": "Échec de l’importation",
|
||||
"importInvalidFormat": "Format JSON non valide",
|
||||
"requestExample": "Exemple de requête",
|
||||
@@ -1716,8 +1716,8 @@
|
||||
"addSuccess": "Stock enregistré.",
|
||||
"addFailed": "Le stock n'a pas pu être enregistré.",
|
||||
"adjustButton": "Ajuster",
|
||||
"adjustTitle": "Corriger le stock",
|
||||
"adjustDescription": "Définissez les unités restantes — pour une correction ou un prélèvement. 0 marque l'article comme épuisé.",
|
||||
"adjustTitle": "Corriger le contenant",
|
||||
"adjustDescription": "Ajustez les unités restantes et corrigez les dates de ce contenant. 0 unité le marque comme épuisé.",
|
||||
"adjustQuantityLabel": "Unités restantes",
|
||||
"adjustHelper": "Capacité : {total} unités.",
|
||||
"adjustSubmit": "Enregistrer",
|
||||
@@ -1757,7 +1757,15 @@
|
||||
"packagingFailed": "Échec de l’enregistrement",
|
||||
"addManufacturerLabel": "Fabricant",
|
||||
"addDoseStrengthLabel": "Dosage",
|
||||
"addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml."
|
||||
"addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml.",
|
||||
"openedOn": "Ouvert le {date}",
|
||||
"expiresOn": "Périme le {date}",
|
||||
"summaryAllExpired": "Aucun stock utilisable : {units} unités marquées périmées",
|
||||
"editExpiryLabel": "Date de péremption imprimée",
|
||||
"editExpiryHelper": "La date imprimée sur la boîte. Laissez vide si aucune n'est enregistrée.",
|
||||
"editOpenedLabel": "Ouvert le",
|
||||
"editOpenedHelper": "Laissez vide si le contenant est encore scellé.",
|
||||
"editOpenedHelperClock": "Un stylo ou une ampoule périme 30 jours après ouverture. Laissez vide s'il est encore scellé."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1909,6 +1917,32 @@
|
||||
"scaleAbsolute": "Absolu",
|
||||
"scalePercentSinceStart": "% depuis le début",
|
||||
"scaleToggleLabel": "Échelle du graphique"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} prises importées.",
|
||||
"resultPartial": "{imported} importées, {skipped} ignorées.",
|
||||
"resultNothing": "Rien n’a été importé : les {skipped} entrées ont été ignorées.",
|
||||
"resultEmpty": "Ce fichier ne contenait aucune prise.",
|
||||
"skipGroupOne": "{count} entrée ignorée : {reason}",
|
||||
"skipGroupFew": "{count} entrées ignorées : {reason}",
|
||||
"skipGroupOther": "{count} entrées ignorées : {reason}",
|
||||
"reasonDuplicateInFile": "une autre entrée du même fichier porte les mêmes date et heure",
|
||||
"reasonAlreadyRecorded": "une prise est déjà enregistrée pour cette date et cette heure",
|
||||
"reasonUnknown": "motif non enregistré",
|
||||
"reasonMedicationNotFound": "aucun médicament enregistré ne correspond au nom du fichier",
|
||||
"reasonMedicationAmbiguous": "le nom du fichier correspond à plusieurs médicaments",
|
||||
"reasonMedicationIsMirrored": "le médicament est une copie en lecture seule provenant d'une autre application",
|
||||
"reasonStatusUnknown": "le statut du fichier ne fait pas partie de ceux que définit le format",
|
||||
"reasonMissingTimestamp": "la ligne n'indique aucune heure",
|
||||
"reasonMissingOffset": "l'heure ne porte pas de décalage UTC, l'instant est donc inconnu",
|
||||
"reasonUnreadableTimestamp": "l'heure n'est pas au format documenté par l'export",
|
||||
"reasonImplausibleTimestamp": "l'heure ne désigne aucun instant réel, ou se situe dans le futur",
|
||||
"reasonMissingMedication": "la ligne ne nomme aucun médicament",
|
||||
"reasonUnreadableDosage": "la dose n'est pas un nombre",
|
||||
"reasonUnreadableRow": "la ligne a moins de colonnes que l'en-tête",
|
||||
"reasonStatusNoDoseInformation": "le fichier ne consigne rien sur cette dose : personne n'a répondu et rien n'a été noté",
|
||||
"reasonStatusReminderEvent": "la ligne concerne un rappel reporté, ce qui ne dit rien de la dose",
|
||||
"reasonStatusNotificationNotSent": "la ligne consigne qu'aucune notification n'a été envoyée, ce qui parle de l'application et non de la dose"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1992,6 +2026,14 @@
|
||||
"loadErrorTitle": "Impossible de charger le graphique",
|
||||
"loadErrorReload": "Recharger",
|
||||
"sparseDataCaption": "Davantage de jours étofferont la tendance.",
|
||||
"dataTable": {
|
||||
"toggle": "Points de données ({count})",
|
||||
"columnDate": "Date",
|
||||
"noValue": "Aucune valeur",
|
||||
"captionDay": "Une ligne par jour pour {metric} : les mêmes points que ceux tracés par le graphique.",
|
||||
"captionWeek": "Une ligne par semaine pour {metric}, la moyenne des jours de cette semaine : les mêmes points que ceux tracés par le graphique.",
|
||||
"captionMonth": "Une ligne par mois pour {metric}, la moyenne des jours de ce mois : les mêmes points que ceux tracés par le graphique."
|
||||
},
|
||||
"personalBaseline": "Ta moyenne",
|
||||
"deltaVsBaseline": "{delta} vs. ta moyenne",
|
||||
"deltaUnchanged": "correspond à ta moyenne",
|
||||
@@ -2120,6 +2162,9 @@
|
||||
"editMode": {
|
||||
"done": "Terminé",
|
||||
"reset": "Réinitialiser",
|
||||
"resetTitle": "Réinitialiser la vue d’ensemble ?",
|
||||
"resetBody": "L’ordre des sections et celles que vous avez masquées reviennent aux valeurs par défaut. Votre disposition n’est conservée nulle part ailleurs.",
|
||||
"resetConfirm": "Réinitialiser la vue",
|
||||
"title": "Personnaliser l’aperçu",
|
||||
"description": "Fais glisser pour réorganiser, touche l’œil pour afficher ou masquer.",
|
||||
"dragHandle": "Fais glisser pour réorganiser",
|
||||
@@ -3395,9 +3440,13 @@
|
||||
"metricActiveEnergy": "Énergie active",
|
||||
"metricSleepDuration": "Sommeil",
|
||||
"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."
|
||||
},
|
||||
@@ -3965,6 +4014,12 @@
|
||||
"overrideToggleLabel": "Valeurs personnelles",
|
||||
"resetAction": "Restaurer la valeur par défaut",
|
||||
"resetAllAction": "Tout restaurer aux valeurs par défaut",
|
||||
"resetAllTitle": "Réinitialiser toutes les plages personnalisées ?",
|
||||
"resetAllBody": "Chaque mesure revient à sa plage par défaut. Les valeurs que vous avez définies ne sont conservées nulle part ailleurs et ne peuvent pas être récupérées.",
|
||||
"resetAllConfirm": "Tout réinitialiser",
|
||||
"resetTitle": "Réinitialiser cette plage ?",
|
||||
"resetBody": "La mesure revient à sa plage par défaut. Les valeurs que vous avez définies ne sont conservées nulle part ailleurs et ne peuvent pas être récupérées.",
|
||||
"resetConfirm": "Réinitialiser la plage",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Max",
|
||||
"unitSuffix": "({unit})",
|
||||
@@ -4122,6 +4177,7 @@
|
||||
"genderNone": "Préfère ne pas le préciser",
|
||||
"genderMale": "Homme",
|
||||
"genderFemale": "Femme",
|
||||
"genderOther": "Autre",
|
||||
"dateOfBirthLabel": "Date de naissance",
|
||||
"dateOfBirthHint": "Utilisée uniquement pour les analyses qui tiennent compte de l'âge.",
|
||||
"saveCta": "Enregistrer et continuer"
|
||||
@@ -4332,6 +4388,9 @@
|
||||
"cardTitle": "Priorité des sources par métrique",
|
||||
"help": "Si plusieurs sources fournissent la même valeur le même jour, la source en tête compte comme canonique. Les autres restent enregistrées pour la piste d’audit.",
|
||||
"resetDefaults": "Rétablir les valeurs par défaut",
|
||||
"resetDefaultsTitle": "Réinitialiser l’ordre des sources ?",
|
||||
"resetDefaultsBody": "Chaque classement que vous avez défini revient à l’ordre par défaut, pour toutes les mesures et tous les types d’appareil à la fois. L’enregistrement est immédiat et irréversible.",
|
||||
"resetDefaultsConfirm": "Réinitialiser l’ordre",
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Descendre",
|
||||
"saveSuccess": "Priorité des sources enregistrée.",
|
||||
@@ -4538,6 +4597,60 @@
|
||||
"reasonUnstableExternalId": "l'identifiant externe n'est pas stable d'un export à l'autre",
|
||||
"reasonDuplicate": "déjà importée",
|
||||
"reasonUnknown": "ignorée"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Historique des prises",
|
||||
"description": "Un historique de prises exporté depuis une autre application de suivi, lu tel quel. Un fichier couvre tous les médicaments qu'il contient.",
|
||||
"pasteLabel": "Coller l'export",
|
||||
"fileInputLabel": "Choisir un fichier d'historique des prises",
|
||||
"uploadFile": "Choisir un fichier",
|
||||
"preview": "Aperçu",
|
||||
"import": "Importer",
|
||||
"empty": "Collez d'abord l'export ou choisissez un fichier.",
|
||||
"readFailed": "Ce fichier n'a pas pu être lu.",
|
||||
"rateLimited": "Maximum 5 imports par heure. Réessayez plus tard.",
|
||||
"tooLarge": "Ce fichier dépasse la limite de 16 Mo.",
|
||||
"inProgress": "Un import d'historique est déjà en cours. Attendez qu'il se termine.",
|
||||
"workerUnavailable": "Le service d'arrière-plan est indisponible, rien n'a été importé.",
|
||||
"failed": "L'import a échoué. Rien n'a été écrit.",
|
||||
"columnsHeading": "Ce que devient chaque colonne",
|
||||
"jsonOnly": "JSON uniquement",
|
||||
"previewLine": "Lignes lues : {rows} · seraient importées : {queued} · refusées : {refused}",
|
||||
"submittedLine": "Lignes lues : {rows} · en file d'import : {queued}",
|
||||
"unmatched": "Aucun médicament enregistré ne correspond à : {names}",
|
||||
"ambiguous": "Correspond à plusieurs médicaments : {names}",
|
||||
"mirrored": "Copié depuis une autre application, aucune prise ne peut y être ajoutée : {names}",
|
||||
"unknownColumns": "Colonnes sans règle dans l'importeur, non lues : {names}",
|
||||
"codingsNotRead": "Lignes portant une valeur Codings non lue : {count}",
|
||||
"archivedRows": "Lignes d'un médicament archivé dans l'application source, importées quand même : {count}",
|
||||
"verdict": {
|
||||
"honoured": "utilisée",
|
||||
"reported": "signalée",
|
||||
"ignored": "non lue"
|
||||
},
|
||||
"column": {
|
||||
"date": "L'heure à laquelle la dose a réellement été prise. Son décalage UTC est lu exactement tel qu'il est écrit.",
|
||||
"scheduledDate": "Le créneau auquel la dose appartenait, distinct de l'heure de prise. Vide signifie qu'elle n'appartenait à aucun créneau.",
|
||||
"medication": "Rapproché d'un médicament déjà présent dans votre dossier. Rien n'est créé à partir du fichier.",
|
||||
"nickname": "Essayé comme second nom lorsque le premier ne correspond à rien.",
|
||||
"dosage": "Consigné sur la dose uniquement lorsqu'il diffère de la quantité prévue.",
|
||||
"scheduledDosage": "Comparé à la quantité réelle pour décider s'il y a eu écart. Jamais conservé seul.",
|
||||
"unit": "Écrit avec la quantité, orthographié comme dans le fichier. « count » signifie des multiples de la dose configurée.",
|
||||
"status": "« Taken » devient une prise et « Skipped » une omission délibérée. Toute autre valeur ne consigne aucune décision et la ligne est refusée.",
|
||||
"archived": "Compté et signalé. Une prise qui a eu lieu n'est pas annulée parce que le médicament a été archivé ensuite.",
|
||||
"codings": "Le CSV ne documente aucun encodage pour cette colonne, elle est donc laissée telle quelle plutôt que devinée. Les cellules remplies sont comptées.",
|
||||
"form": "Décrit le médicament, pas la prise.",
|
||||
"start": "La date de début du traitement, pas l'heure de cette prise.",
|
||||
"end": "La date d'arrêt du traitement, pas l'heure de cette prise."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "Ce fichier ne contient aucune ligne.",
|
||||
"missingColumns": "Il manque à ce fichier une colonne dont l'importeur a besoin : Date, Medication ou Status.",
|
||||
"unreadableJson": "Ce fichier n'est pas du JSON valide.",
|
||||
"jsonNotAnArray": "Ce JSON ne contient aucune liste de prises.",
|
||||
"jsonNoIntakeTime": "L'export JSON consigne le moment où une dose était due, pas celui où elle a été prise, et une heure prévue ne sera pas écrite comme si c'était la prise. Exportez le CSV, qui porte les deux.",
|
||||
"tooManyRows": "Ce fichier contient plus de lignes qu'un import peut en prendre. Découpez-le et importez les parties."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exportez vos données ou importez depuis une sauvegarde."
|
||||
@@ -4661,7 +4774,10 @@
|
||||
"description": "Récupérez et enregistrez la météo d'une plage de dates passée pour que les jours anciens rejoignent les corrélations. Le début reprend par défaut la date d'effet de votre domicile ; pour remonter plus loin, ajoutez d'abord une période de localisation pour cette plage.",
|
||||
"run": "Récupérer"
|
||||
},
|
||||
"noResults": "Aucun lieu correspondant trouvé."
|
||||
"noResults": "Aucun lieu correspondant trouvé.",
|
||||
"fetchFailedOne": "La dernière mise à jour météo en arrière-plan a échoué ({when}).",
|
||||
"fetchFailedFew": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}.",
|
||||
"fetchFailedOther": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4706,6 +4822,9 @@
|
||||
"upload": "Téléverser une photo",
|
||||
"replace": "Remplacer la photo",
|
||||
"remove": "Supprimer",
|
||||
"removeTitle": "Supprimer la photo de profil ?",
|
||||
"removeBody": "L’image est supprimée de votre serveur et vos initiales prennent sa place. Il vous faudra le fichier d’origine pour la téléverser à nouveau.",
|
||||
"removeConfirm": "Supprimer la photo",
|
||||
"hint": "JPEG, PNG ou WebP. Jusqu'à 2 Mo et 2048×2048 pixels.",
|
||||
"uploaded": "Photo de profil mise à jour",
|
||||
"removed": "Photo de profil supprimée",
|
||||
@@ -4865,6 +4984,9 @@
|
||||
"statusExpired": "Connexion expirée",
|
||||
"connectButton": "Se connecter avec ChatGPT",
|
||||
"disconnectButton": "Déconnecter",
|
||||
"disconnectTitle": "Déconnecter ChatGPT ?",
|
||||
"disconnectBody": "La connexion enregistrée est supprimée, ainsi que le texte de vos analyses en cache. Se reconnecter suppose de refaire l’authentification par code d’appareil.",
|
||||
"disconnectConfirm": "Déconnecter",
|
||||
"modelSlugLabel": "Slug du modèle",
|
||||
"modelSlugBody": "Codex utilise le modèle vers lequel votre abonnement ChatGPT route. La variable d’environnement CODEX_MODEL permet à l’opérateur de le remplacer sur l’instance.",
|
||||
"lastInsight": "Dernière analyse : {when}"
|
||||
@@ -4895,6 +5017,9 @@
|
||||
"categoryContext": "Contexte",
|
||||
"forget": "Oublier",
|
||||
"forgetAria": "Oublier cette information",
|
||||
"forgetTitle": "Oublier ce fait ?",
|
||||
"forgetBody": "Le Coach cesse de l’utiliser dès le message suivant. Un fait oublié ne peut pas être récupéré.",
|
||||
"forgetConfirm": "Oublier",
|
||||
"forgetAll": "Tout oublier",
|
||||
"forgetAllConfirmTitle": "Oublier tout ce dont le Coach se souvient ?",
|
||||
"forgetAllConfirmBody": "Cela supprime toutes les informations enregistrées. Le Coach repartira de zéro lors de votre prochaine conversation. Cette action est irréversible.",
|
||||
@@ -4938,6 +5063,9 @@
|
||||
"placeholder": "Ex. travail posté, régime végétarien, blessure au genou, entraînement pour un semi-marathon…",
|
||||
"charCount": "{used} / {max} caractères",
|
||||
"clear": "Vider",
|
||||
"clearTitle": "Vider la note « À propos de moi » ?",
|
||||
"clearBody": "Seul le texte libre est supprimé. Vos allergies et intolérances restent inchangées.",
|
||||
"clearConfirm": "Vider la note",
|
||||
"save": "Enregistrer",
|
||||
"savedToast": "Note « À propos de moi » enregistrée.",
|
||||
"clearedToast": "Note « À propos de moi » supprimée.",
|
||||
@@ -5155,7 +5283,7 @@
|
||||
"integrationPill": {
|
||||
"connected": "Connecté",
|
||||
"errorReconnect": "Erreur — reconnecter",
|
||||
"warningServerError": "Connecté, problème de synchronisation",
|
||||
"warningServerError": "Échec de synchronisation",
|
||||
"parkedReconnect": "En pause — reconnecter manuellement",
|
||||
"notConnected": "Non connecté",
|
||||
"justNow": "à l’instant",
|
||||
@@ -5217,10 +5345,10 @@
|
||||
"apiEndpointAuth": "Authentification",
|
||||
"apiEndpointExample": "Exemple de corps",
|
||||
"dangerZone": "Supprimer toutes les données",
|
||||
"dangerZoneDescription": "Supprime toutes vos données de santé et intégrations. Votre compte utilisateur sera conservé.",
|
||||
"dangerZoneDescription": "Efface l’intégralité de votre dossier de santé et toutes les connexions d’intégration. Votre compte et vos identifiants de connexion sont conservés.",
|
||||
"dangerZoneConfirm": "Supprimer toutes les données ?",
|
||||
"dangerZoneConfirmDescription": "Cette action supprimera définitivement toutes vos données de santé et intégrations. Votre compte utilisateur sera conservé.",
|
||||
"dangerZoneSuccess": "Toutes les données personnelles ont été supprimées",
|
||||
"dangerZoneConfirmDescription": "Cette action supprime définitivement tout ce que contient votre dossier : mesures, médicaments, entrées d’humeur, résultats de laboratoire, entrées de cycle, documents, conversations avec l’IA et toutes les connexions d’intégration, ainsi que tout ce qui en découle et tout ce que vous aviez supprimé auparavant. Les liens de partage remis à un cabinet cessent aussitôt de fonctionner. Votre compte reste en place, de même que vos identifiants de connexion et vos réglages d’affichage comme la langue, le fuseau horaire et les unités. Cette action est irréversible.",
|
||||
"dangerZoneSuccess": "Votre dossier de santé a été supprimé. Votre compte et votre connexion sont inchangés.",
|
||||
"dangerZoneDeleteFailed": "Échec de la suppression",
|
||||
"finalDelete": "Supprimer définitivement",
|
||||
"deleteAccountCardTitle": "Supprimer entièrement le compte",
|
||||
@@ -5763,6 +5891,9 @@
|
||||
"baseUrlLabel": "URL de base",
|
||||
"save": "Enregistrer",
|
||||
"remove": "Supprimer la clé",
|
||||
"removeTitle": "Supprimer la clé du serveur ?",
|
||||
"removeBody": "Les comptes sans clé personnelle perdent aussitôt les fonctions IA. La clé n’est affichée nulle part une fois enregistrée : il vous faudra l’original pour la remettre.",
|
||||
"removeConfirm": "Supprimer la clé",
|
||||
"saved": "Paramètres de la clé IA du serveur enregistrés",
|
||||
"error": "Échec de l'enregistrement de la clé IA du serveur"
|
||||
},
|
||||
@@ -6285,10 +6416,23 @@
|
||||
"statusExpired": "Expiré",
|
||||
"connectButton": "Relier un compte ChatGPT",
|
||||
"disconnectButton": "Déconnecter",
|
||||
"disconnectTitle": "Déconnecter le compte IA partagé ?",
|
||||
"disconnectBody": "Tous les utilisateurs de ce serveur qui s’appuient sur la connexion partagée perdent les fonctions IA jusqu’à ce qu’un compte soit relié à nouveau. La connexion enregistrée est irrécupérable.",
|
||||
"disconnectConfirm": "Déconnecter",
|
||||
"connectedSince": "Connecté depuis le {when}",
|
||||
"connected": "Connexion IA partagée reliée",
|
||||
"disconnected": "Connexion IA partagée supprimée",
|
||||
"error": "Une erreur s'est produite — veuillez réessayer"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Tâches en arrière-plan",
|
||||
"description": "Files dont les tâches ont abandonné après avoir épuisé leurs tentatives.",
|
||||
"none": "Aucune tâche n’a échoué au cours des {hours} dernières heures.",
|
||||
"unavailable": "Aucune file à consulter : ce déploiement fonctionne sans le worker en arrière-plan.",
|
||||
"countOne": "{count} échec",
|
||||
"countFew": "{count} échecs",
|
||||
"countOther": "{count} échecs",
|
||||
"lastFailed": "Dernier échec {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -6751,6 +6895,10 @@
|
||||
"diastolicMax": "Diastolique max",
|
||||
"boundsHint": "Plage autorisée : {min}–{max} {unit}",
|
||||
"resetToDefault": "Restaurer la valeur par défaut",
|
||||
"resetTitle": "Réinitialiser cet objectif ?",
|
||||
"resetBody": "La plage revient à la valeur par défaut. Les valeurs que vous avez définies ne sont conservées nulle part ailleurs et ne peuvent pas être récupérées.",
|
||||
"resetBodyBp": "La plage systolique et la plage diastolique reviennent toutes deux à la valeur par défaut. Les valeurs que vous avez définies ne sont conservées nulle part ailleurs et ne peuvent pas être récupérées.",
|
||||
"resetConfirm": "Réinitialiser l’objectif",
|
||||
"saveSuccess": "Plage cible mise à jour",
|
||||
"saveError": "Impossible d’enregistrer la plage cible",
|
||||
"resetSuccess": "Plage cible réinitialisée",
|
||||
@@ -6880,7 +7028,8 @@
|
||||
"title": "Stock faible : {medName}",
|
||||
"bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve.",
|
||||
"bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn} — recommandez avant le {reorderBy}.",
|
||||
"bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}."
|
||||
"bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}.",
|
||||
"bodyExpired": "{medName} : plus de stock utilisable. {units} unités sont marquées périmées."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Enregistrer votre humeur",
|
||||
@@ -6983,6 +7132,9 @@
|
||||
"icon": "Icône",
|
||||
"limitReached": "Limite de symptômes personnalisés atteinte.",
|
||||
"remove": "Supprimer {label}",
|
||||
"removeTitle": "Supprimer ce symptôme ?",
|
||||
"removeBody": "« {label} » disparaît de la liste des symptômes. Les journées déjà marquées le conservent, mais vous ne pourrez plus le sélectionner.",
|
||||
"removeConfirm": "Supprimer",
|
||||
"fallbackLabel": "Symptôme personnalisé"
|
||||
}
|
||||
},
|
||||
@@ -7215,6 +7367,9 @@
|
||||
"severity": "Intensité",
|
||||
"severityLevel": "Intensité {level} sur 4",
|
||||
"delete": "Supprimer",
|
||||
"deleteTitle": "Supprimer cette journée ?",
|
||||
"deleteBody": "Tout ce qui est enregistré pour cette journée disparaît : flux, température, symptômes et votre note. Impossible de le récupérer.",
|
||||
"deleteConfirm": "Supprimer la journée",
|
||||
"temperatureSection": "Température et ovulation",
|
||||
"intimacySection": "Intimité et contraception",
|
||||
"testsSection": "Tests",
|
||||
@@ -8008,6 +8163,9 @@
|
||||
"markMet": "Objectif atteint",
|
||||
"abandon": "Terminer le plan",
|
||||
"remove": "Supprimer",
|
||||
"removeTitle": "Supprimer ce plan ?",
|
||||
"removeBody": "Le plan et son déroulement disparaissent de votre historique. Il est impossible de le récupérer.",
|
||||
"removeConfirm": "Supprimer le plan",
|
||||
"empty": "Aucun plan pour l'instant. Lorsque vous vous engagez sur une intention concrète « si… alors » dans une conversation, le Coach la propose ici pour confirmation.",
|
||||
"groupProposed": "Propositions",
|
||||
"groupActive": "Actifs",
|
||||
|
||||
+170
-12
@@ -455,6 +455,9 @@
|
||||
"scoreRingsTitle": "Anelli dei punteggi",
|
||||
"scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza. Trascina per riordinare; il punteggio di salute è primo per impostazione predefinita.",
|
||||
"layoutReset": "Ripristina i valori predefiniti",
|
||||
"layoutResetTitle": "Reimpostare il layout della dashboard?",
|
||||
"layoutResetBody": "L’ordine dei riquadri, quelli che hai nascosto e gli anelli scelti tornano ai valori predefiniti. La disposizione non è salvata altrove.",
|
||||
"layoutResetConfirm": "Reimposta layout",
|
||||
"layoutSaveSuccess": "Layout della dashboard salvato",
|
||||
"layoutSaveError": "Impossibile salvare il layout",
|
||||
"layoutConflictReloaded": "Layout ricaricato dal server. Applica di nuovo la tua modifica.",
|
||||
@@ -911,9 +914,6 @@
|
||||
"importFileLoaded": "File \"{name}\" caricato",
|
||||
"importInvalidJson": "Il file non contiene un JSON valido",
|
||||
"importNoArray": "Nessun array trovato",
|
||||
"importResult": "{imported} assunzioni importate",
|
||||
"importDuplicatesSkipped": "{count} duplicati ignorati",
|
||||
"importInvalidSkipped": "{count} voci non valide ignorate",
|
||||
"importFailed": "Importazione non riuscita",
|
||||
"importInvalidFormat": "Formato JSON non valido",
|
||||
"requestExample": "Esempio di richiesta",
|
||||
@@ -1716,8 +1716,8 @@
|
||||
"addSuccess": "Scorta registrata.",
|
||||
"addFailed": "Impossibile registrare la scorta.",
|
||||
"adjustButton": "Correggi",
|
||||
"adjustTitle": "Correggi la scorta",
|
||||
"adjustDescription": "Imposta le unità rimanenti — per correzioni o un prelievo. 0 segna la scorta come esaurita.",
|
||||
"adjustTitle": "Correggi il contenitore",
|
||||
"adjustDescription": "Imposta le unità rimanenti e correggi le date di questo contenitore. 0 unità lo segna come esaurito.",
|
||||
"adjustQuantityLabel": "Unità rimanenti",
|
||||
"adjustHelper": "Capacità: {total} unità.",
|
||||
"adjustSubmit": "Salva",
|
||||
@@ -1757,7 +1757,15 @@
|
||||
"packagingFailed": "Salvataggio non riuscito",
|
||||
"addManufacturerLabel": "Produttore",
|
||||
"addDoseStrengthLabel": "Dosaggio",
|
||||
"addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml."
|
||||
"addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml.",
|
||||
"openedOn": "Aperto il {date}",
|
||||
"expiresOn": "Scade il {date}",
|
||||
"summaryAllExpired": "Nessuna scorta utilizzabile: {units} unità contrassegnate come scadute",
|
||||
"editExpiryLabel": "Data di scadenza stampata",
|
||||
"editExpiryHelper": "La data stampata sulla confezione. Lascia vuoto se non ne è registrata nessuna.",
|
||||
"editOpenedLabel": "Aperto il",
|
||||
"editOpenedHelper": "Lascia vuoto se il contenitore è ancora sigillato.",
|
||||
"editOpenedHelperClock": "Una penna o una fiala scade 30 giorni dopo l'apertura. Lascia vuoto se è ancora sigillata."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1909,6 +1917,32 @@
|
||||
"scaleAbsolute": "Assoluto",
|
||||
"scalePercentSinceStart": "% dall'inizio",
|
||||
"scaleToggleLabel": "Scala del grafico"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} assunzioni importate.",
|
||||
"resultPartial": "{imported} importate, {skipped} ignorate.",
|
||||
"resultNothing": "Non è stato importato nulla: tutte le {skipped} voci sono state ignorate.",
|
||||
"resultEmpty": "Questo file non conteneva assunzioni.",
|
||||
"skipGroupOne": "{count} voce ignorata: {reason}",
|
||||
"skipGroupFew": "{count} voci ignorate: {reason}",
|
||||
"skipGroupOther": "{count} voci ignorate: {reason}",
|
||||
"reasonDuplicateInFile": "un’altra voce dello stesso file ha la stessa data e la stessa ora",
|
||||
"reasonAlreadyRecorded": "per quella data e ora è già registrata un’assunzione",
|
||||
"reasonUnknown": "motivo non registrato",
|
||||
"reasonMedicationNotFound": "nessun farmaco registrato corrisponde al nome nel file",
|
||||
"reasonMedicationAmbiguous": "il nome nel file corrisponde a più di un farmaco",
|
||||
"reasonMedicationIsMirrored": "il farmaco è una copia in sola lettura da un'altra app",
|
||||
"reasonStatusUnknown": "lo stato nel file non è uno di quelli definiti dal formato",
|
||||
"reasonMissingTimestamp": "la riga non indica alcun orario",
|
||||
"reasonMissingOffset": "l'orario non porta un offset UTC, quindi l'istante è ignoto",
|
||||
"reasonUnreadableTimestamp": "l'orario non è nel formato documentato dall'esportazione",
|
||||
"reasonImplausibleTimestamp": "l'orario non indica un istante reale, o è nel futuro",
|
||||
"reasonMissingMedication": "la riga non nomina alcun farmaco",
|
||||
"reasonUnreadableDosage": "il dosaggio non è un numero",
|
||||
"reasonUnreadableRow": "la riga ha meno colonne dell'intestazione",
|
||||
"reasonStatusNoDoseInformation": "il file non registra nulla su quella dose: nessuno ha risposto e niente è stato annotato",
|
||||
"reasonStatusReminderEvent": "la riga riguarda un promemoria postposto, che non dice nulla sulla dose",
|
||||
"reasonStatusNotificationNotSent": "la riga registra che non è stata inviata alcuna notifica, il che riguarda l'app e non la dose"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1992,6 +2026,14 @@
|
||||
"loadErrorTitle": "Impossibile caricare il grafico",
|
||||
"loadErrorReload": "Ricarica",
|
||||
"sparseDataCaption": "Più giorni completeranno il trend.",
|
||||
"dataTable": {
|
||||
"toggle": "Punti dati ({count})",
|
||||
"columnDate": "Data",
|
||||
"noValue": "Nessun valore",
|
||||
"captionDay": "Una riga per giorno di {metric}: gli stessi punti disegnati dal grafico.",
|
||||
"captionWeek": "Una riga per settimana di {metric}, la media dei giorni di quella settimana: gli stessi punti disegnati dal grafico.",
|
||||
"captionMonth": "Una riga per mese di {metric}, la media dei giorni di quel mese: gli stessi punti disegnati dal grafico."
|
||||
},
|
||||
"personalBaseline": "La tua media",
|
||||
"deltaVsBaseline": "{delta} vs. la tua media",
|
||||
"deltaUnchanged": "corrisponde alla tua media",
|
||||
@@ -2120,6 +2162,9 @@
|
||||
"editMode": {
|
||||
"done": "Fine",
|
||||
"reset": "Reimposta",
|
||||
"resetTitle": "Reimpostare la panoramica?",
|
||||
"resetBody": "L’ordine delle sezioni e quelle che hai nascosto tornano ai valori predefiniti. La tua disposizione non è salvata altrove.",
|
||||
"resetConfirm": "Reimposta panoramica",
|
||||
"title": "Personalizza panoramica",
|
||||
"description": "Trascina per riordinare, tocca l’occhio per mostrare o nascondere.",
|
||||
"dragHandle": "Trascina per riordinare",
|
||||
@@ -3395,9 +3440,13 @@
|
||||
"metricActiveEnergy": "Energia attiva",
|
||||
"metricSleepDuration": "Sonno",
|
||||
"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."
|
||||
},
|
||||
@@ -3965,6 +4014,12 @@
|
||||
"overrideToggleLabel": "Valori personali",
|
||||
"resetAction": "Ripristina predefinito",
|
||||
"resetAllAction": "Ripristina tutti ai predefiniti",
|
||||
"resetAllTitle": "Reimpostare tutti gli intervalli personalizzati?",
|
||||
"resetAllBody": "Ogni metrica torna al suo intervallo predefinito. I valori impostati non sono salvati altrove e non possono essere recuperati.",
|
||||
"resetAllConfirm": "Reimposta tutto",
|
||||
"resetTitle": "Reimpostare questo intervallo?",
|
||||
"resetBody": "La metrica torna al suo intervallo predefinito. I valori impostati non sono salvati altrove e non possono essere recuperati.",
|
||||
"resetConfirm": "Reimposta intervallo",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Max",
|
||||
"unitSuffix": "({unit})",
|
||||
@@ -4122,6 +4177,7 @@
|
||||
"genderNone": "Preferisco non specificare",
|
||||
"genderMale": "Maschile",
|
||||
"genderFemale": "Femminile",
|
||||
"genderOther": "Altro",
|
||||
"dateOfBirthLabel": "Data di nascita",
|
||||
"dateOfBirthHint": "Usata solo per le analisi che tengono conto dell'età.",
|
||||
"saveCta": "Salva e continua"
|
||||
@@ -4332,6 +4388,9 @@
|
||||
"cardTitle": "Priorità delle fonti per metrica",
|
||||
"help": "Se più fonti forniscono lo stesso valore nello stesso giorno, la fonte in cima conta come canonica. Le altre restano memorizzate per la traccia di controllo.",
|
||||
"resetDefaults": "Ripristina i valori predefiniti",
|
||||
"resetDefaultsTitle": "Reimpostare l’ordine delle fonti?",
|
||||
"resetDefaultsBody": "Ogni gerarchia che hai impostato torna all’ordine predefinito, per tutte le metriche e tutti i tipi di dispositivo insieme. Viene salvato subito e non può essere recuperato.",
|
||||
"resetDefaultsConfirm": "Reimposta ordine",
|
||||
"moveUp": "Sposta su",
|
||||
"moveDown": "Sposta giù",
|
||||
"saveSuccess": "Priorità delle fonti salvata.",
|
||||
@@ -4538,6 +4597,60 @@
|
||||
"reasonUnstableExternalId": "l'ID esterno non è stabile tra un export e l'altro",
|
||||
"reasonDuplicate": "già importata",
|
||||
"reasonUnknown": "saltata"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Storico delle assunzioni",
|
||||
"description": "Uno storico di assunzioni esportato da un'altra app di monitoraggio, letto così com'è. Un file copre tutti i farmaci che contiene.",
|
||||
"pasteLabel": "Incolla l'esportazione",
|
||||
"fileInputLabel": "Scegli un file con lo storico delle assunzioni",
|
||||
"uploadFile": "Scegli file",
|
||||
"preview": "Anteprima",
|
||||
"import": "Importa",
|
||||
"empty": "Prima incolla l'esportazione o scegli un file.",
|
||||
"readFailed": "Non è stato possibile leggere quel file.",
|
||||
"rateLimited": "Massimo 5 importazioni all'ora. Riprova più tardi.",
|
||||
"tooLarge": "Quel file supera il limite di 16 MB.",
|
||||
"inProgress": "Un'importazione dello storico è già in corso. Attendi che finisca.",
|
||||
"workerUnavailable": "Il processo in background non è disponibile, quindi non è stato importato nulla.",
|
||||
"failed": "L'importazione è fallita. Non è stato scritto nulla.",
|
||||
"columnsHeading": "Cosa fa ogni colonna",
|
||||
"jsonOnly": "solo JSON",
|
||||
"previewLine": "Righe lette: {rows} · da importare: {queued} · rifiutate: {refused}",
|
||||
"submittedLine": "Righe lette: {rows} · in coda per l'importazione: {queued}",
|
||||
"unmatched": "Nessun farmaco registrato corrisponde a: {names}",
|
||||
"ambiguous": "Corrisponde a più di un farmaco: {names}",
|
||||
"mirrored": "Copiato da un'altra app, quindi non si può aggiungere alcuna dose: {names}",
|
||||
"unknownColumns": "Colonne senza regola nell'importatore, non lette: {names}",
|
||||
"codingsNotRead": "Righe con un valore Codings non letto: {count}",
|
||||
"archivedRows": "Righe di un farmaco archiviato nell'app di origine, importate comunque: {count}",
|
||||
"verdict": {
|
||||
"honoured": "usata",
|
||||
"reported": "segnalata",
|
||||
"ignored": "non letta"
|
||||
},
|
||||
"column": {
|
||||
"date": "L'ora in cui la dose è stata effettivamente assunta. Il suo offset UTC viene letto esattamente come è scritto.",
|
||||
"scheduledDate": "La fascia a cui la dose apparteneva, tenuta distinta dall'ora di assunzione. Vuoto significa che non apparteneva a nessuna fascia.",
|
||||
"medication": "Abbinato a un farmaco già presente nella tua scheda. Dal file non viene creato nulla.",
|
||||
"nickname": "Provato come secondo nome quando il primo non corrisponde a niente.",
|
||||
"dosage": "Registrato sulla dose solo quando differisce dalla quantità prevista.",
|
||||
"scheduledDosage": "Confrontato con la quantità effettiva per stabilire se quella dose si è discostata. Mai salvato da solo.",
|
||||
"unit": "Scritto con la quantità, come lo scrive il file. \"count\" indica multipli della dose configurata.",
|
||||
"status": "\"Taken\" diventa una dose e \"Skipped\" un'omissione deliberata. Qualsiasi altro valore non registra alcuna decisione e la riga viene rifiutata.",
|
||||
"archived": "Contato e segnalato. Una dose che è avvenuta non si annulla archiviando il farmaco dopo.",
|
||||
"codings": "Il CSV non documenta alcuna codifica per questa colonna, quindi viene lasciata stare invece di essere indovinata. Le celle piene vengono contate.",
|
||||
"form": "Descrive il farmaco, non la dose.",
|
||||
"start": "La data in cui hai iniziato il farmaco, non l'ora di questa dose.",
|
||||
"end": "La data in cui hai smesso il farmaco, non l'ora di questa dose."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "Quel file non contiene righe.",
|
||||
"missingColumns": "A quel file manca una colonna che serve all'importatore: Date, Medication o Status.",
|
||||
"unreadableJson": "Quel file non è JSON valido.",
|
||||
"jsonNotAnArray": "Quel JSON non contiene alcun elenco di dosi.",
|
||||
"jsonNoIntakeTime": "L'esportazione JSON registra quando una dose era prevista, non quando è stata assunta, e un orario previsto non verrà scritto come se fosse l'assunzione. Esporta il CSV, che porta entrambi.",
|
||||
"tooManyRows": "Quel file contiene più righe di quante un'importazione possa accoglierne. Dividilo e importa le parti."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Esporta i tuoi dati o importa da un backup."
|
||||
@@ -4661,7 +4774,10 @@
|
||||
"description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni. L'inizio è impostato in modo predefinito sulla data di validità della tua residenza; per andare più indietro, aggiungi prima un periodo di posizione per quell'intervallo.",
|
||||
"run": "Recupera"
|
||||
},
|
||||
"noResults": "Nessun luogo corrispondente trovato."
|
||||
"noResults": "Nessun luogo corrispondente trovato.",
|
||||
"fetchFailedOne": "L’ultimo aggiornamento meteo in background non è riuscito ({when}).",
|
||||
"fetchFailedFew": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}.",
|
||||
"fetchFailedOther": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -4706,6 +4822,9 @@
|
||||
"upload": "Carica foto",
|
||||
"replace": "Sostituisci foto",
|
||||
"remove": "Rimuovi",
|
||||
"removeTitle": "Rimuovere la foto del profilo?",
|
||||
"removeBody": "L’immagine viene eliminata dal tuo server e al suo posto compaiono le tue iniziali. Per caricarla di nuovo ti servirà il file originale.",
|
||||
"removeConfirm": "Rimuovi foto",
|
||||
"hint": "JPEG, PNG o WebP. Fino a 2 MB e 2048×2048 pixel.",
|
||||
"uploaded": "Foto del profilo aggiornata",
|
||||
"removed": "Foto del profilo rimossa",
|
||||
@@ -4865,6 +4984,9 @@
|
||||
"statusExpired": "Connessione scaduta",
|
||||
"connectButton": "Connetti con ChatGPT",
|
||||
"disconnectButton": "Disconnetti",
|
||||
"disconnectTitle": "Disconnettere ChatGPT?",
|
||||
"disconnectBody": "L’accesso salvato viene eliminato insieme al testo degli approfondimenti in cache. Per riconnetterti dovrai ripetere il flusso con codice dispositivo.",
|
||||
"disconnectConfirm": "Disconnetti",
|
||||
"modelSlugLabel": "Slug del modello",
|
||||
"modelSlugBody": "Codex usa il modello a cui instrada il tuo abbonamento ChatGPT. La variabile d’ambiente CODEX_MODEL consente all’operatore di sovrascriverlo sull’istanza.",
|
||||
"lastInsight": "Ultima analisi: {when}"
|
||||
@@ -4895,6 +5017,9 @@
|
||||
"categoryContext": "Contesto",
|
||||
"forget": "Dimentica",
|
||||
"forgetAria": "Dimentica questa informazione",
|
||||
"forgetTitle": "Dimenticare questa informazione?",
|
||||
"forgetBody": "Il Coach smette di usarla dal messaggio successivo. Un’informazione dimenticata non può essere recuperata.",
|
||||
"forgetConfirm": "Dimentica",
|
||||
"forgetAll": "Dimentica tutto",
|
||||
"forgetAllConfirmTitle": "Dimenticare tutto ciò che il Coach ricorda?",
|
||||
"forgetAllConfirmBody": "Questo rimuove tutte le informazioni memorizzate. Il Coach ripartirà da zero alla prossima chat. L'operazione non può essere annullata.",
|
||||
@@ -4938,6 +5063,9 @@
|
||||
"placeholder": "Es. lavoro a turni, dieta vegetariana, infortunio al ginocchio, allenamento per una mezza maratona…",
|
||||
"charCount": "{used} / {max} caratteri",
|
||||
"clear": "Svuota",
|
||||
"clearTitle": "Svuotare la nota «Su di me»?",
|
||||
"clearBody": "Rimuove solo il testo libero. Allergie e intolleranze restano invariate.",
|
||||
"clearConfirm": "Svuota nota",
|
||||
"save": "Salva",
|
||||
"savedToast": "Nota «Su di me» salvata.",
|
||||
"clearedToast": "Nota «Su di me» eliminata.",
|
||||
@@ -5155,7 +5283,7 @@
|
||||
"integrationPill": {
|
||||
"connected": "Connesso",
|
||||
"errorReconnect": "Errore — riconnetti",
|
||||
"warningServerError": "Connesso, problema di sincronizzazione",
|
||||
"warningServerError": "Sincronizzazione in errore",
|
||||
"parkedReconnect": "In pausa — riconnettere manualmente",
|
||||
"notConnected": "Non connesso",
|
||||
"justNow": "proprio ora",
|
||||
@@ -5217,10 +5345,10 @@
|
||||
"apiEndpointAuth": "Autenticazione",
|
||||
"apiEndpointExample": "Esempio di body",
|
||||
"dangerZone": "Elimina tutti i dati",
|
||||
"dangerZoneDescription": "Elimina tutti i tuoi dati sanitari e le integrazioni. Il tuo account utente verrà conservato.",
|
||||
"dangerZoneDescription": "Cancella l’intera cartella sanitaria e ogni connessione di integrazione. Il tuo account e le credenziali con cui accedi restano.",
|
||||
"dangerZoneConfirm": "Eliminare tutti i dati?",
|
||||
"dangerZoneConfirmDescription": "Questa azione eliminerà definitivamente tutti i tuoi dati sanitari e le integrazioni. Il tuo account utente verrà conservato.",
|
||||
"dangerZoneSuccess": "Tutti i dati personali sono stati eliminati",
|
||||
"dangerZoneConfirmDescription": "Questa azione elimina definitivamente tutto ciò che contiene la tua cartella: misurazioni, farmaci, voci dell’umore, risultati di laboratorio, voci del ciclo, documenti, conversazioni con l’IA e ogni connessione di integrazione, insieme a tutto ciò che ne deriva e a tutto quello che avevi eliminato prima. I link di condivisione consegnati a uno studio medico smettono subito di funzionare. Il tuo account resta, insieme alle credenziali con cui accedi e alle impostazioni di visualizzazione come lingua, fuso orario e unità. Non può essere annullato.",
|
||||
"dangerZoneSuccess": "La tua cartella sanitaria è stata eliminata. Account e accesso restano invariati.",
|
||||
"dangerZoneDeleteFailed": "Eliminazione non riuscita",
|
||||
"finalDelete": "Elimina definitivamente",
|
||||
"deleteAccountCardTitle": "Elimina completamente l’account",
|
||||
@@ -5763,6 +5891,9 @@
|
||||
"baseUrlLabel": "URL di base",
|
||||
"save": "Salva",
|
||||
"remove": "Rimuovi chiave",
|
||||
"removeTitle": "Rimuovere la chiave del server?",
|
||||
"removeBody": "Gli account senza una chiave propria perdono subito le funzioni IA. Dopo il salvataggio la chiave non è più visibile da nessuna parte: per rimetterla ti servirà l’originale.",
|
||||
"removeConfirm": "Rimuovi chiave",
|
||||
"saved": "Impostazioni della chiave IA del server salvate",
|
||||
"error": "Salvataggio della chiave IA del server non riuscito"
|
||||
},
|
||||
@@ -6285,10 +6416,23 @@
|
||||
"statusExpired": "Scaduto",
|
||||
"connectButton": "Collega un account ChatGPT",
|
||||
"disconnectButton": "Disconnetti",
|
||||
"disconnectTitle": "Disconnettere l’account IA condiviso?",
|
||||
"disconnectBody": "Tutti gli utenti di questo server che usano la connessione condivisa perdono le funzioni IA finché qualcuno non collega di nuovo un account. L’accesso salvato non è recuperabile.",
|
||||
"disconnectConfirm": "Disconnetti",
|
||||
"connectedSince": "Connesso dal {when}",
|
||||
"connected": "Connessione IA condivisa collegata",
|
||||
"disconnected": "Connessione IA condivisa rimossa",
|
||||
"error": "Qualcosa è andato storto — riprova"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Processi in background",
|
||||
"description": "Code i cui processi hanno rinunciato dopo aver esaurito i tentativi.",
|
||||
"none": "Nessun processo è fallito nelle ultime {hours} ore.",
|
||||
"unavailable": "Nessuna coda da leggere: questo deployment funziona senza il worker in background.",
|
||||
"countOne": "{count} errore",
|
||||
"countFew": "{count} errori",
|
||||
"countOther": "{count} errori",
|
||||
"lastFailed": "Ultimo errore {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -6751,6 +6895,10 @@
|
||||
"diastolicMax": "Diastolica max",
|
||||
"boundsHint": "Range consentito: {min}–{max} {unit}",
|
||||
"resetToDefault": "Ripristina predefinito",
|
||||
"resetTitle": "Reimpostare questo obiettivo?",
|
||||
"resetBody": "L’intervallo torna al valore predefinito. I valori impostati non sono salvati altrove e non possono essere recuperati.",
|
||||
"resetBodyBp": "Sia l’intervallo sistolico sia quello diastolico tornano al valore predefinito. I valori impostati non sono salvati altrove e non possono essere recuperati.",
|
||||
"resetConfirm": "Reimposta obiettivo",
|
||||
"saveSuccess": "Range obiettivo aggiornato",
|
||||
"saveError": "Impossibile salvare il range obiettivo",
|
||||
"resetSuccess": "Range obiettivo ripristinato",
|
||||
@@ -6880,7 +7028,8 @@
|
||||
"title": "Scorte in esaurimento: {medName}",
|
||||
"bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta.",
|
||||
"bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn} — riordina entro il {reorderBy}.",
|
||||
"bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}."
|
||||
"bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}.",
|
||||
"bodyExpired": "{medName}: nessuna scorta utilizzabile. {units} unità sono contrassegnate come scadute."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Registra il tuo umore",
|
||||
@@ -6983,6 +7132,9 @@
|
||||
"icon": "Icona",
|
||||
"limitReached": "Limite di sintomi personalizzati raggiunto.",
|
||||
"remove": "Rimuovi {label}",
|
||||
"removeTitle": "Rimuovere questo sintomo?",
|
||||
"removeBody": "«{label}» sparisce dall’elenco dei sintomi. I giorni già contrassegnati lo mantengono, ma non potrai più selezionarlo.",
|
||||
"removeConfirm": "Rimuovi",
|
||||
"fallbackLabel": "Sintomo personalizzato"
|
||||
}
|
||||
},
|
||||
@@ -7215,6 +7367,9 @@
|
||||
"severity": "Intensità",
|
||||
"severityLevel": "Intensità {level} su 4",
|
||||
"delete": "Elimina",
|
||||
"deleteTitle": "Eliminare questo giorno?",
|
||||
"deleteBody": "Sparisce tutto ciò che hai registrato per questo giorno: flusso, temperatura, sintomi e la tua nota. Non può essere recuperato.",
|
||||
"deleteConfirm": "Elimina giorno",
|
||||
"temperatureSection": "Temperatura e ovulazione",
|
||||
"intimacySection": "Intimità e contraccezione",
|
||||
"testsSection": "Test",
|
||||
@@ -8008,6 +8163,9 @@
|
||||
"markMet": "Obiettivo raggiunto",
|
||||
"abandon": "Termina piano",
|
||||
"remove": "Rimuovi",
|
||||
"removeTitle": "Rimuovere questo piano?",
|
||||
"removeBody": "Il piano e il suo andamento spariscono dalla cronologia. Non può essere recuperato.",
|
||||
"removeConfirm": "Rimuovi piano",
|
||||
"empty": "Ancora nessun piano. Quando in una conversazione ti impegni in un'intenzione concreta «se… allora», il Coach la propone qui per la conferma.",
|
||||
"groupProposed": "Proposte",
|
||||
"groupActive": "Attivi",
|
||||
|
||||
+170
-12
@@ -455,6 +455,9 @@
|
||||
"scoreRingsTitle": "Pierścienie wyników",
|
||||
"scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń. Przeciągnij, aby zmienić kolejność; wskaźnik zdrowia jest domyślnie na początku.",
|
||||
"layoutReset": "Przywróć wartości domyślne",
|
||||
"layoutResetTitle": "Zresetować układ pulpitu?",
|
||||
"layoutResetBody": "Kolejność kafelków, ukryte kafelki i wybrane pierścienie wracają do ustawień domyślnych. Ten układ nie jest nigdzie indziej zapisany.",
|
||||
"layoutResetConfirm": "Zresetuj układ",
|
||||
"layoutSaveSuccess": "Układ panelu zapisany",
|
||||
"layoutSaveError": "Nie udało się zapisać układu",
|
||||
"layoutConflictReloaded": "Układ został ponownie wczytany z serwera. Wprowadź zmianę jeszcze raz.",
|
||||
@@ -911,9 +914,6 @@
|
||||
"importFileLoaded": "Plik „{name}” wczytany",
|
||||
"importInvalidJson": "Plik nie zawiera prawidłowego JSON",
|
||||
"importNoArray": "Nie znaleziono tablicy",
|
||||
"importResult": "{imported} przyjęć zaimportowano",
|
||||
"importDuplicatesSkipped": "{count} duplikatów pominięto",
|
||||
"importInvalidSkipped": "{count} nieprawidłowych wpisów pominięto",
|
||||
"importFailed": "Import nie powiódł się",
|
||||
"importInvalidFormat": "Nieprawidłowy format JSON",
|
||||
"requestExample": "Przykład żądania",
|
||||
@@ -1716,8 +1716,8 @@
|
||||
"addSuccess": "Zapas zarejestrowany.",
|
||||
"addFailed": "Nie udało się zarejestrować zapasu.",
|
||||
"adjustButton": "Skoryguj",
|
||||
"adjustTitle": "Skoryguj zapas",
|
||||
"adjustDescription": "Ustaw pozostałe jednostki — przy korekcie lub pobraniu. 0 oznacza zapas jako zużyty.",
|
||||
"adjustTitle": "Popraw opakowanie",
|
||||
"adjustDescription": "Ustaw pozostałe jednostki i popraw daty tego opakowania. 0 jednostek oznacza je jako zużyte.",
|
||||
"adjustQuantityLabel": "Pozostałe jednostki",
|
||||
"adjustHelper": "Pojemność: {total} jednostek.",
|
||||
"adjustSubmit": "Zapisz",
|
||||
@@ -1757,7 +1757,15 @@
|
||||
"packagingFailed": "Zapis nie powiódł się",
|
||||
"addManufacturerLabel": "Producent",
|
||||
"addDoseStrengthLabel": "Moc",
|
||||
"addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml."
|
||||
"addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml.",
|
||||
"openedOn": "Otwarto {date}",
|
||||
"expiresOn": "Ważne do {date}",
|
||||
"summaryAllExpired": "Brak zapasu do użycia: {units} jednostek oznaczono jako przeterminowane",
|
||||
"editExpiryLabel": "Data ważności z opakowania",
|
||||
"editExpiryHelper": "Data nadrukowana na opakowaniu. Zostaw puste, jeśli nie została zapisana.",
|
||||
"editOpenedLabel": "Data otwarcia",
|
||||
"editOpenedHelper": "Zostaw puste, jeśli opakowanie jest jeszcze zamknięte.",
|
||||
"editOpenedHelperClock": "Pen lub ampułka traci ważność 30 dni po otwarciu. Zostaw puste, jeśli jest jeszcze zamknięta."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1909,6 +1917,32 @@
|
||||
"scaleAbsolute": "Bezwzględne",
|
||||
"scalePercentSinceStart": "% od początku",
|
||||
"scaleToggleLabel": "Skala wykresu"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "Zaimportowano przyjęcia: {imported}.",
|
||||
"resultPartial": "Zaimportowano {imported}, pominięto {skipped}.",
|
||||
"resultNothing": "Nic nie zaimportowano: pominięto wszystkie wpisy ({skipped}).",
|
||||
"resultEmpty": "Ten plik nie zawierał żadnych przyjęć.",
|
||||
"skipGroupOne": "Pominięto {count} wpis: {reason}",
|
||||
"skipGroupFew": "Pominięto {count} wpisy: {reason}",
|
||||
"skipGroupOther": "Pominięto {count} wpisów: {reason}",
|
||||
"reasonDuplicateInFile": "inny wpis w tym samym pliku ma tę samą datę i godzinę",
|
||||
"reasonAlreadyRecorded": "przyjęcie o tej dacie i godzinie jest już zapisane",
|
||||
"reasonUnknown": "powód nieodnotowany",
|
||||
"reasonMedicationNotFound": "żaden zapisany lek nie odpowiada nazwie z pliku",
|
||||
"reasonMedicationAmbiguous": "nazwa z pliku odpowiada więcej niż jednemu lekowi",
|
||||
"reasonMedicationIsMirrored": "lek jest kopią tylko do odczytu z innej aplikacji",
|
||||
"reasonStatusUnknown": "status w pliku nie należy do tych, które definiuje format",
|
||||
"reasonMissingTimestamp": "wiersz nie podaje godziny",
|
||||
"reasonMissingOffset": "godzina nie ma przesunięcia UTC, więc moment jest nieznany",
|
||||
"reasonUnreadableTimestamp": "godzina nie ma formatu opisanego w dokumentacji eksportu",
|
||||
"reasonImplausibleTimestamp": "godzina nie wskazuje rzeczywistego momentu albo leży w przyszłości",
|
||||
"reasonMissingMedication": "wiersz nie nazywa żadnego leku",
|
||||
"reasonUnreadableDosage": "dawka nie jest liczbą",
|
||||
"reasonUnreadableRow": "wiersz ma mniej kolumn niż nagłówek",
|
||||
"reasonStatusNoDoseInformation": "plik nie zapisuje nic o tej dawce — nikt nie odpowiedział i nic nie zostało zanotowane",
|
||||
"reasonStatusReminderEvent": "wiersz dotyczy odłożonego przypomnienia, co nic nie mówi o dawce",
|
||||
"reasonStatusNotificationNotSent": "wiersz zapisuje, że nie wysłano powiadomienia, co dotyczy aplikacji, a nie dawki"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1992,6 +2026,14 @@
|
||||
"loadErrorTitle": "Nie udało się załadować wykresu",
|
||||
"loadErrorReload": "Załaduj ponownie",
|
||||
"sparseDataCaption": "Więcej dni dopełni trend.",
|
||||
"dataTable": {
|
||||
"toggle": "Punkty danych ({count})",
|
||||
"columnDate": "Data",
|
||||
"noValue": "Brak wartości",
|
||||
"captionDay": "Jeden wiersz na dzień dla {metric} — dokładnie te punkty, które rysuje wykres.",
|
||||
"captionWeek": "Jeden wiersz na tydzień dla {metric}, średnia z dni tego tygodnia — dokładnie te punkty, które rysuje wykres.",
|
||||
"captionMonth": "Jeden wiersz na miesiąc dla {metric}, średnia z dni tego miesiąca — dokładnie te punkty, które rysuje wykres."
|
||||
},
|
||||
"personalBaseline": "Twoja średnia",
|
||||
"deltaVsBaseline": "{delta} vs. twoja średnia",
|
||||
"deltaUnchanged": "odpowiada twojej średniej",
|
||||
@@ -2120,6 +2162,9 @@
|
||||
"editMode": {
|
||||
"done": "Gotowe",
|
||||
"reset": "Przywróć",
|
||||
"resetTitle": "Zresetować przegląd?",
|
||||
"resetBody": "Kolejność sekcji i ukryte sekcje wracają do ustawień domyślnych. Twój układ nie jest nigdzie indziej zapisany.",
|
||||
"resetConfirm": "Zresetuj przegląd",
|
||||
"title": "Dostosuj przegląd",
|
||||
"description": "Przeciągnij, aby zmienić kolejność, dotknij oka, aby pokazać lub ukryć.",
|
||||
"dragHandle": "Przeciągnij, aby zmienić kolejność",
|
||||
@@ -3395,9 +3440,13 @@
|
||||
"metricActiveEnergy": "Aktywna energia",
|
||||
"metricSleepDuration": "Sen",
|
||||
"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."
|
||||
},
|
||||
@@ -3965,6 +4014,12 @@
|
||||
"overrideToggleLabel": "Własne wartości",
|
||||
"resetAction": "Przywróć wartość domyślną",
|
||||
"resetAllAction": "Przywróć wszystkie wartości domyślne",
|
||||
"resetAllTitle": "Zresetować wszystkie własne zakresy?",
|
||||
"resetAllBody": "Każda metryka wraca do zakresu domyślnego. Ustawione wartości nie są nigdzie indziej zapisane i nie da się ich odzyskać.",
|
||||
"resetAllConfirm": "Zresetuj wszystkie",
|
||||
"resetTitle": "Zresetować ten zakres?",
|
||||
"resetBody": "Metryka wraca do zakresu domyślnego. Ustawione wartości nie są nigdzie indziej zapisane i nie da się ich odzyskać.",
|
||||
"resetConfirm": "Zresetuj zakres",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Maks",
|
||||
"unitSuffix": "({unit})",
|
||||
@@ -4122,6 +4177,7 @@
|
||||
"genderNone": "Wolę nie podawać",
|
||||
"genderMale": "Mężczyzna",
|
||||
"genderFemale": "Kobieta",
|
||||
"genderOther": "Inna",
|
||||
"dateOfBirthLabel": "Data urodzenia",
|
||||
"dateOfBirthHint": "Używana wyłącznie do analiz zależnych od wieku.",
|
||||
"saveCta": "Zapisz i kontynuuj"
|
||||
@@ -4332,6 +4388,9 @@
|
||||
"cardTitle": "Priorytet źródeł według metryki",
|
||||
"help": "Gdy kilka źródeł dostarcza tę samą wartość tego samego dnia, źródło na górze liczy się jako kanoniczne. Pozostałe pozostają zapisane na potrzeby audytu.",
|
||||
"resetDefaults": "Przywróć wartości domyślne",
|
||||
"resetDefaultsTitle": "Zresetować kolejność źródeł?",
|
||||
"resetDefaultsBody": "Każda ustawiona przez ciebie kolejność wraca do domyślnej, dla wszystkich metryk i typów urządzeń naraz. Zapis następuje natychmiast i nie da się go cofnąć.",
|
||||
"resetDefaultsConfirm": "Zresetuj kolejność",
|
||||
"moveUp": "W górę",
|
||||
"moveDown": "W dół",
|
||||
"saveSuccess": "Zapisano priorytet źródeł.",
|
||||
@@ -4538,6 +4597,60 @@
|
||||
"reasonUnstableExternalId": "identyfikator zewnętrzny nie jest stabilny między eksportami",
|
||||
"reasonDuplicate": "już zaimportowano",
|
||||
"reasonUnknown": "pominięto"
|
||||
},
|
||||
"doseHistory": {
|
||||
"title": "Historia dawek",
|
||||
"description": "Historia dawek wyeksportowana z innej aplikacji, czytana wprost. Jeden plik obejmuje wszystkie leki, które zawiera.",
|
||||
"pasteLabel": "Wklej eksport",
|
||||
"fileInputLabel": "Wybierz plik z historią dawek",
|
||||
"uploadFile": "Wybierz plik",
|
||||
"preview": "Podgląd",
|
||||
"import": "Importuj",
|
||||
"empty": "Najpierw wklej eksport albo wybierz plik.",
|
||||
"readFailed": "Nie udało się odczytać tego pliku.",
|
||||
"rateLimited": "Maksymalnie 5 importów na godzinę. Spróbuj później.",
|
||||
"tooLarge": "Ten plik przekracza limit 16 MB.",
|
||||
"inProgress": "Import historii dawek już trwa. Poczekaj, aż się zakończy.",
|
||||
"workerUnavailable": "Proces w tle jest niedostępny, więc nic nie zostało zaimportowane.",
|
||||
"failed": "Import nie udał się. Nic nie zostało zapisane.",
|
||||
"columnsHeading": "Co się dzieje z każdą kolumną",
|
||||
"jsonOnly": "tylko JSON",
|
||||
"previewLine": "Odczytane wiersze: {rows} · do zaimportowania: {queued} · odrzucone: {refused}",
|
||||
"submittedLine": "Odczytane wiersze: {rows} · w kolejce do importu: {queued}",
|
||||
"unmatched": "Żaden zapisany lek nie odpowiada: {names}",
|
||||
"ambiguous": "Odpowiada więcej niż jednemu lekowi: {names}",
|
||||
"mirrored": "Skopiowany z innej aplikacji, więc nie można dodać dawki: {names}",
|
||||
"unknownColumns": "Kolumny bez reguły w importerze, nieodczytane: {names}",
|
||||
"codingsNotRead": "Wiersze z nieodczytaną wartością Codings: {count}",
|
||||
"archivedRows": "Wiersze leku zarchiwizowanego w aplikacji źródłowej, zaimportowane mimo to: {count}",
|
||||
"verdict": {
|
||||
"honoured": "użyta",
|
||||
"reported": "zgłoszona",
|
||||
"ignored": "nieodczytana"
|
||||
},
|
||||
"column": {
|
||||
"date": "Godzina rzeczywistego przyjęcia dawki. Jej przesunięcie UTC czytane jest dokładnie tak, jak zapisane.",
|
||||
"scheduledDate": "Okno, do którego należała dawka, trzymane osobno od godziny przyjęcia. Puste znaczy, że dawka nie należała do żadnego okna.",
|
||||
"medication": "Dopasowane do leku, który już jest w Twojej kartotece. Z pliku nic nie jest tworzone.",
|
||||
"nickname": "Próbowane jako druga nazwa, gdy pierwsza do niczego nie pasuje.",
|
||||
"dosage": "Zapisywane przy dawce tylko wtedy, gdy różni się od zaplanowanej ilości.",
|
||||
"scheduledDosage": "Porównywane z rzeczywistą ilością, by rozstrzygnąć, czy dawka odbiegła od planu. Nigdy nie zapisywane samo.",
|
||||
"unit": "Zapisywana razem z ilością, dokładnie tak, jak w pliku. „count” oznacza wielokrotności skonfigurowanej dawki.",
|
||||
"status": "„Taken” staje się dawką, a „Skipped” świadomym pominięciem. Każda inna wartość nie zapisuje żadnej decyzji i wiersz zostaje odrzucony.",
|
||||
"archived": "Liczone i zgłaszane. Dawka, która się odbyła, nie znika przez późniejsze zarchiwizowanie leku.",
|
||||
"codings": "CSV nie dokumentuje dla tej kolumny żadnego kodowania, więc zostaje nietknięta zamiast zgadywana. Wypełnione komórki są liczone.",
|
||||
"form": "Opisuje lek, nie dawkę.",
|
||||
"start": "Data rozpoczęcia leku, nie godzina tej dawki.",
|
||||
"end": "Data zakończenia leku, nie godzina tej dawki."
|
||||
},
|
||||
"fatal": {
|
||||
"emptyFile": "Ten plik nie zawiera wierszy.",
|
||||
"missingColumns": "W tym pliku brakuje kolumny, której potrzebuje importer: Date, Medication albo Status.",
|
||||
"unreadableJson": "Ten plik nie jest poprawnym JSON-em.",
|
||||
"jsonNotAnArray": "Ten JSON nie zawiera listy dawek.",
|
||||
"jsonNoIntakeTime": "Eksport JSON zapisuje, kiedy dawka była zaplanowana, a nie kiedy została przyjęta, i zaplanowana godzina nie zostanie zapisana jako godzina przyjęcia. Wyeksportuj CSV, który zawiera obie.",
|
||||
"tooManyRows": "Ten plik ma więcej wierszy, niż może przyjąć jeden import. Podziel go i zaimportuj części."
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej."
|
||||
@@ -4661,7 +4774,10 @@
|
||||
"description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji. Początek domyślnie przyjmuje datę obowiązywania Twojego domu; aby sięgnąć dalej wstecz, najpierw dodaj okres lokalizacji dla tego zakresu.",
|
||||
"run": "Uzupełnij"
|
||||
},
|
||||
"noResults": "Nie znaleziono pasujących miejsc."
|
||||
"noResults": "Nie znaleziono pasujących miejsc.",
|
||||
"fetchFailedOne": "Ostatnia aktualizacja pogody w tle nie powiodła się ({when}).",
|
||||
"fetchFailedFew": "Ostatnie {count} aktualizacje pogody w tle nie powiodły się, najnowsza {when}.",
|
||||
"fetchFailedOther": "Ostatnich {count} aktualizacji pogody w tle nie powiodło się, najnowsza {when}."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4706,6 +4822,9 @@
|
||||
"upload": "Prześlij zdjęcie",
|
||||
"replace": "Zmień zdjęcie",
|
||||
"remove": "Usuń",
|
||||
"removeTitle": "Usunąć zdjęcie profilowe?",
|
||||
"removeBody": "Obraz zostanie usunięty z twojego serwera, a jego miejsce zajmą inicjały. Do ponownego wgrania potrzebny będzie oryginalny plik.",
|
||||
"removeConfirm": "Usuń zdjęcie",
|
||||
"hint": "JPEG, PNG lub WebP. Do 2 MB i 2048×2048 pikseli.",
|
||||
"uploaded": "Zaktualizowano zdjęcie profilowe",
|
||||
"removed": "Usunięto zdjęcie profilowe",
|
||||
@@ -4865,6 +4984,9 @@
|
||||
"statusExpired": "Połączenie wygasło",
|
||||
"connectButton": "Połącz z ChatGPT",
|
||||
"disconnectButton": "Rozłącz",
|
||||
"disconnectTitle": "Odłączyć ChatGPT?",
|
||||
"disconnectBody": "Zapisane logowanie zostanie usunięte razem z zapisanym tekstem analiz. Ponowne połączenie wymaga przejścia przez kod urządzenia jeszcze raz.",
|
||||
"disconnectConfirm": "Odłącz",
|
||||
"modelSlugLabel": "Slug modelu",
|
||||
"modelSlugBody": "Codex używa modelu, do którego kieruje Twoja subskrypcja ChatGPT. Zmienna środowiskowa CODEX_MODEL pozwala operatorowi nadpisać to w instancji.",
|
||||
"lastInsight": "Ostatnia analiza: {when}"
|
||||
@@ -4895,6 +5017,9 @@
|
||||
"categoryContext": "Kontekst",
|
||||
"forget": "Zapomnij",
|
||||
"forgetAria": "Zapomnij tę informację",
|
||||
"forgetTitle": "Zapomnieć tę informację?",
|
||||
"forgetBody": "Coach przestanie z niej korzystać od następnej wiadomości. Zapomnianej informacji nie da się odzyskać.",
|
||||
"forgetConfirm": "Zapomnij",
|
||||
"forgetAll": "Zapomnij wszystko",
|
||||
"forgetAllConfirmTitle": "Zapomnieć wszystko, co Coach pamięta?",
|
||||
"forgetAllConfirmBody": "Spowoduje to usunięcie wszystkich zapisanych informacji. Coach zacznie od nowa przy następnej rozmowie. Tej operacji nie można cofnąć.",
|
||||
@@ -4938,6 +5063,9 @@
|
||||
"placeholder": "Np. praca zmianowa, dieta wegetariańska, kontuzja kolana, trening do półmaratonu…",
|
||||
"charCount": "{used} / {max} znaków",
|
||||
"clear": "Wyczyść",
|
||||
"clearTitle": "Wyczyścić notatkę „O mnie”?",
|
||||
"clearBody": "Usuwa tylko tekst notatki. Alergie i nietolerancje pozostają bez zmian.",
|
||||
"clearConfirm": "Wyczyść notatkę",
|
||||
"save": "Zapisz",
|
||||
"savedToast": "Notatka „O mnie” zapisana.",
|
||||
"clearedToast": "Notatka „O mnie” usunięta.",
|
||||
@@ -5155,7 +5283,7 @@
|
||||
"integrationPill": {
|
||||
"connected": "Połączono",
|
||||
"errorReconnect": "Błąd — połącz ponownie",
|
||||
"warningServerError": "Połączono, problem z synchronizacją",
|
||||
"warningServerError": "Synchronizacja nie działa",
|
||||
"parkedReconnect": "Wstrzymane — połącz ręcznie",
|
||||
"notConnected": "Nie połączono",
|
||||
"justNow": "przed chwilą",
|
||||
@@ -5217,10 +5345,10 @@
|
||||
"apiEndpointAuth": "Uwierzytelnianie",
|
||||
"apiEndpointExample": "Przykład treści",
|
||||
"dangerZone": "Usuń wszystkie dane",
|
||||
"dangerZoneDescription": "Usuwa wszystkie Twoje dane zdrowotne i integracje. Twoje konto użytkownika zostanie zachowane.",
|
||||
"dangerZoneDescription": "Kasuje całą Twoją dokumentację zdrowotną i wszystkie połączenia integracji. Konto i dane logowania zostają zachowane.",
|
||||
"dangerZoneConfirm": "Usunąć wszystkie dane?",
|
||||
"dangerZoneConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich Twoich danych zdrowotnych i integracji. Twoje konto użytkownika zostanie zachowane.",
|
||||
"dangerZoneSuccess": "Wszystkie dane osobowe zostały usunięte",
|
||||
"dangerZoneConfirmDescription": "Ta czynność trwale usuwa wszystko z Twojej dokumentacji: pomiary, leki, wpisy nastroju, wyniki badań, wpisy cyklu, dokumenty, rozmowy z SI oraz wszystkie połączenia integracji, a wraz z nimi wszystko, co z nich wyliczono, i wszystko, co usunięto wcześniej. Linki udostępnione poradni natychmiast przestają działać. Konto zostaje, razem z danymi logowania i ustawieniami wyświetlania, takimi jak język, strefa czasowa i jednostki. Nie można tego cofnąć.",
|
||||
"dangerZoneSuccess": "Twoja dokumentacja zdrowotna została usunięta. Konto i logowanie pozostają bez zmian.",
|
||||
"dangerZoneDeleteFailed": "Usuwanie nie powiodło się",
|
||||
"finalDelete": "Usuń trwale",
|
||||
"deleteAccountCardTitle": "Usuń całe konto",
|
||||
@@ -5763,6 +5891,9 @@
|
||||
"baseUrlLabel": "Adres bazowy URL",
|
||||
"save": "Zapisz",
|
||||
"remove": "Usuń klucz",
|
||||
"removeTitle": "Usunąć klucz serwera?",
|
||||
"removeBody": "Konta bez własnego klucza natychmiast tracą funkcje AI. Po zapisaniu klucz nie jest nigdzie pokazywany, więc do przywrócenia potrzebny będzie oryginał.",
|
||||
"removeConfirm": "Usuń klucz",
|
||||
"saved": "Zapisano ustawienia serwerowego klucza AI",
|
||||
"error": "Nie udało się zapisać serwerowego klucza AI"
|
||||
},
|
||||
@@ -6285,10 +6416,23 @@
|
||||
"statusExpired": "Wygasło",
|
||||
"connectButton": "Podłącz konto ChatGPT",
|
||||
"disconnectButton": "Rozłącz",
|
||||
"disconnectTitle": "Odłączyć wspólne konto AI?",
|
||||
"disconnectBody": "Wszyscy użytkownicy tego serwera korzystający ze wspólnego połączenia stracą funkcje AI, dopóki ktoś nie podłączy konta ponownie. Zapisanego logowania nie da się odzyskać.",
|
||||
"disconnectConfirm": "Odłącz",
|
||||
"connectedSince": "Połączono od {when}",
|
||||
"connected": "Wspólne połączenie AI podłączone",
|
||||
"disconnected": "Wspólne połączenie AI usunięte",
|
||||
"error": "Coś poszło nie tak — spróbuj ponownie"
|
||||
},
|
||||
"failingJobs": {
|
||||
"title": "Zadania w tle",
|
||||
"description": "Kolejki, których zadania poddały się po wyczerpaniu ponowień.",
|
||||
"none": "W ciągu ostatnich {hours} godzin żadne zadanie nie zawiodło.",
|
||||
"unavailable": "Brak kolejki zadań do odczytu — to wdrożenie działa bez workera w tle.",
|
||||
"countOne": "{count} niepowodzenie",
|
||||
"countFew": "{count} niepowodzenia",
|
||||
"countOther": "{count} niepowodzeń",
|
||||
"lastFailed": "Ostatnie niepowodzenie {when}"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -6751,6 +6895,10 @@
|
||||
"diastolicMax": "Rozkurczowe maks",
|
||||
"boundsHint": "Dozwolony zakres: {min}–{max} {unit}",
|
||||
"resetToDefault": "Przywróć wartość domyślną",
|
||||
"resetTitle": "Zresetować ten cel?",
|
||||
"resetBody": "Zakres wraca do wartości domyślnej. Ustawione wartości nie są nigdzie indziej zapisane i nie da się ich odzyskać.",
|
||||
"resetBodyBp": "Zarówno zakres skurczowy, jak i rozkurczowy wracają do wartości domyślnej. Ustawione wartości nie są nigdzie indziej zapisane i nie da się ich odzyskać.",
|
||||
"resetConfirm": "Zresetuj cel",
|
||||
"saveSuccess": "Zakres docelowy zaktualizowany",
|
||||
"saveError": "Nie udało się zapisać zakresu docelowego",
|
||||
"resetSuccess": "Zakres docelowy przywrócono do domyślnego",
|
||||
@@ -6880,7 +7028,8 @@
|
||||
"title": "Niski zapas: {medName}",
|
||||
"bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu.",
|
||||
"bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn} — zamów ponownie do {reorderBy}.",
|
||||
"bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}."
|
||||
"bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}.",
|
||||
"bodyExpired": "{medName}: brak zapasu do użycia. {units} jednostek oznaczono jako przeterminowane."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Zapisz nastrój",
|
||||
@@ -6983,6 +7132,9 @@
|
||||
"icon": "Ikona",
|
||||
"limitReached": "Osiągnięto limit własnych objawów.",
|
||||
"remove": "Usuń {label}",
|
||||
"removeTitle": "Usunąć ten objaw?",
|
||||
"removeBody": "„{label}” zniknie z listy objawów. Dni już nim oznaczone zachowają go, ale nie wybierzesz go ponownie.",
|
||||
"removeConfirm": "Usuń",
|
||||
"fallbackLabel": "Objaw własny"
|
||||
}
|
||||
},
|
||||
@@ -7215,6 +7367,9 @@
|
||||
"severity": "Nasilenie",
|
||||
"severityLevel": "Nasilenie {level} z 4",
|
||||
"delete": "Usuń",
|
||||
"deleteTitle": "Usunąć ten dzień?",
|
||||
"deleteBody": "Zniknie wszystko, co zapisano tego dnia: krwawienie, temperatura, objawy i twoja notatka. Nie da się tego przywrócić.",
|
||||
"deleteConfirm": "Usuń dzień",
|
||||
"temperatureSection": "Temperatura i owulacja",
|
||||
"intimacySection": "Intymność i antykoncepcja",
|
||||
"testsSection": "Testy",
|
||||
@@ -8008,6 +8163,9 @@
|
||||
"markMet": "Cel osiągnięty",
|
||||
"abandon": "Zakończ plan",
|
||||
"remove": "Usuń",
|
||||
"removeTitle": "Usunąć ten plan?",
|
||||
"removeBody": "Plan i jego przebieg znikną z historii. Nie da się go przywrócić.",
|
||||
"removeConfirm": "Usuń plan",
|
||||
"empty": "Nie ma jeszcze planów. Gdy w rozmowie zobowiążesz się do konkretnej intencji „jeśli… to”, Coach zaproponuje ją tutaj do potwierdzenia.",
|
||||
"groupProposed": "Propozycje",
|
||||
"groupActive": "Aktywne",
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.32.39",
|
||||
"version": "1.33.0",
|
||||
"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",
|
||||
@@ -77,13 +77,13 @@
|
||||
"jose": "^6.2.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"lucide-react": "^1.18.0",
|
||||
"lucide-react": "^1.27.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "9.0.3",
|
||||
"otpauth": "^9.5.1",
|
||||
"p-limit": "^7.3.0",
|
||||
"p-limit": "^7.3.1",
|
||||
"pg-boss": "^12.26.0",
|
||||
"prisma": "^7.8.0",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -92,7 +92,7 @@
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.7",
|
||||
"react-hook-form": "7.81.0",
|
||||
"recharts": "^3.8.1",
|
||||
"recharts": "^3.10.1",
|
||||
"rrule": "^2.8.1",
|
||||
"sax": "^1.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@next/bundle-analyzer": "^16.2.9",
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^12.0.3",
|
||||
@@ -118,7 +118,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
|
||||
Generated
+195
-447
File diff suppressed because it is too large.
Load diff
@@ -21,3 +21,7 @@ allowBuilds:
|
||||
ssh2: false
|
||||
tesseract.js: false
|
||||
unrs-resolver: false
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- "@next/bundle-analyzer@16.2.12"
|
||||
- lucide-react@1.27.0
|
||||
@@ -0,0 +1,46 @@
|
||||
-- v1.32.40 — indexes for the retention purges.
|
||||
--
|
||||
-- The nightly tombstone purge asks for `deleted_at IS NOT NULL AND deleted_at
|
||||
-- < cutoff`, and the audit-log purge asks for `created_at < cutoff`. Neither
|
||||
-- predicate had an index: every `deleted_at` index in the schema is PARTIAL on
|
||||
-- `deleted_at IS NULL` (the live-rows predicate, the exact opposite of what a
|
||||
-- purge needs), and every `audit_logs` index leads with `user_id` or `action`.
|
||||
-- Both purges therefore planned a sequential scan and paid index maintenance
|
||||
-- on every removed row. Each connection carries `statement_timeout=60000`
|
||||
-- (`src/lib/db.ts`), so past a certain backlog the statement aborts, the
|
||||
-- transaction rolls back, nothing is purged, and the next night runs the same
|
||||
-- statement against the same rows. The purge that was meant to give deletion a
|
||||
-- horizon could not close on the instances that needed it most.
|
||||
--
|
||||
-- The purges are batched as of the same release, so no single statement is on
|
||||
-- the hook for a whole backlog; these indexes are what make each batch's
|
||||
-- lookup cheap instead of a fresh sequential scan per batch.
|
||||
--
|
||||
-- The three tombstone indexes are PARTIAL on `deleted_at IS NOT NULL`, which
|
||||
-- is the shape that matters here: on a healthy instance the index covers only
|
||||
-- the tombstones, so it stays tiny and shrinks as the purge drains. Prisma
|
||||
-- cannot express a partial predicate, so these stay raw-SQL-only — the same
|
||||
-- convention as migrations 0087 / 0108 / 0243, noted at the model in
|
||||
-- `schema.prisma`. `audit_logs(created_at)` is expressible and is declared on
|
||||
-- the model.
|
||||
--
|
||||
-- Additive-only: every statement is `CREATE INDEX IF NOT EXISTS`, no table
|
||||
-- rewrite, no data change. NON-CONCURRENT on purpose — Prisma Migrate wraps a
|
||||
-- migration file in a transaction and `CREATE INDEX CONCURRENTLY` cannot run
|
||||
-- in one; these build at boot, where docker-entrypoint runs `migrate deploy`
|
||||
-- before the app serves traffic, so the build lock lands pre-traffic.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "measurements_tombstone_purge_idx"
|
||||
ON "measurements" ("deleted_at")
|
||||
WHERE "deleted_at" IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mood_entries_tombstone_purge_idx"
|
||||
ON "mood_entries" ("deleted_at")
|
||||
WHERE "deleted_at" IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "medication_intake_events_tombstone_purge_idx"
|
||||
ON "medication_intake_events" ("deleted_at")
|
||||
WHERE "deleted_at" IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "audit_logs_created_at_idx"
|
||||
ON "audit_logs" ("created_at");
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Integration sync ledger: date the failure streak, and name the leg that owns it.
|
||||
--
|
||||
-- `failing_since_at` is the start of the current unbroken failure streak,
|
||||
-- whatever kind the classifier assigned. The per-kind buckets counted attempts
|
||||
-- but nothing dated them, so a streak the classifier had called `transient`
|
||||
-- could run for a thousand attempts without ever becoming anything: the park
|
||||
-- test read `kind = 'persistent'` and the persistent anchor was only ever
|
||||
-- stamped by a persistent failure.
|
||||
--
|
||||
-- `failing_leg` records which sync leg the error came from. The table is keyed
|
||||
-- (user_id, integration) while Withings runs four legs on four crons and WHOOP
|
||||
-- runs four resources on four more, so a sibling leg's success was clearing an
|
||||
-- error it had not caused — several times an hour, which is why the strike
|
||||
-- ladder never climbed.
|
||||
ALTER TABLE "integration_statuses"
|
||||
ADD COLUMN "failing_since_at" TIMESTAMP(3),
|
||||
ADD COLUMN "failing_leg" TEXT;
|
||||
|
||||
-- Backfill for rows that are already failing. Without this a connection that
|
||||
-- has been broken for weeks would restart its clock at the next attempt and
|
||||
-- need another two days before it escalated. `last_success_at` is the honest
|
||||
-- anchor where it exists (the streak began at most one cron tick after it);
|
||||
-- rows that never succeeded fall back to the last attempt, then to creation.
|
||||
-- `failing_leg` is left NULL: which leg broke is not recoverable from the row,
|
||||
-- and NULL means "unattributed", under which any success still clears — the
|
||||
-- pre-existing behaviour, applied only to the backfilled rows.
|
||||
UPDATE "integration_statuses"
|
||||
SET "failing_since_at" = COALESCE("last_success_at", "last_attempt_at", "created_at")
|
||||
WHERE "state" IN ('error_transient', 'error_reauth', 'parked')
|
||||
AND "failing_since_at" IS NULL;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- An intake import can now cover a whole regimen.
|
||||
--
|
||||
-- The per-medication import route scopes its job to one medication and the
|
||||
-- column named it. A dose history exported from another tracker is one file for
|
||||
-- every medication a person takes, so that job belongs to the account and each
|
||||
-- payload entry names its own medication instead. NULL means "account-wide",
|
||||
-- not "unknown"; the worker refuses a payload entry that names no medication
|
||||
-- and no job row to fall back on.
|
||||
--
|
||||
-- Widening only: every existing row keeps its medication and the per-medication
|
||||
-- route keeps writing one.
|
||||
ALTER TABLE "medication_intake_import_jobs"
|
||||
ALTER COLUMN "medication_id" DROP NOT NULL;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Restore supply containers that the in-use clock wrote off although they
|
||||
-- never carried one, and drop the deadlines that would have written off the
|
||||
-- rest of them over the following weeks.
|
||||
--
|
||||
-- Until this release the state machine started a 30-day post-opening clock
|
||||
-- for every container as soon as `first_use_at` was set, including the
|
||||
-- containers whose units stay individually sealed. Pressing the first tablet
|
||||
-- out of a blister therefore condemned the rest of the pack a month later:
|
||||
-- the row went EXPIRED, its units dropped out of the available pool, and the
|
||||
-- supply card read "0 of 0 doses" beside a count of expired units.
|
||||
--
|
||||
-- Two things have to be undone. The rows already flipped need their state
|
||||
-- back, and every clock-less container still carries a persisted `expires_at`
|
||||
-- derived from that clock — including the ones whose deadline has not landed
|
||||
-- yet. Leaving those would let the daily expire scan repeat the whole defect
|
||||
-- on its own schedule.
|
||||
|
||||
-- (1) A clock-less container's only legitimate deadline is its printed
|
||||
-- expiry, so that is what `expires_at` becomes. For the common row that is
|
||||
-- NULL, which is precisely how it stays out of the daily expire scan. PEN and
|
||||
-- AMPOULE are left alone: their post-opening clock is real, so the deadline
|
||||
-- they carry is earned.
|
||||
UPDATE "medication_inventory_items"
|
||||
SET
|
||||
"expires_at" = "printed_expiry",
|
||||
"updated_at" = NOW()
|
||||
WHERE "container_type" NOT IN ('PEN', 'AMPOULE')
|
||||
AND "expires_at" IS DISTINCT FROM "printed_expiry";
|
||||
|
||||
-- (2) The rows that already flipped. They are the ones that can ONLY have
|
||||
-- reached EXPIRED through that clock:
|
||||
-- * `container_type` is one of the kinds that has no post-opening clock
|
||||
-- (everything except PEN and AMPOULE — see IN_USE_CLOCK_CONTAINER_TYPES
|
||||
-- in src/lib/medications/inventory/state-machine.ts),
|
||||
-- * the printed expiry is absent or still ahead of now, so the printed
|
||||
-- label cannot be what expired the row,
|
||||
-- * units are left, so USED_UP is not the answer either.
|
||||
--
|
||||
-- Nothing else can have set EXPIRED: the API cannot write that state
|
||||
-- directly, the daily cron only flips rows whose `expires_at` has lapsed,
|
||||
-- and the consumption hook derives the state from the same two clocks.
|
||||
--
|
||||
-- Deliberately untouched:
|
||||
-- * PEN and AMPOULE rows — their clock is real and their EXPIRED is earned;
|
||||
-- * any row whose printed expiry has genuinely lapsed, whatever its kind;
|
||||
-- * rows with no units left (their state is a USED_UP question, not this
|
||||
-- one);
|
||||
-- * the state of every row that is not EXPIRED, and every other column.
|
||||
-- No date is invented and no printed expiry is fabricated: a row that
|
||||
-- recorded no expiry keeps recording none.
|
||||
UPDATE "medication_inventory_items"
|
||||
SET
|
||||
"state" = CASE
|
||||
WHEN "first_use_at" IS NOT NULL THEN 'IN_USE'::"medication_inventory_state"
|
||||
ELSE 'ACTIVE'::"medication_inventory_state"
|
||||
END,
|
||||
"updated_at" = NOW()
|
||||
WHERE "state" = 'EXPIRED'
|
||||
AND "container_type" NOT IN ('PEN', 'AMPOULE')
|
||||
AND ("printed_expiry" IS NULL OR "printed_expiry" > NOW())
|
||||
AND "doses_remaining" > 0;
|
||||
+209
-145
@@ -640,104 +640,104 @@ model User {
|
||||
passkeyUpgradeNudgeDismissed Boolean @default(false) @map("passkey_upgrade_nudge_dismissed")
|
||||
|
||||
// Relations
|
||||
passkeys Passkey[]
|
||||
sessions Session[]
|
||||
challenges AuthChallenge[]
|
||||
passkeys Passkey[]
|
||||
sessions Session[]
|
||||
challenges AuthChallenge[]
|
||||
// v1.23 — second-factor stores (kept separate from `passkeys`, which is the
|
||||
// passwordless-primary credential set; an MFA credential is meaningless
|
||||
// without the password).
|
||||
mfaChallenges MfaChallenge[]
|
||||
mfaRecoveryCodes MfaRecoveryCode[]
|
||||
webauthnMfaCredentials WebauthnMfaCredential[]
|
||||
knownDevices UserKnownDevice[]
|
||||
mfaChallenges MfaChallenge[]
|
||||
mfaRecoveryCodes MfaRecoveryCode[]
|
||||
webauthnMfaCredentials WebauthnMfaCredential[]
|
||||
knownDevices UserKnownDevice[]
|
||||
// v1.23 — "remember this device" trusted-device tokens. A trusted device
|
||||
// skips the second factor (factor 2 only) within the window; the password
|
||||
// is still required. Revoked when the second factor is removed.
|
||||
trustedDevices TrustedDevice[]
|
||||
trustedDevices TrustedDevice[]
|
||||
// v1.30.34 — short-lived, token-bound step-up elevations. The Bearer-transport
|
||||
// equivalent of `Session.mfaVerifiedAt`: a token client re-proves a factor and
|
||||
// receives one single-use elevation good for one MFA-management call.
|
||||
stepUpElevations StepUpElevation[]
|
||||
measurements Measurement[]
|
||||
medications Medication[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
medicationPauseEras MedicationPauseEra[]
|
||||
apiTokens ApiToken[]
|
||||
stepUpElevations StepUpElevation[]
|
||||
measurements Measurement[]
|
||||
medications Medication[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
medicationPauseEras MedicationPauseEra[]
|
||||
apiTokens ApiToken[]
|
||||
// v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted
|
||||
// authorization for an MCP client, audience-bound to health:read/health:write.
|
||||
mcpOAuthConnections McpOAuthConnection[]
|
||||
refreshTokens RefreshToken[]
|
||||
mcpOAuthConnections McpOAuthConnection[]
|
||||
refreshTokens RefreshToken[]
|
||||
// v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the
|
||||
// browser-context OIDC callback to the cookie-less native token exchange.
|
||||
oidcNativeHandoffs OidcNativeHandoff[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
oidcNativeHandoffs OidcNativeHandoff[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
// v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the
|
||||
// Withings OAuth state check. The connect route mints a row with a
|
||||
// 10-minute TTL; the callback resolves the user via this relation
|
||||
// instead of parsing the cookie value, closing the L-1 audit gap
|
||||
// where the legacy `${user.id}:${nonce}` shape leaked the user id
|
||||
// into request logs / network captures.
|
||||
withingsOAuthStates WithingsOAuthState[]
|
||||
withingsOAuthStates WithingsOAuthState[]
|
||||
// v1.11.0 — WHOOP integration. Mirrors the Withings connection (1:1) +
|
||||
// OAuth-state ledger (1:N) relations.
|
||||
whoopConnection WhoopConnection?
|
||||
whoopOAuthStates WhoopOAuthState[]
|
||||
whoopConnectTickets WhoopConnectTicket[]
|
||||
whoopConnection WhoopConnection?
|
||||
whoopOAuthStates WhoopOAuthState[]
|
||||
whoopConnectTickets WhoopConnectTicket[]
|
||||
// v1.12.0 — Fitbit/Pixel integration. Mirrors the WHOOP connection (1:1) +
|
||||
// OAuth-state ledger (1:N) relations.
|
||||
fitbitConnection FitbitConnection?
|
||||
fitbitOAuthStates FitbitOAuthState[]
|
||||
fitbitConnection FitbitConnection?
|
||||
fitbitOAuthStates FitbitOAuthState[]
|
||||
// v1.27.0 — Google Health API integration (separate provider from the classic
|
||||
// Fitbit Web API above). Mirrors the connection (1:1) + OAuth-state ledger
|
||||
// (1:N) relations.
|
||||
googleHealthConnection GoogleHealthConnection?
|
||||
googleHealthOAuthStates GoogleHealthOAuthState[]
|
||||
moodEntries MoodEntry[]
|
||||
mentalHealthAssessments MentalHealthAssessment[]
|
||||
moodTagsCustom MoodTag[] @relation("UserMoodTags")
|
||||
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
|
||||
moodTagsHidden MoodTagHidden[]
|
||||
notificationChannels NotificationChannel[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
dataBackups DataBackup[]
|
||||
achievements UserAchievement[]
|
||||
recommendationFeedback RecommendationFeedback[]
|
||||
idempotencyKeys IdempotencyKey[]
|
||||
devices Device[]
|
||||
integrationStatuses IntegrationStatus[]
|
||||
coachConversations CoachConversation[]
|
||||
coachFacts CoachFact[]
|
||||
coachPlans CoachPlan[]
|
||||
coachReminders CoachReminder[]
|
||||
coachUsage CoachUsage[]
|
||||
googleHealthConnection GoogleHealthConnection?
|
||||
googleHealthOAuthStates GoogleHealthOAuthState[]
|
||||
moodEntries MoodEntry[]
|
||||
mentalHealthAssessments MentalHealthAssessment[]
|
||||
moodTagsCustom MoodTag[] @relation("UserMoodTags")
|
||||
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
|
||||
moodTagsHidden MoodTagHidden[]
|
||||
notificationChannels NotificationChannel[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
dataBackups DataBackup[]
|
||||
achievements UserAchievement[]
|
||||
recommendationFeedback RecommendationFeedback[]
|
||||
idempotencyKeys IdempotencyKey[]
|
||||
devices Device[]
|
||||
integrationStatuses IntegrationStatus[]
|
||||
coachConversations CoachConversation[]
|
||||
coachFacts CoachFact[]
|
||||
coachPlans CoachPlan[]
|
||||
coachReminders CoachReminder[]
|
||||
coachUsage CoachUsage[]
|
||||
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
|
||||
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
|
||||
// session can build against a stable DTO. Routes hang off Workout
|
||||
// (1:1), not User — the User → WorkoutRoute path is two hops by
|
||||
// design.
|
||||
workouts Workout[]
|
||||
workouts Workout[]
|
||||
// v1.4.25 W8d — personal records (schema only; detection worker
|
||||
// lands in a later release). The GET /api/personal-records route
|
||||
// ships with v1.4.25 so the iOS app can build its query path
|
||||
// against a stable contract.
|
||||
personalRecords PersonalRecord[]
|
||||
personalRecords PersonalRecord[]
|
||||
// Today rail dismiss ledger — observational PriorityItem kinds only.
|
||||
dismissedPriorityItems DismissedPriorityItem[]
|
||||
dismissedPriorityItems DismissedPriorityItem[]
|
||||
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
|
||||
// companion to the running-sum MedicationInventoryEvent ledger.
|
||||
// Carries the EMA §6.3 30-day in-use clock per opened pen.
|
||||
inventoryItems MedicationInventoryItem[]
|
||||
inventoryItems MedicationInventoryItem[]
|
||||
// v1.4.25 W19d — GLP-1 side-effect logbook. 21-entry × 5-category
|
||||
// EMA EPAR §4.8-derived taxonomy with 1-5 Likert severity. Daily
|
||||
// capture (not per-shot) so delayed-onset symptoms map to their
|
||||
// actual occurrence time.
|
||||
medicationSideEffects MedicationSideEffect[]
|
||||
medicationSideEffects MedicationSideEffect[]
|
||||
// v1.4.34 — Apple Health `export.zip` import jobs. Mirrors the
|
||||
// pg-boss job state so the polling endpoint can read live per-
|
||||
// phase progress without round-tripping through pg-boss internals.
|
||||
importJobs ImportJob[]
|
||||
medicationIntakeImportJobs MedicationIntakeImportJob[]
|
||||
importJobs ImportJob[]
|
||||
medicationIntakeImportJobs MedicationIntakeImportJob[]
|
||||
// v1.5.0 — persistent measurement rollup table. Carries the
|
||||
// count/min/max/mean/sd/slope/r2 stats per (type, granularity,
|
||||
// bucketStart) so the analytics + comprehensive read paths bypass
|
||||
@@ -745,112 +745,112 @@ model User {
|
||||
// multi-instance fan-out. Populated synchronously on the DAY
|
||||
// granularity from the measurement write hooks; WEEK/MONTH/YEAR
|
||||
// are enqueued through pg-boss for the worker to fold.
|
||||
measurementRollups MeasurementRollup[]
|
||||
measurementRollups MeasurementRollup[]
|
||||
// v0.5.4 ios-coord — per-day dedup ledger for MOOD_REMINDER pushes.
|
||||
// One row per (user, local date) the worker has nudged. Prevents
|
||||
// a second push when the cron rechecks the same hour later in the
|
||||
// 22:00 window (the cron fires every 15 min — without the ledger
|
||||
// a user could see three pushes back-to-back).
|
||||
moodReminderDispatches MoodReminderDispatch[]
|
||||
moodReminderDispatches MoodReminderDispatch[]
|
||||
// v1.4.39 W-MOOD — per-(user, granularity, bucket) mood rollup. New
|
||||
// tier behind the mood-analytics read path; per-write hooks fold
|
||||
// MoodEntry mutations into the DAY granularity inline so the next
|
||||
// read serves from this table instead of the unbounded
|
||||
// MoodEntry.findMany walk.
|
||||
moodEntryRollups MoodEntryRollup[]
|
||||
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[]
|
||||
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
|
||||
// be verifiable") plus the operator's GDPR Art. 7 documentation
|
||||
// burden. Append-only — a user revoking + re-granting consent mints
|
||||
// a fresh row so the timeline reads chronologically.
|
||||
consentReceipts ConsentReceipt[]
|
||||
consentReceipts ConsentReceipt[]
|
||||
// v1.4.49 — per-attempt push-delivery ledger. Every sender (APNS,
|
||||
// WEB_PUSH, TELEGRAM, NTFY) writes one fire-and-forget row per
|
||||
// dispatch so the admin diagnostic endpoint can return the trailing
|
||||
// 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin
|
||||
// retires rows older than 90 days (see `handlePushAttemptCleanup` in
|
||||
// `reminder-worker.ts`).
|
||||
pushAttempts PushAttempt[]
|
||||
pushAttempts PushAttempt[]
|
||||
// v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total
|
||||
// TRIMP + running EWMA reference) so the nightly Strain engine anchors
|
||||
// the 0–100 map to the user's own training load without re-integrating
|
||||
// the 42-day chronic window of HR series each night.
|
||||
strainTrimpCache StrainTrimpCache[]
|
||||
strainTrimpCache StrainTrimpCache[]
|
||||
// v1.11.0 — owner-minted clinician share links. Each row grants a
|
||||
// scoped, time-limited, revocable read view of the health record to a
|
||||
// clinician holding the raw `hls_` token (stored only as an HMAC hash).
|
||||
clinicianShareLinks ClinicianShareLink[]
|
||||
clinicianShareLinks ClinicianShareLink[]
|
||||
// v1.11.0 W1 — durable per-provider AI-health ledger (one row per
|
||||
// provider chain entry) so a known-bad credential is negatively cached
|
||||
// across workers instead of re-burning a 401 every generation.
|
||||
providerHealth ProviderHealth[]
|
||||
providerHealth ProviderHealth[]
|
||||
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
|
||||
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
|
||||
insightNarratives InsightNarrative[]
|
||||
insightNarratives InsightNarrative[]
|
||||
// v1.31.0 — the data-arrival spine's durable per-day reaction marker. One
|
||||
// row per (kind, local day); the unique index IS the once-per-kind-per-day
|
||||
// claim every reaction surface reads. Retained 14 days.
|
||||
arrivalReactions ArrivalReaction[]
|
||||
workoutInsights WorkoutInsight[]
|
||||
arrivalReactions ArrivalReaction[]
|
||||
workoutInsights WorkoutInsight[]
|
||||
workoutInsightGenerationClaims WorkoutInsightGenerationClaim[]
|
||||
// v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row
|
||||
// per user-created reminder ("BP every 7 days", "annual blood panel").
|
||||
// Server computes the canonical `nextDueAt`; the cron fires through the
|
||||
// existing notification dispatcher. Soft-deleted.
|
||||
measurementReminders MeasurementReminder[]
|
||||
measurementReminders MeasurementReminder[]
|
||||
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
|
||||
// that pairs with the Vorsorge "annual blood panel" reminder so a result
|
||||
// has somewhere to land. Soft-deleted.
|
||||
labResults LabResult[]
|
||||
labResults LabResult[]
|
||||
// v1.19.0 — user-scoped biomarker catalog (canonical name/unit/range per
|
||||
// marker); LabResult rows link to a Biomarker for the resolved range/unit.
|
||||
biomarkers Biomarker[]
|
||||
biomarkers Biomarker[]
|
||||
// v1.25.5 — user-defined custom metrics. A separate generic store
|
||||
// (catalog + values), fully isolated from the closed MeasurementType
|
||||
// system: not synced, not in FHIR, not in AI insights — log + chart only.
|
||||
customMetrics CustomMetric[]
|
||||
customMetricEntries CustomMetricEntry[]
|
||||
customMetrics CustomMetric[]
|
||||
customMetricEntries CustomMetricEntry[]
|
||||
// v1.19.0 — illness / condition journal (born-gated by the module system).
|
||||
// IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the
|
||||
// per-day timeline mirroring the CycleDayLog shape.
|
||||
illnessEpisodes IllnessEpisode[]
|
||||
illnessDayLogs IllnessDayLog[]
|
||||
illnessEpisodes IllnessEpisode[]
|
||||
illnessDayLogs IllnessDayLog[]
|
||||
// v1.25 (W-RECORDS) — structured health-record entries. Allergies map to
|
||||
// FHIR AllergyIntolerance; family-history entries map to FHIR
|
||||
// FamilyMemberHistory. Reference records (not time-series signals); the
|
||||
// sensitive free-text fields are encrypted at rest.
|
||||
allergies Allergy[]
|
||||
familyHistory FamilyHistoryEntry[]
|
||||
allergies Allergy[]
|
||||
familyHistory FamilyHistoryEntry[]
|
||||
// v1.25 (W-ENV) — opt-in environmental-context module. `EnvironmentContext`
|
||||
// is the per-day weather/daylight observation keyed to the resolved location;
|
||||
// `EnvironmentTravelLocation` holds the manual date-range travel overrides
|
||||
// that win over the home location for the days they cover.
|
||||
environmentContexts EnvironmentContext[]
|
||||
environmentTravelLocations EnvironmentTravelLocation[]
|
||||
environmentContexts EnvironmentContext[]
|
||||
environmentTravelLocations EnvironmentTravelLocation[]
|
||||
// v1.28 — micronutrient intake daily totals synced from Apple Health
|
||||
// (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off
|
||||
// by default; the ingest route refuses while the module is off.
|
||||
nutrientIntakeDays NutrientIntakeDay[]
|
||||
nutrientIntakeDays NutrientIntakeDay[]
|
||||
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
|
||||
// discharge letters) the user uploads. The raw document is encrypted at
|
||||
// rest; extracted facts land in a review-then-confirm staging area
|
||||
// (ExtractedFact) and only reach the structured stores on explicit
|
||||
// approval. Opt-in module, off by default.
|
||||
inboundDocuments InboundDocument[]
|
||||
extractedFacts ExtractedFact[]
|
||||
documentConditionLinks DocumentConditionLink[]
|
||||
documentContentIndexes DocumentContentIndex[]
|
||||
documentThumbnails DocumentThumbnail[]
|
||||
inboundDocuments InboundDocument[]
|
||||
extractedFacts ExtractedFact[]
|
||||
documentConditionLinks DocumentConditionLink[]
|
||||
documentContentIndexes DocumentContentIndex[]
|
||||
documentThumbnails DocumentThumbnail[]
|
||||
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
|
||||
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
|
||||
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
|
||||
ecgRecordings EcgRecording[]
|
||||
ecgRecordings EcgRecording[]
|
||||
|
||||
// v1.7.0 profile — optional patient-identity fields surfaced on the
|
||||
// health-record export cover (PDF) and the FHIR `Patient` resource.
|
||||
@@ -919,6 +919,16 @@ enum EnvironmentLocationSource {
|
||||
HOME
|
||||
TRAVEL
|
||||
DEVICE
|
||||
|
||||
/// Migration 0217 created the physical type as `environment_location_source`,
|
||||
/// like every other enum in this schema. This declaration carried no `@@map`,
|
||||
/// so the client compiled every write against `public."EnvironmentLocationSource"`
|
||||
/// — a type no database has ever had — and each insert into `environment_contexts`
|
||||
/// died on `42704` before a row could commit. The map names the type that
|
||||
/// exists; no migration follows, because the database was never the side that
|
||||
/// drifted. `prisma-enum-mapping-guard.test.ts` now holds the whole schema to
|
||||
/// this rule.
|
||||
@@map("environment_location_source")
|
||||
}
|
||||
|
||||
/// A per-day environmental observation (daily weather / daylight) for the
|
||||
@@ -1602,8 +1612,8 @@ enum MeasurementAggregationProvenance {
|
||||
}
|
||||
|
||||
model Measurement {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type MeasurementType
|
||||
value Float
|
||||
// v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated row.
|
||||
@@ -1614,26 +1624,26 @@ model Measurement {
|
||||
// samples. NULL for every other row (per-sample readings, per-day
|
||||
// cumulative `stats:` totals, manual entries) — the series reader hands
|
||||
// them through only for the heart-rate kind.
|
||||
valueMin Float? @map("value_min")
|
||||
valueMax Float? @map("value_max")
|
||||
valueMin Float? @map("value_min")
|
||||
valueMax Float? @map("value_max")
|
||||
unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius
|
||||
source MeasurementSource @default(MANUAL)
|
||||
measuredAt DateTime @map("measured_at")
|
||||
source MeasurementSource @default(MANUAL)
|
||||
measuredAt DateTime @map("measured_at")
|
||||
notes String? // LEGACY plaintext — superseded by `notesEncrypted` (v1.23). New writes go to the encrypted column and null this; the boot-time backfill migrates the remaining rows. Column drop is a follow-up release once the backfill reports zero remaining.
|
||||
// v1.23 — free-text measurement note, AES-256-GCM at rest (Bytes, matching
|
||||
// the IllnessDayLog / CycleDayLog / LabResult note convention). Registered
|
||||
// in ENCRYPTED_COLUMNS + the rotation script.
|
||||
notesEncrypted Bytes? @map("notes_encrypted")
|
||||
notesEncrypted Bytes? @map("notes_encrypted")
|
||||
// External-system dedup key. Withings used `grpid` (numeric); v1.4.23
|
||||
// adds Apple Health which uses `HKSample.uuid` (string). NULL for
|
||||
// manual entries — Postgres treats NULLs as distinct in unique
|
||||
// indexes, so two manual rows with the same (user, type, source) are
|
||||
// still allowed.
|
||||
externalId String? @map("external_id")
|
||||
externalId String? @map("external_id")
|
||||
// v1.4.23 — opaque marker for the source's schema version (HealthKit
|
||||
// sample's `metadata.HKWasUserEntered`, Withings model code, …).
|
||||
// Stored as a string so the ingest path can replay it back unchanged.
|
||||
externalSourceVersion String? @map("external_source_version")
|
||||
externalSourceVersion String? @map("external_source_version")
|
||||
// Authority of an aggregate stored under a `stats:*` identity. NULL for
|
||||
// ordinary point/sample rows. Native HealthKit statistics outrank the
|
||||
// source-day estimate derived from export.xml; migrated ambiguous rows are
|
||||
@@ -1647,11 +1657,11 @@ model Measurement {
|
||||
// one direction only: a non-glucose row can never carry a context. Every
|
||||
// per-context reader selects by equality against the four enum members, so
|
||||
// a NULL context stays absent rather than reading as a classification.
|
||||
glucoseContext GlucoseContext? @map("glucose_context")
|
||||
glucoseContext GlucoseContext? @map("glucose_context")
|
||||
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
|
||||
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
|
||||
// pattern. A single night produces ~5 rows (one per stage).
|
||||
sleepStage SleepStage? @map("sleep_stage")
|
||||
sleepStage SleepStage? @map("sleep_stage")
|
||||
// v1.10.0 — categorical events (WX-B). The device's own classification
|
||||
// verdict / severity for an EVENT row. NULL for every continuous
|
||||
// measurement; non-NULL only for the categorical EVENT `MeasurementType`s.
|
||||
@@ -1660,7 +1670,7 @@ model Measurement {
|
||||
// continuous row can never carry a classification. NOT part of any unique
|
||||
// index — dedup stays on `(userId, type, source, externalId)` keyed by the
|
||||
// device's `HKSample.uuid`, so a re-posted event collapses to a duplicate.
|
||||
rhythmClassification RhythmClassification? @map("rhythm_classification")
|
||||
rhythmClassification RhythmClassification? @map("rhythm_classification")
|
||||
// v1.4.25 W8c — device-type tag for the cross-source canonical
|
||||
// picker. Stored as a free-text string (kept simple — Prisma enum
|
||||
// would require coordinated client + server bumps every time we
|
||||
@@ -1671,7 +1681,7 @@ model Measurement {
|
||||
// NULL as `unknown`. Unique constraints intentionally do not
|
||||
// include `deviceType` so two ingest paths from the same source
|
||||
// still dedup on (type, source, externalId).
|
||||
deviceType String? @map("device_type")
|
||||
deviceType String? @map("device_type")
|
||||
// v1.4.30 — iOS SyncMode foundation (R-E C-2). Monotonically
|
||||
// incremented on every server-side mutation of the row. iOS uses
|
||||
// it for last-writer-wins reconciliation when two devices write
|
||||
@@ -1679,16 +1689,16 @@ model Measurement {
|
||||
// update path is responsible for bumping it. Existing rows
|
||||
// start at the default after the additive migration so legacy
|
||||
// ingest keeps round-tripping unchanged.
|
||||
syncVersion Int @default(1) @map("sync_version")
|
||||
syncVersion Int @default(1) @map("sync_version")
|
||||
// v1.4.30 — soft-delete column. The DELETE path on the
|
||||
// by-external-ids endpoint flips this rather than removing the row
|
||||
// so paired iOS clients can reconcile deletions without losing the
|
||||
// audit trail. NULL = live row; non-NULL = tombstoned at the
|
||||
// recorded instant. The unique-by-externalId index keeps both
|
||||
// states distinguishable for the bulk-backfill UPSERT path.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
// v1.19.0 — optional back-relation to the full ECG waveform recording(s)
|
||||
@@ -1763,6 +1773,13 @@ model Measurement {
|
||||
// (v1.28.46 H4). It makes the note-encryption boot-discovery `SELECT DISTINCT
|
||||
// user_id` an index-only scan that converges to empty. Same partial-predicate
|
||||
// reason it is omitted here.
|
||||
// `measurements_tombstone_purge_idx` is a partial index over `(deleted_at)
|
||||
// WHERE deleted_at IS NOT NULL` — see migration 0276 (v1.33.0). Every
|
||||
// other deleted_at index here is partial on IS NULL, which is the live-rows
|
||||
// predicate and the opposite of what the nightly tombstone purge asks for;
|
||||
// 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).
|
||||
@@map("measurements")
|
||||
}
|
||||
|
||||
@@ -1959,10 +1976,10 @@ model Workout {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
route WorkoutRoute?
|
||||
samples WorkoutSamples?
|
||||
insight WorkoutInsight?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
route WorkoutRoute?
|
||||
samples WorkoutSamples?
|
||||
insight WorkoutInsight?
|
||||
insightGenerationClaim WorkoutInsightGenerationClaim?
|
||||
|
||||
/// Ingest dedup. NULL externalId is distinct so two manual
|
||||
@@ -2069,7 +2086,6 @@ model WorkoutSamples {
|
||||
// ever creates one: a workout with no row renders no card, and that includes
|
||||
// every workout that predates this model.
|
||||
model WorkoutInsight {
|
||||
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
workoutId String @unique @map("workout_id")
|
||||
@@ -2420,7 +2436,7 @@ model Medication {
|
||||
|
||||
schedules MedicationSchedule[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
intakeImportJobs MedicationIntakeImportJob[]
|
||||
intakeImportJobs MedicationIntakeImportJob[]
|
||||
phaseConfig ReminderPhaseConfig?
|
||||
telegramMessages TelegramReminderMessage[]
|
||||
doseChanges MedicationDoseChange[]
|
||||
@@ -2854,6 +2870,13 @@ model MedicationIntakeEvent {
|
||||
// scanned the whole intake history. These two let both index-seek instead.
|
||||
@@index([userId, scheduledFor])
|
||||
@@index([userId, medicationId, takenAt])
|
||||
// `medication_intake_events_tombstone_purge_idx` is a partial index over `(deleted_at)
|
||||
// WHERE deleted_at IS NOT NULL` — see migration 0276 (v1.33.0). Every
|
||||
// other deleted_at index here is partial on IS NULL, which is the live-rows
|
||||
// predicate and the opposite of what the nightly tombstone purge asks for;
|
||||
// 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).
|
||||
@@map("medication_intake_events")
|
||||
}
|
||||
|
||||
@@ -2932,9 +2955,17 @@ enum MedicationInventoryState {
|
||||
}
|
||||
|
||||
/// v1.16.10 — what kind of physical container an inventory item is.
|
||||
/// Display-level only (no state-machine behaviour hangs off it); the
|
||||
/// register dialog defaults it from the medication's delivery form
|
||||
/// The register dialog defaults it from the medication's delivery form
|
||||
/// (INJECTION → PEN, ORAL → BLISTER, else OTHER).
|
||||
///
|
||||
/// The kind decides whether opening the container starts a
|
||||
/// post-opening degradation clock: PEN and AMPOULE are one sealed
|
||||
/// reservoir that the first dose breaches, so they carry the 30-day
|
||||
/// in-use window; BLISTER / INHALER / BOTTLE / OTHER hold individually
|
||||
/// sealed units (or an unidentified arrangement) and expire only on
|
||||
/// their printed date. See `IN_USE_CLOCK_CONTAINER_TYPES` in
|
||||
/// `src/lib/medications/inventory/state-machine.ts` for the per-member
|
||||
/// reasoning.
|
||||
enum MedicationContainerType {
|
||||
PEN
|
||||
AMPOULE
|
||||
@@ -2953,6 +2984,8 @@ model MedicationInventoryItem {
|
||||
state MedicationInventoryState @default(ACTIVE)
|
||||
/// v1.16.10 — container kind (pen / ampoule / blister pack / …).
|
||||
/// Backfilled PEN for items on INJECTION medications, OTHER otherwise.
|
||||
/// Read by the state machine: PEN / AMPOULE carry the post-opening
|
||||
/// clock, the rest expire only on `printedExpiry`.
|
||||
containerType MedicationContainerType @default(OTHER) @map("container_type")
|
||||
/// Units the container shipped with (v1.16.10 — renamed from
|
||||
/// `dosesTotal`; the DB column stays `doses_total`). A unit is one
|
||||
@@ -2966,10 +2999,12 @@ model MedicationInventoryItem {
|
||||
/// (v1.16.10 — renamed from `dosesRemaining`; column stays
|
||||
/// `doses_remaining`). v1.16.12 (#316) — Decimal for fractional dosing.
|
||||
unitsRemaining Decimal @map("doses_remaining") @db.Decimal(12, 4)
|
||||
/// NULL until the pen is first opened. Sets the 30-day in-use
|
||||
/// clock per EMA EPAR §6.3.
|
||||
/// NULL until the container is first opened. On a PEN / AMPOULE it
|
||||
/// also sets the 30-day in-use clock per EMA EPAR §6.3; on the other
|
||||
/// kinds it records that the pack is open and nothing more.
|
||||
firstUseAt DateTime? @map("first_use_at")
|
||||
/// Persisted = MIN(firstUseAt + 30 days, printedExpiry). Persisting
|
||||
/// Persisted = MIN(firstUseAt + 30 days, printedExpiry) for a
|
||||
/// clock-bearing container, else printedExpiry alone. Persisting
|
||||
/// rather than deriving keeps the daily "expire-stale" cron a
|
||||
/// single indexed scan.
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
@@ -3218,8 +3253,8 @@ model ApiToken {
|
||||
// 60-minute access rows so it does not flood (M2).
|
||||
mcpConnectionId String? @map("mcp_connection_id")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
|
||||
// v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it.
|
||||
stepUpElevations StepUpElevation[]
|
||||
|
||||
@@ -3455,13 +3490,13 @@ model OidcNativeHandoff {
|
||||
// ─── Withings ──────────────────────────────────────────────
|
||||
|
||||
model WithingsConnection {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
withingsUserId String @map("withings_user_id")
|
||||
accessToken String @map("access_token") // encrypted at app level
|
||||
refreshToken String @map("refresh_token") // encrypted at app level
|
||||
tokenExpiresAt DateTime @map("token_expires_at")
|
||||
lastSyncedAt DateTime? @map("last_synced_at")
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
withingsUserId String @map("withings_user_id")
|
||||
accessToken String @map("access_token") // encrypted at app level
|
||||
refreshToken String @map("refresh_token") // encrypted at app level
|
||||
tokenExpiresAt DateTime @map("token_expires_at")
|
||||
lastSyncedAt DateTime? @map("last_synced_at")
|
||||
/// v1.4.25 W5d — comma-separated OAuth scope string returned by the
|
||||
/// Withings authorisation flow. Legacy v1.4.24- connections sit on
|
||||
/// `user.metrics` only; v1.4.25 starts requesting
|
||||
@@ -3469,21 +3504,21 @@ model WithingsConnection {
|
||||
/// activity / sleep endpoints unlock. Null = legacy connection that
|
||||
/// has never re-authed since v1.4.25 — the UI surfaces a banner
|
||||
/// inviting them to reconnect.
|
||||
scope String? @map("scope")
|
||||
scope String? @map("scope")
|
||||
/// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings
|
||||
/// sleep rows were stamped with the segment START; the reader treats
|
||||
/// `measuredAt` as the END, shifting every night one segment-length earlier.
|
||||
/// The boot-time backfill deletes the affected WITHINGS / SLEEP_DURATION rows
|
||||
/// and re-syncs with the corrected END stamp, then sets this so the discovery
|
||||
/// query drops the connection. Null = backfill not yet run.
|
||||
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
|
||||
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
|
||||
/// v1.32 — durable per-category webhook subscription outcomes. The JSON
|
||||
/// payload is versioned and validated before use; the timestamp mirrors the
|
||||
/// earliest transient/pending category retry for indexed hourly discovery.
|
||||
webhookSubscriptionState Json? @map("webhook_subscription_state")
|
||||
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -3852,6 +3887,13 @@ model MoodEntry {
|
||||
// WHERE note IS NOT NULL AND note_encrypted IS NULL` — see migration 0243
|
||||
// (v1.28.46 H4), serving the note-encryption boot-discovery scan. Raw SQL
|
||||
// only (partial predicate not expressible in Prisma).
|
||||
// `mood_entries_tombstone_purge_idx` is a partial index over `(deleted_at)
|
||||
// WHERE deleted_at IS NOT NULL` — see migration 0276 (v1.33.0). Every
|
||||
// other deleted_at index here is partial on IS NULL, which is the live-rows
|
||||
// predicate and the opposite of what the nightly tombstone purge asks for;
|
||||
// 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).
|
||||
@@map("mood_entries")
|
||||
}
|
||||
|
||||
@@ -4116,6 +4158,16 @@ model IntegrationStatus {
|
||||
// sole source of truth for the alert ladder.
|
||||
consecutiveFailuresByKind Json? @map("consecutive_failures_by_kind") // v1.4.43 W14 — { transient, reauth_required, persistent }
|
||||
persistentFailureStartedAt DateTime? @map("persistent_failure_started_at") // v1.4.43 W14 — start of the current persistent streak (drives >24h park)
|
||||
// Start of the current unbroken failure streak, whatever the kind. The
|
||||
// per-kind buckets count attempts; this dates them, which is what tells a
|
||||
// blip apart from a dead pipe. Cleared by a success that is entitled to
|
||||
// clear it, by a reconnect, a disconnect, and a resume.
|
||||
failingSinceAt DateTime? @map("failing_since_at")
|
||||
// Which sync leg owns the error currently on the row. `null` = the provider
|
||||
// has a single leg, or the failure predates leg attribution. A success from
|
||||
// a DIFFERENT leg may not clear an error this one did not cause — see
|
||||
// `recordSyncSuccess`.
|
||||
failingLeg String? @map("failing_leg")
|
||||
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -4277,6 +4329,11 @@ model AuditLog {
|
||||
// this three-column index serves both the findFirst and the take:5 as
|
||||
// an index range scan over the highest-churn table. See migration 0123.
|
||||
@@index([userId, action, createdAt(sort: Desc)])
|
||||
// v1.33.0 — the retention purge filters on `createdAt` alone. Every index
|
||||
// above leads with `userId` or `action`, so the purge planned a sequential
|
||||
// scan and could not finish inside statement_timeout on an instance with
|
||||
// enough history. See migration 0276.
|
||||
@@index([createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
@@ -4430,7 +4487,14 @@ model ImportJob {
|
||||
model MedicationIntakeImportJob {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
medicationId String @map("medication_id")
|
||||
/// v1.33.0 (migration 0278) — nullable. The per-medication import route
|
||||
/// scopes its job to one medication and names it here. A dose history
|
||||
/// exported from another tracker covers a whole regimen in one file — the
|
||||
/// reported export named sixteen medications across 3,395 rows — so that job
|
||||
/// belongs to the account and each payload entry names its own medication
|
||||
/// instead. NULL therefore means "account-wide", not "unknown": the worker
|
||||
/// reads `entry.medicationId` and refuses a payload that names neither.
|
||||
medicationId String? @map("medication_id")
|
||||
pgBossJobId String? @unique @map("pg_boss_job_id")
|
||||
status String @default("queued")
|
||||
payload Json
|
||||
@@ -4443,8 +4507,8 @@ model MedicationIntakeImportJob {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
medication Medication? @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([medicationId, createdAt])
|
||||
@@ -5378,13 +5442,13 @@ model ClinicianShareLink {
|
||||
// ── Frozen scope (write-once at creation; never widened) ──
|
||||
/// Reporting-window start (absolute, not "last N days"). Frozen so a
|
||||
/// rolling share can never reach data older than the owner chose.
|
||||
rangeStart DateTime @map("range_start")
|
||||
rangeStart DateTime @map("range_start")
|
||||
/// Window end. Null = rolling ("up to now"); set = frozen snapshot window.
|
||||
rangeEnd DateTime? @map("range_end")
|
||||
rangeEnd DateTime? @map("range_end")
|
||||
/// The frozen report scope: `{ "v": 2, "leaves": [...] }`. Write-once at
|
||||
/// mint, never widened. A blob this build cannot read resolves to the empty
|
||||
/// selection, so a drifted row serves nothing rather than defaulting open.
|
||||
sectionsJson Json @map("sections_json")
|
||||
sectionsJson Json @map("sections_json")
|
||||
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
|
||||
/// create. When true the public view serves ONLY the attached documents and
|
||||
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
|
||||
@@ -5393,7 +5457,7 @@ model ClinicianShareLink {
|
||||
/// documents-only link (the previous derived check would have leaked it once
|
||||
/// the new key defaulted on). Legacy links (pre-column) keep the derived
|
||||
/// all-off fallback in the view loader.
|
||||
documentOnly Boolean @default(false) @map("document_only")
|
||||
documentOnly Boolean @default(false) @map("document_only")
|
||||
|
||||
/// REQUIRED — no never-expiring share (capped at `SHARE_LINK_MAX_DAYS`).
|
||||
expiresAt DateTime @map("expires_at")
|
||||
@@ -5497,38 +5561,38 @@ model InsightNarrative {
|
||||
// revision owns generation. A strictly newer arrival atomically invalidates
|
||||
// stale generation state; equal or older deliveries are deduplicated.
|
||||
model ArrivalReaction {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// One of `ARRIVAL_KINDS` (`@/lib/arrivals/types`).
|
||||
kind String
|
||||
kind String
|
||||
/// The user's PROFILE-timezone YYYY-MM-DD this reaction is filed under.
|
||||
localDate String @map("local_date")
|
||||
localDate String @map("local_date")
|
||||
/// Timestamp of the newest sample that triggered it.
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
/// When the newest sample actually reached HealthLog. Unlike `createdAt`,
|
||||
/// this advances when a later same-kind arrival refreshes the daily marker.
|
||||
arrivedAt DateTime @default(now()) @map("arrived_at")
|
||||
arrivedAt DateTime @default(now()) @map("arrived_at")
|
||||
/// Kind-scoped referent: the workout id for `workout`, the panel day for labs.
|
||||
refId String? @map("ref_id")
|
||||
refId String? @map("ref_id")
|
||||
/// AES-256-GCM ciphertext of the generated reaction line. Null until — and
|
||||
/// unless — a line is generated: the marker is the feature, the sentence is
|
||||
/// garnish, and a provider-less install never fills this in. Follows the
|
||||
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
|
||||
/// the status cards use. Registered in `scripts/rotate-encryption-key.ts`.
|
||||
lineEncrypted Bytes? @map("line_encrypted")
|
||||
generatedAt DateTime? @map("generated_at")
|
||||
lineEncrypted Bytes? @map("line_encrypted")
|
||||
generatedAt DateTime? @map("generated_at")
|
||||
/// Opaque owner token and lease for pre-provider setup. A stale worker may
|
||||
/// reclaim only while `generationProviderInvokedAt` is null.
|
||||
generationClaimId String? @map("generation_claim_id")
|
||||
generationClaimedAt DateTime? @map("generation_claimed_at")
|
||||
generationClaimId String? @map("generation_claim_id")
|
||||
generationClaimedAt DateTime? @map("generation_claimed_at")
|
||||
/// The token reservation is written in the same transaction as the
|
||||
/// `coach_usage` increment. A reclaimed pre-provider attempt reuses it.
|
||||
generationReservedTokens Int? @map("generation_reserved_tokens")
|
||||
generationBudgetDateKey String? @map("generation_budget_date_key")
|
||||
generationReservedTokens Int? @map("generation_reserved_tokens")
|
||||
generationBudgetDateKey String? @map("generation_budget_date_key")
|
||||
/// Durable provider boundary. Once set, this marker is terminal unless the
|
||||
/// encrypted line is committed; no retry may invoke a provider again.
|
||||
generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
+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.32.39";
|
||||
/* @sw-version-fallback */ "v1.33.0";
|
||||
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/*`
|
||||
|
||||
@@ -69,11 +69,12 @@ const FINALITY_PHRASES: Record<string, readonly string[]> = {
|
||||
],
|
||||
it: [
|
||||
"non può essere annullata",
|
||||
"non può essere annullato",
|
||||
"definitivamente",
|
||||
"definitiva",
|
||||
"permanente",
|
||||
],
|
||||
pl: ["nie można cofnąć", "trwale", "nieodwracaln"],
|
||||
pl: ["nie można cofnąć", "nie można tego cofnąć", "trwale", "nieodwracaln"],
|
||||
};
|
||||
|
||||
/** Per-locale phrase that promises the short undo window. */
|
||||
@@ -108,6 +109,12 @@ const PERMANENT_CONFIRM_KEYS = [
|
||||
"mood.manage.purgeBody",
|
||||
// GDPR Art. 17 account purge
|
||||
"settings.deleteAccountConfirmDescription",
|
||||
// hard delete of every user-scoped row except the sign-in credentials
|
||||
"settings.dangerZoneConfirmDescription",
|
||||
// hard delete since v1.32.40 — the tombstone served nothing and the
|
||||
// dialog already promised permanence
|
||||
"measurementReminders.deleteConfirmDescription",
|
||||
"records.allergies.deleteConfirmDescription",
|
||||
] as const;
|
||||
|
||||
describe("delete confirmations describe what the surface actually does", () => {
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Structural guard binding `prisma/schema.prisma` to the "Delete All Data"
|
||||
* wipe plan.
|
||||
*
|
||||
* The failure it exists to catch: a model is added to the schema, it is
|
||||
* user-scoped from birth, and the wipe list is "the follow-up". Nothing fails
|
||||
* — the route keeps deleting what it always deleted, the toast keeps saying
|
||||
* "all personal data has been deleted", and the new table quietly joins the
|
||||
* set of things that survive a confirmation the person typed by hand. That is
|
||||
* how a 13-table wipe ended up facing an 80-table schema.
|
||||
*
|
||||
* Both ends are literals — Prisma model blocks on one side, exported arrays
|
||||
* and objects on the other — so the matcher is exact. Nothing here greps for
|
||||
* intent.
|
||||
*
|
||||
* What it proves:
|
||||
* 1. every model carrying a `userId` column is either wiped or exempt with a
|
||||
* reason;
|
||||
* 2. every model without one either reaches a wiped model through a chain of
|
||||
* required `onDelete: Cascade` relations (so the database removes it) or
|
||||
* is declared instance-scoped;
|
||||
* 3. every `User` column is either reset or kept with a reason;
|
||||
* 4. the delete order lists children before parents, so the per-model counts
|
||||
* in the audit row are truthful;
|
||||
* 5. no exemption has gone stale.
|
||||
*
|
||||
* Its honest limit: it proves classification, not judgement. It cannot tell
|
||||
* that a model was put on the exempt list for a bad reason — only that
|
||||
* somebody had to write a reason down. And it reads relations, so a table
|
||||
* linked to an account by a bare `userId` string with no foreign key (there
|
||||
* are two: `TelegramScheduledDeletion`, `TelegramPromptContext`) is caught by
|
||||
* rule 1 only because the column name matches the convention.
|
||||
*
|
||||
* Mutation check: remove any entry from `WIPE_MODELS` and this goes red naming
|
||||
* that model; add a column to `model User` without classifying it and it goes
|
||||
* red naming the column.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
INSTANCE_SCOPED,
|
||||
USER_KEPT_FIELDS,
|
||||
USER_RESET,
|
||||
WIPE_EXEMPT,
|
||||
WIPE_MODELS,
|
||||
wipeDelegateKey,
|
||||
} from "@/lib/data-wipe/wipe-plan";
|
||||
|
||||
const SCHEMA = join(process.cwd(), "prisma", "schema.prisma");
|
||||
|
||||
interface Relation {
|
||||
/** Target model name. */
|
||||
target: string;
|
||||
cascade: boolean;
|
||||
/** `true` when the relation field is optional (`Model?`). */
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
interface ModelShape {
|
||||
scalars: string[];
|
||||
relations: Relation[];
|
||||
}
|
||||
|
||||
function parseSchema(schema: string): Map<string, ModelShape> {
|
||||
const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map(
|
||||
(m) => ({ name: m[1], body: m[2] }),
|
||||
);
|
||||
const modelNames = new Set(blocks.map((b) => b.name));
|
||||
|
||||
const out = new Map<string, ModelShape>();
|
||||
for (const block of blocks) {
|
||||
const scalars: string[] = [];
|
||||
const relations: Relation[] = [];
|
||||
for (const rawLine of block.body.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
|
||||
const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line);
|
||||
if (!match) continue;
|
||||
const [, field, type, isList, isOptional] = match;
|
||||
if (modelNames.has(type)) {
|
||||
// A list side of a relation carries no column and cannot cascade.
|
||||
if (isList) continue;
|
||||
relations.push({
|
||||
target: type,
|
||||
cascade: line.includes("onDelete: Cascade"),
|
||||
optional: Boolean(isOptional),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isList && type === type.toUpperCase()) {
|
||||
// Scalar / enum list column — still a column.
|
||||
scalars.push(field);
|
||||
continue;
|
||||
}
|
||||
scalars.push(field);
|
||||
}
|
||||
out.set(block.name, { scalars, relations });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const schemaText = readFileSync(SCHEMA, "utf8");
|
||||
const models = parseSchema(schemaText);
|
||||
const wipeSet = new Set<string>(WIPE_MODELS);
|
||||
|
||||
describe("data-wipe completeness", () => {
|
||||
it("parses the schema into a plausible shape", () => {
|
||||
expect(models.size).toBeGreaterThan(100);
|
||||
expect(models.get("Measurement")?.scalars).toContain("userId");
|
||||
expect(models.get("Measurement")?.scalars).not.toContain("user");
|
||||
expect(models.get("User")?.scalars).toContain("heightCm");
|
||||
expect(models.get("User")?.scalars).not.toContain("measurements");
|
||||
expect(
|
||||
models.get("CoachMessage")?.relations.map((r) => r.target),
|
||||
).toContain("CoachConversation");
|
||||
});
|
||||
|
||||
it("every model with a userId column is wiped or exempt with a reason", () => {
|
||||
const userScoped = [...models]
|
||||
.filter(([, shape]) => shape.scalars.includes("userId"))
|
||||
.map(([name]) => name);
|
||||
|
||||
expect(userScoped.length).toBeGreaterThan(80);
|
||||
|
||||
const unclassified = userScoped.filter(
|
||||
(name) =>
|
||||
!wipeSet.has(name) &&
|
||||
!(name in WIPE_EXEMPT) &&
|
||||
!(name in INSTANCE_SCOPED),
|
||||
);
|
||||
|
||||
if (unclassified.length > 0) {
|
||||
throw new Error(
|
||||
`${unclassified.length} user-scoped model(s) are in neither the wipe list nor the exemption list:\n` +
|
||||
unclassified.map((n) => ` ❌ ${n}`).join("\n") +
|
||||
"\n\nA person who types the confirmation on Settings → Delete All Data " +
|
||||
"expects these rows gone. Add each to WIPE_MODELS, or to WIPE_EXEMPT " +
|
||||
"with the reason keeping it is not account deletion — and say so in " +
|
||||
"the confirmation copy.",
|
||||
);
|
||||
}
|
||||
|
||||
const bothWays = userScoped.filter(
|
||||
(name) => wipeSet.has(name) && name in WIPE_EXEMPT,
|
||||
);
|
||||
expect(bothWays, "a model cannot be both wiped and exempt").toEqual([]);
|
||||
});
|
||||
|
||||
it("every model without a userId column is reached by cascade or declared instance-scoped", () => {
|
||||
// Fixpoint over required cascade relations: a model is covered when the
|
||||
// database removes it as a consequence of a row the wipe deletes.
|
||||
const covered = new Set<string>(wipeSet);
|
||||
for (;;) {
|
||||
let grew = false;
|
||||
for (const [name, shape] of models) {
|
||||
if (covered.has(name)) continue;
|
||||
const reached = shape.relations.some(
|
||||
(r) => r.cascade && !r.optional && covered.has(r.target),
|
||||
);
|
||||
if (reached) {
|
||||
covered.add(name);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
if (!grew) break;
|
||||
}
|
||||
|
||||
const orphans = [...models]
|
||||
.filter(([, shape]) => !shape.scalars.includes("userId"))
|
||||
.map(([name]) => name)
|
||||
.filter(
|
||||
(name) =>
|
||||
!covered.has(name) &&
|
||||
!(name in INSTANCE_SCOPED) &&
|
||||
!(name in WIPE_EXEMPT),
|
||||
);
|
||||
|
||||
if (orphans.length > 0) {
|
||||
throw new Error(
|
||||
`${orphans.length} model(s) carry no userId, are not removed by any cascade from a wiped row, and are not declared instance-scoped:\n` +
|
||||
orphans.map((n) => ` ❌ ${n}`).join("\n") +
|
||||
"\n\nEither give the parent relation onDelete: Cascade, add the model " +
|
||||
"to WIPE_MODELS, or add it to INSTANCE_SCOPED with the reason it " +
|
||||
"belongs to the instance rather than to an account.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("every User column is either reset or kept with a reason", () => {
|
||||
const columns = models.get("User")?.scalars ?? [];
|
||||
expect(columns.length).toBeGreaterThan(80);
|
||||
|
||||
const reset = new Set(Object.keys(USER_RESET));
|
||||
const kept = new Set(Object.keys(USER_KEPT_FIELDS));
|
||||
|
||||
const unclassified = columns.filter((c) => !reset.has(c) && !kept.has(c));
|
||||
if (unclassified.length > 0) {
|
||||
throw new Error(
|
||||
`${unclassified.length} User column(s) are in neither USER_RESET nor USER_KEPT_FIELDS:\n` +
|
||||
unclassified.map((c) => ` ❌ ${c}`).join("\n") +
|
||||
"\n\nA column on the account row survives the wipe by default. Reset " +
|
||||
"it, or record why keeping it is not keeping the person's record.",
|
||||
);
|
||||
}
|
||||
|
||||
const bothWays = columns.filter((c) => reset.has(c) && kept.has(c));
|
||||
expect(bothWays, "a column cannot be both reset and kept").toEqual([]);
|
||||
});
|
||||
|
||||
it("carries no stale classification", () => {
|
||||
for (const name of WIPE_MODELS) {
|
||||
expect(
|
||||
models.has(name),
|
||||
`WIPE_MODELS names "${name}", which is not a model`,
|
||||
).toBe(true);
|
||||
}
|
||||
for (const [name, reason] of Object.entries(WIPE_EXEMPT)) {
|
||||
expect(
|
||||
models.has(name),
|
||||
`WIPE_EXEMPT names "${name}", which is not a model`,
|
||||
).toBe(true);
|
||||
expect(
|
||||
reason.length,
|
||||
`WIPE_EXEMPT.${name} needs a real reason`,
|
||||
).toBeGreaterThan(30);
|
||||
}
|
||||
for (const [name, reason] of Object.entries(INSTANCE_SCOPED)) {
|
||||
expect(
|
||||
models.has(name),
|
||||
`INSTANCE_SCOPED names "${name}", which is not a model`,
|
||||
).toBe(true);
|
||||
expect(
|
||||
reason.length,
|
||||
`INSTANCE_SCOPED.${name} needs a real reason`,
|
||||
).toBeGreaterThan(20);
|
||||
}
|
||||
const columns = new Set(models.get("User")?.scalars ?? []);
|
||||
for (const [column, reason] of Object.entries(USER_KEPT_FIELDS)) {
|
||||
expect(
|
||||
columns.has(column),
|
||||
`USER_KEPT_FIELDS names "${column}", which is no longer a User column`,
|
||||
).toBe(true);
|
||||
expect(
|
||||
reason.length,
|
||||
`USER_KEPT_FIELDS.${column} needs a real reason`,
|
||||
).toBeGreaterThan(20);
|
||||
}
|
||||
expect(new Set(WIPE_MODELS).size, "WIPE_MODELS repeats a model").toBe(
|
||||
WIPE_MODELS.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes children before their parents so the counts are truthful", () => {
|
||||
const position = new Map(WIPE_MODELS.map((m, i) => [m as string, i]));
|
||||
const violations: string[] = [];
|
||||
for (const child of WIPE_MODELS) {
|
||||
const shape = models.get(child);
|
||||
if (!shape) continue;
|
||||
for (const relation of shape.relations) {
|
||||
if (!relation.cascade) continue;
|
||||
if (relation.target === child) continue;
|
||||
if (!position.has(relation.target)) continue;
|
||||
if (position.get(child)! > position.get(relation.target)!) {
|
||||
violations.push(
|
||||
`${child} (#${position.get(child)}) cascades from ${relation.target} (#${position.get(relation.target)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
violations,
|
||||
`WIPE_MODELS deletes a parent before its child, so the child's count reports 0 for rows that existed:\n${violations.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps every wiped model to a plausible delegate key", () => {
|
||||
for (const model of WIPE_MODELS) {
|
||||
const key = wipeDelegateKey(model);
|
||||
expect(key[0]).toBe(model[0].toLowerCase());
|
||||
expect(key.slice(1)).toBe(model.slice(1));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,815 @@
|
||||
/**
|
||||
* Structural guard on destructive controls: nothing that can take a person's
|
||||
* data away ships without someone having written down what it takes and what
|
||||
* gates it.
|
||||
*
|
||||
* WHY A REGISTRY RATHER THAN A PATTERN MATCH
|
||||
*
|
||||
* The obvious guard — "every `Trash2` trigger must sit next to an
|
||||
* `AlertDialog`" — fails in both directions, and it fails quietly. It misses
|
||||
* the control that destroys the most: Settings → AI cleared a person's
|
||||
* free-text note AND their allergy line from one untargeted tap, through a
|
||||
* `PUT` carrying two empty strings, with no trash icon involved in the write
|
||||
* and nothing in the payload spelling "delete". It cries wolf on the reverse:
|
||||
* a `Trash2` that removes a row from an unsaved draft, a "reset" that only
|
||||
* touches local state until a separate Save, an undo affordance inside a
|
||||
* success toast. Both classes are live in this tree. A guard that has to be
|
||||
* suppressed on half its hits teaches people to suppress it.
|
||||
*
|
||||
* So the honest shape is: the REGISTRY below is the source of truth for what
|
||||
* is destructive, because that judgement is not mechanical. Three detectors
|
||||
* then make FORGETTING TO REGISTER the failure mode — each is complete for
|
||||
* its own syntactic shape, and together they cover every way this codebase
|
||||
* currently issues a destroying request:
|
||||
*
|
||||
* D1 a DELETE goes out `apiDelete(…)` / `method: "DELETE"`
|
||||
* D2 a route whose own name is the act `/api/…/{disconnect,revoke,reset,
|
||||
* purge,clear,wipe,bulk-delete}`
|
||||
* D3 a write blanks a stored field a payload literal assigning `""`
|
||||
* or `null` to a named key
|
||||
*
|
||||
* WHAT THIS CANNOT DO — stated plainly, because a guard that oversells itself
|
||||
* is worse than none:
|
||||
*
|
||||
* - It cannot prove the registry is COMPLETE. A destructive control that
|
||||
* issues no DELETE, hits no act-named route, and blanks no literal field —
|
||||
* a PUT that overwrites a list with a shorter one, say — is invisible to
|
||||
* all three detectors and will not be caught by anything here.
|
||||
* - It cannot prove a registered claim is TRUE. `confirm: ["ConfirmButton"]`
|
||||
* is checked only to the extent that the named mechanism is present in one
|
||||
* of the entry's files. A reviewer who waves through a wrong entry defeats
|
||||
* it, and no test substitutes for that review.
|
||||
* - `unconfirmed` entries are the escape hatch, and they are deliberately
|
||||
* expensive: each needs a written reason long enough to be an argument
|
||||
* rather than a shrug. There is no silent skip.
|
||||
*
|
||||
* What it does hold: a new destructive control cannot land unnoticed, a
|
||||
* removed one cannot leave a lie behind, and the specific regression that
|
||||
* started this — a destroying payload fired straight from an `onClick` — goes
|
||||
* red at the call site.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
type ConfirmKind =
|
||||
| "ConfirmButton"
|
||||
| "ConfirmDialog"
|
||||
| "DeleteButton"
|
||||
| "SelectionActionBar"
|
||||
| "AlertDialog"
|
||||
| "inline-two-step"
|
||||
| "typed-phrase";
|
||||
|
||||
/**
|
||||
* How bounded the loss is. This is the axis that decides whether a control
|
||||
* wants a confirmation, an undo, or both, so the registry makes the next
|
||||
* person answer it rather than leaving it implied.
|
||||
*
|
||||
* permanent a column overwritten, or a row hard-deleted.
|
||||
* Nothing in the product brings it back.
|
||||
* tombstoned-no-restore soft-deleted in the database, but no restore route
|
||||
* and no surface a person could reach it from. Gone,
|
||||
* as far as the person is concerned.
|
||||
* restorable a restore route exists AND a control reaches it.
|
||||
*/
|
||||
type Recovery = "permanent" | "tombstoned-no-restore" | "restorable";
|
||||
|
||||
interface DestructiveEntry {
|
||||
/** Path under `src/`, forward slashes. Where the destroying request lives. */
|
||||
file: string;
|
||||
/** Plain words: what a person loses. */
|
||||
destroys: string;
|
||||
/** Files carrying the trigger, when the mutation lives in a hook. */
|
||||
triggers?: string[];
|
||||
recovery: Recovery;
|
||||
/** Mechanisms gating the triggers. Empty only alongside `unconfirmed`. */
|
||||
confirm: ConfirmKind[];
|
||||
/**
|
||||
* Controls in this entry that deliberately do NOT confirm. Every one needs
|
||||
* an argument, not a note — the length floor is enforced below.
|
||||
*/
|
||||
unconfirmed?: { control: string; reason: string }[];
|
||||
/**
|
||||
* Set when the entry is deliberately kept although no detector sees it —
|
||||
* the concrete form of "this guard cannot prove the registry is complete".
|
||||
* The value says which shape slips past, so the blind spot is on the record
|
||||
* instead of being inferred from an absent row.
|
||||
*/
|
||||
undetected?: string;
|
||||
}
|
||||
|
||||
const REGISTRY: DestructiveEntry[] = [
|
||||
// ── Account, credentials, sessions ──────────────────────────────────────
|
||||
{
|
||||
file: "components/settings/advanced-section.tsx",
|
||||
destroys:
|
||||
"every health record on the account, or the whole account including passkeys and audit history",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "research-mode switch, off position",
|
||||
reason:
|
||||
"Turning research mode off drops the stored acknowledgement receipt, which is regenerated by turning it back on and re-acknowledging. Nothing a person authored or measured is involved, so a dialog on a labelled switch would gate a preference rather than a loss.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/settings/security-sessions-card.tsx",
|
||||
destroys: "one login session, or every session other than this one",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/trusted-devices-card.tsx",
|
||||
destroys: "the second-factor memory for one device, or for all of them",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/security-section/passkey-list-section.tsx",
|
||||
destroys: "a passkey — hard-deleted, the credential cannot be re-derived",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/security-section/security-keys-card.tsx",
|
||||
destroys: "a WebAuthn second factor",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/api-section.tsx",
|
||||
destroys: "an API token — revoked, and the secret is never shown again",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/mcp-section.tsx",
|
||||
destroys: "an MCP client grant or an MCP token",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/sharing-section.tsx",
|
||||
destroys: "a clinician share link — the URL stays dead once revoked",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/account-section/avatar-section.tsx",
|
||||
destroys: "the uploaded profile photo, deleted from the server",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
|
||||
// ── AI credentials ──────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/settings/ai/codex-provider-form.tsx",
|
||||
destroys: "the stored Codex sign-in and the cached insight text with it",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/admin/central-codex-section.tsx",
|
||||
destroys:
|
||||
"the instance-wide shared AI sign-in — every user relying on it loses AI features",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/admin/ai-server-key-section.tsx",
|
||||
destroys:
|
||||
"the operator-wide AI provider key, which is never displayed again once stored",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/ai/ai-consent-card.tsx",
|
||||
destroys: "the AI consent receipt that gates every AI surface",
|
||||
recovery: "restorable",
|
||||
confirm: ["inline-two-step"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/about-me-section.tsx",
|
||||
destroys: "the free-text self-context note the Coach and briefing read",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/coach-memory-section.tsx",
|
||||
destroys: "one remembered Coach fact, or every one of them",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton", "AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "hooks/use-coach-reminders.ts",
|
||||
destroys: "a Coach reminder",
|
||||
triggers: ["components/settings/coach-reminders-section.tsx"],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "hooks/use-coach-plans.ts",
|
||||
destroys: "a Coach plan and the record of how it went",
|
||||
triggers: [
|
||||
"app/coach/plans/page.tsx",
|
||||
"components/insights/coach-panel/plan-proposal-card.tsx",
|
||||
],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton"],
|
||||
unconfirmed: [
|
||||
{
|
||||
control:
|
||||
"decline a PROPOSED plan — app/coach/plans/page.tsx and plan-proposal-card.tsx",
|
||||
reason:
|
||||
"Declining is the negative half of an accept/decline pair the assistant just offered; the person has invested nothing in a proposal they never adopted, and the same suggestion re-derives on a later turn. Confirming a refusal would make saying no more expensive than saying yes. The delete of a plan the person actually RAN is confirmed, and that is the one carrying history.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/insights/coach-panel/use-coach.ts",
|
||||
destroys: "a whole Coach conversation and every message in it",
|
||||
triggers: [
|
||||
"components/insights/coach-panel/history-rail.tsx",
|
||||
"app/coach/conversations/page.tsx",
|
||||
],
|
||||
recovery: "permanent",
|
||||
confirm: [],
|
||||
unconfirmed: [
|
||||
{
|
||||
control:
|
||||
"delete conversation — history-rail.tsx and conversations page",
|
||||
reason:
|
||||
"This surface deliberately chose undo over confirmation: the delete is held for a few seconds behind an undo toast, and the earlier arm-then-confirm double tap was removed on purpose. Adding a dialog back would give the same act two gates. The pending-delete flush on unmount is a real hole in that promise and is tracked separately; it is a defect in the undo, not an argument for a confirmation.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/insights/coach-panel/coach-conversation.tsx",
|
||||
destroys:
|
||||
"the stored list of pending clarifying questions, and document attachments on a conversation",
|
||||
recovery: "permanent",
|
||||
confirm: [],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "dismiss questions; detach a document",
|
||||
reason:
|
||||
"Neither touches something the person wrote. The questions are assistant-generated suggestions that the extractor regenerates on later turns, and dismissing them IS the affordance. Detaching removes a join row only — the document itself stays in the vault and is two taps from being re-attached.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Preferences and layout a person arranged by hand ─────────────────────
|
||||
{
|
||||
file: "components/settings/thresholds-editor-section.tsx",
|
||||
destroys: "the custom range set for one metric, or for every metric",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton", "ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/targets/target-edit-sheet.tsx",
|
||||
destroys:
|
||||
"the custom target range for one metric — both halves at once for blood pressure",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/dashboard-layout-section.tsx",
|
||||
destroys:
|
||||
"the dashboard arrangement: tile order, hidden tiles, ring choice",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/insights/insights-edit-mode.tsx",
|
||||
destroys: "the Insights overview arrangement: section order and visibility",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/environment-section.tsx",
|
||||
destroys: "a saved travel location",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/web-push-card.tsx",
|
||||
destroys: "the browser push subscription row",
|
||||
recovery: "restorable",
|
||||
confirm: [],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "unsubscribe from push notifications",
|
||||
reason:
|
||||
"This is the off half of a notification channel toggle. Re-subscribing from the same control recreates the row, nothing the person authored is stored in it, and the browser permission survives. Gating it would put a dialog on a switch.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Measurements, mood, labs, illness — the undo-and-restore surfaces ────
|
||||
{
|
||||
file: "components/measurements/measurement-list.tsx",
|
||||
destroys: "one measurement, or every selected measurement",
|
||||
recovery: "restorable",
|
||||
confirm: ["DeleteButton", "SelectionActionBar", "AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/mood/mood-list.tsx",
|
||||
destroys: "one mood entry, or every selected mood entry",
|
||||
recovery: "restorable",
|
||||
confirm: ["DeleteButton", "SelectionActionBar", "AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/labs/lab-history-list.tsx",
|
||||
destroys: "one lab reading",
|
||||
recovery: "restorable",
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "components/illness/use-illness.ts",
|
||||
destroys: "an illness episode with its day logs and notes",
|
||||
triggers: ["components/illness/episode-menu.tsx"],
|
||||
recovery: "restorable",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/labs/biomarker-manager.tsx",
|
||||
destroys: "a biomarker AND every lab reading recorded against it",
|
||||
recovery: "permanent",
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "components/labs/lab-biomarker-detail.tsx",
|
||||
destroys: "a biomarker AND every lab reading recorded against it",
|
||||
recovery: "permanent",
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "components/custom-metrics/custom-metric-detail.tsx",
|
||||
destroys: "a custom metric definition",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "components/custom-metrics/custom-metric-history-list.tsx",
|
||||
destroys: "one custom-metric reading",
|
||||
recovery: "permanent",
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "components/records/allergy-manager.tsx",
|
||||
destroys: "one allergy record",
|
||||
recovery: "permanent",
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "components/records/family-history-manager.tsx",
|
||||
destroys: "one family-history entry",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "hooks/use-measurement-reminders.ts",
|
||||
destroys: "a checkup or measurement reminder with its schedule",
|
||||
triggers: ["components/measurement-reminders/vorsorge-section.tsx"],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
|
||||
// ── Cycle ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/cycle/use-cycle.ts",
|
||||
destroys:
|
||||
"a whole logged cycle day — flow, temperature, symptoms, note — or a custom symptom from the catalogue",
|
||||
triggers: ["components/cycle/log-day-sheet.tsx"],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton", "ConfirmDialog"],
|
||||
},
|
||||
|
||||
// ── Medications ─────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/medications/sections/destructive-zone-section.tsx",
|
||||
destroys:
|
||||
"every intake event for a medication, or the medication with its schedules and inventory",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/medications/intake-history-editable.tsx",
|
||||
destroys: "one intake event, or every selected intake event",
|
||||
triggers: ["components/medications/intake-history-list-v2.tsx"],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/medications/dose-history-ledger.tsx",
|
||||
destroys: "one intake event",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/medications/side-effects-section.tsx",
|
||||
destroys: "one side-effect log entry",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/medications/sections/inventory-section.tsx",
|
||||
destroys: "one inventory pack record",
|
||||
recovery: "permanent",
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "components/medications/scheduling/schedule-history-timeline.tsx",
|
||||
destroys: "one schedule revision, re-segmenting the dosing history",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/medications/use-medication-intake.ts",
|
||||
destroys: "an intake event recorded seconds ago",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: [],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "Undo inside the intake success toast",
|
||||
reason:
|
||||
"This IS the undo for a create the person made a moment earlier, reached only from that create's own toast. Confirming an undo would ask someone to confirm a correction, which inverts what the affordance is for.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Mood tags ───────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/mood/manage/archived-tags-card.tsx",
|
||||
destroys:
|
||||
"an archived custom mood tag AND every mood entry's link to it, by cascade",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/mood/manage/tag-groups-card.tsx",
|
||||
destroys: "a custom mood tag group (its member tags are rehomed, not lost)",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
|
||||
// ── Documents ───────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/documents/document-detail-sheet.tsx",
|
||||
destroys: "one document in the vault",
|
||||
recovery: "restorable",
|
||||
confirm: [],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "Delete in the document detail sheet",
|
||||
reason:
|
||||
"Documents chose undo over confirmation across every one of its delete paths, and it is the one domain where that choice is fully backed: the row is soft-deleted, a restore route exists, and the success toast carries the undo that calls it. Layering a dialog on top would gate a reversible act on the surface that already handles reversibility best.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/documents/documents-view.tsx",
|
||||
destroys: "every selected document, or one document from a focused card",
|
||||
triggers: [
|
||||
"components/documents/document-bulk-bar.tsx",
|
||||
"components/documents/document-card.tsx",
|
||||
],
|
||||
recovery: "restorable",
|
||||
confirm: [],
|
||||
undetected:
|
||||
'The bulk route is POST /api/documents/inbound/bulk with the verb in the body ({ action: "delete" }), so no detector sees it: no DELETE goes out, the path names no act, and nothing is blanked. Registered by hand.',
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "bulk delete, and the Delete/Backspace key on a focused card",
|
||||
reason:
|
||||
"Same undo-not-confirmation choice as the detail sheet, and the bulk path raises one aggregate undo for the whole batch. The keyboard path is the weakest link in that argument, since a keystroke needs no aim and the undo lives only in a toast with no trash view behind it; it is recorded here rather than silently accepted, and closing it means giving documents a recovery surface, not a dialog.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Admin ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/admin/danger-zone-section.tsx",
|
||||
destroys: "every user's records on the instance",
|
||||
recovery: "permanent",
|
||||
confirm: ["typed-phrase"],
|
||||
},
|
||||
{
|
||||
file: "components/admin/invite-tokens-section.tsx",
|
||||
destroys: "an unredeemed invite token — the link stays dead",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/admin/user-management-section.tsx",
|
||||
destroys: "another user's sessions, or their stored password hash",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
|
||||
// ── Integrations ────────────────────────────────────────────────────────
|
||||
...(
|
||||
[
|
||||
["withings-card.tsx", "Withings"],
|
||||
["fitbit-card.tsx", "Fitbit"],
|
||||
["whoop-card.tsx", "WHOOP"],
|
||||
["google-health-card.tsx", "Google Health"],
|
||||
["nightscout-card.tsx", "Nightscout"],
|
||||
["oauth-provider-card.tsx", "the generic OAuth providers"],
|
||||
] as const
|
||||
).map(([f, name]): DestructiveEntry => ({
|
||||
file: `components/settings/integrations/${f}`,
|
||||
destroys: `the stored ${name} credentials — reconnecting means authorising again`,
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
})),
|
||||
];
|
||||
|
||||
// ── Detectors ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** D1 — a DELETE request goes out. */
|
||||
const D1 = /apiDelete[<(]|method:\s*"DELETE"/;
|
||||
/** D2 — a route path whose own name is the destructive act. */
|
||||
const D2 =
|
||||
/["`]\/api\/[^"`?\s]*(?:disconnect|revoke|reset|purge|clear|wipe|bulk-delete)[^"`?\s]*/;
|
||||
/** D3 — a write payload that blanks a named field. */
|
||||
const D3 =
|
||||
/(?:\.mutate|\.mutateAsync|apiPut|apiPatch|apiPost)[<(]?\(\s*(?:"[^"]*",\s*)?\{[^{}]*?\b\w+:\s*(?:""|null)/;
|
||||
|
||||
/**
|
||||
* Where a destructive CONTROL can live. Route handlers are the other end of
|
||||
* the wire, not a control, and `src/lib` holds no JSX — its three matches are
|
||||
* the `apiDelete` helper itself, an audit-path string constant and an OpenAPI
|
||||
* path literal. Both exclusions are asserted below so neither can be widened
|
||||
* into a hiding place without this test noticing.
|
||||
*/
|
||||
const CONTROL_ROOTS = ["app/", "components/", "hooks/"];
|
||||
const EXCLUDED_PREFIXES = ["app/api/", "generated/", "lib/"];
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
function isControlFile(rel: string): boolean {
|
||||
if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) return false;
|
||||
return CONTROL_ROOTS.some((p) => rel.startsWith(p));
|
||||
}
|
||||
|
||||
function detect(rel: string): string[] {
|
||||
const src = read(rel);
|
||||
const tags: string[] = [];
|
||||
if (D1.test(src)) tags.push("D1");
|
||||
if (D2.test(src)) tags.push("D2");
|
||||
if (D3.test(src)) tags.push("D3");
|
||||
return tags;
|
||||
}
|
||||
|
||||
let detectedCache: string[] | null = null;
|
||||
function detectedFiles(): string[] {
|
||||
detectedCache ??= sourceFiles().filter(
|
||||
(rel) => isControlFile(rel) && detect(rel).length,
|
||||
);
|
||||
return detectedCache;
|
||||
}
|
||||
|
||||
const CONFIRM_MARKERS: Record<ConfirmKind, RegExp> = {
|
||||
ConfirmButton: /<ConfirmButton\b/,
|
||||
ConfirmDialog: /<ConfirmDialog\b/,
|
||||
DeleteButton: /<DeleteButton\b/,
|
||||
SelectionActionBar: /<SelectionActionBar\b/,
|
||||
AlertDialog: /<AlertDialogAction\b/,
|
||||
// A confirm step rendered in place rather than in a dialog: the first tap
|
||||
// swaps the control for an explanation plus a separate, differently-labelled
|
||||
// confirm button. Marked by the `-confirm` slot that second button carries.
|
||||
"inline-two-step": /data-slot="[\w-]+-confirm"/,
|
||||
// An admin-grade gate: the exact word has to be typed before the action arms.
|
||||
"typed-phrase": /CONFIRM_TOKEN|confirmText|typedConfirm/,
|
||||
};
|
||||
|
||||
const byFile = new Map(REGISTRY.map((e) => [e.file, e]));
|
||||
|
||||
describe("T1 — every destructive control is registered", () => {
|
||||
it("the registry names every file the detectors flag", () => {
|
||||
const missing = detectedFiles().filter((rel) => !byFile.has(rel));
|
||||
expect(
|
||||
missing,
|
||||
"A destructive request is issued from these files and nothing in the " +
|
||||
"registry accounts for it. Add an entry naming what it destroys, how " +
|
||||
"recoverable that is, and what gates it — or, if it genuinely does " +
|
||||
"not destroy anything, say so under `unconfirmed` with the argument.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries no stale entry", () => {
|
||||
const stale = REGISTRY.filter(
|
||||
(e) => !e.undetected && !detectedFiles().includes(e.file),
|
||||
).map((e) => e.file);
|
||||
expect(
|
||||
stale,
|
||||
"These entries no longer match any detector. If the control was removed, " +
|
||||
"delete the entry; if it was rewritten into a shape the detectors " +
|
||||
"cannot see, that is worth knowing before the entry is dropped.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("declares each file once", () => {
|
||||
expect(byFile.size).toBe(REGISTRY.length);
|
||||
});
|
||||
|
||||
it("pins the detector exclusions so they cannot quietly widen", () => {
|
||||
// `src/lib` and the route handlers are excluded because a control cannot
|
||||
// live there. If that stops being true the exclusion is a hiding place.
|
||||
expect(EXCLUDED_PREFIXES).toEqual(["app/api/", "generated/", "lib/"]);
|
||||
expect(CONTROL_ROOTS).toEqual(["app/", "components/", "hooks/"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T2 — every registered control names a real gate", () => {
|
||||
for (const entry of REGISTRY) {
|
||||
describe(entry.file, () => {
|
||||
const files = [entry.file, ...(entry.triggers ?? [])];
|
||||
|
||||
it("has a confirmation mechanism, or an argument for having none", () => {
|
||||
expect(
|
||||
entry.confirm.length > 0 || (entry.unconfirmed?.length ?? 0) > 0,
|
||||
`${entry.file}: neither a confirmation nor a reason for going without one`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
for (const kind of entry.confirm) {
|
||||
it(`the claimed ${kind} is actually present`, () => {
|
||||
const found = files.some((f) => CONFIRM_MARKERS[kind].test(read(f)));
|
||||
expect(
|
||||
found,
|
||||
`${entry.file} claims ${kind}, but no such control renders in ${files.join(" / ")}`,
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const skip of entry.unconfirmed ?? []) {
|
||||
it(`the case for leaving "${skip.control}" unconfirmed is written out`, () => {
|
||||
// A one-liner is a shrug. The floor is set where a sentence that
|
||||
// actually argues something has to be written.
|
||||
expect(
|
||||
skip.reason.trim().length,
|
||||
`${entry.file} → ${skip.control}: give the reason, not a note`,
|
||||
).toBeGreaterThanOrEqual(120);
|
||||
});
|
||||
}
|
||||
|
||||
it("every named trigger file exists", () => {
|
||||
for (const f of files) expect(() => read(f)).not.toThrow();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("T3 — a destroying payload never fires straight from a handler", () => {
|
||||
/**
|
||||
* The regression that started this: `onClick={() => save.mutate({ aboutMe:
|
||||
* "", allergies: "" })}`. One tap, two fields blanked, no gate. This walks
|
||||
* every event-handler attribute in every registered file and fails if a
|
||||
* destroying request sits inline inside one — which is the shape that skips
|
||||
* the dialog by construction rather than by oversight.
|
||||
*
|
||||
* Indirection through a named handler (`onClick={handleDisconnect}`) is out
|
||||
* of reach here and is covered instead by T2's requirement that the file
|
||||
* render a confirmation at all. Named, so nobody reads more into this.
|
||||
*/
|
||||
/**
|
||||
* Elements whose handlers ARE the confirmed path — the dialog's own action
|
||||
* button, and the primitives that own their dialog. A destroying call inside
|
||||
* one of these is the point, not the bug. Getting this list wrong in the
|
||||
* other direction is how a guard starts crying wolf, so it is short and
|
||||
* every member is a confirmation surface by construction.
|
||||
*/
|
||||
const CONFIRMED_ELEMENTS = new Set([
|
||||
"AlertDialogAction",
|
||||
"ConfirmButton",
|
||||
"ConfirmDialog",
|
||||
"DeleteButton",
|
||||
"SelectionActionBar",
|
||||
]);
|
||||
/** `onConfirm` is a confirmation callback wherever it appears. */
|
||||
const CONFIRMED_ATTRS = /^onConfirm$/;
|
||||
|
||||
/**
|
||||
* Walk the JSX opening tags and hand back `[element, handlerAttr, body]` for
|
||||
* every event handler, so the check can tell a raw `onClick` on a `Button`
|
||||
* from the `onClick` the dialog's action button carries.
|
||||
*/
|
||||
function handlers(src: string): { el: string; attr: string; body: string }[] {
|
||||
const out: { el: string; attr: string; body: string }[] = [];
|
||||
const TAG = /<([A-Z][\w.]*|[a-z][\w-]*)(?=[\s/>])/g;
|
||||
for (const tag of src.matchAll(TAG)) {
|
||||
const el = tag[1];
|
||||
// Scan to the end of THIS opening tag, tracking brace depth and strings
|
||||
// so an attribute expression containing `>` does not end it early.
|
||||
let i = tag.index + tag[0].length;
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
const attrs: { attr: string; body: string }[] = [];
|
||||
let pendingAttr: string | null = null;
|
||||
let bodyStart = 0;
|
||||
for (; i < src.length; i++) {
|
||||
const c = src[i];
|
||||
if (quote) {
|
||||
if (c === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (depth === 0 && (c === '"' || c === "'" || c === "`")) {
|
||||
quote = c;
|
||||
continue;
|
||||
}
|
||||
if (c === "{") {
|
||||
if (depth === 0) {
|
||||
const before = src.slice(Math.max(0, i - 60), i);
|
||||
const m = /\b(on[A-Z]\w*)=$/.exec(before);
|
||||
pendingAttr = m ? m[1] : null;
|
||||
bodyStart = i;
|
||||
}
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (c === "}") {
|
||||
depth--;
|
||||
if (depth === 0 && pendingAttr) {
|
||||
attrs.push({
|
||||
attr: pendingAttr,
|
||||
body: src.slice(bodyStart, i + 1),
|
||||
});
|
||||
pendingAttr = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (depth === 0 && c === ">") break;
|
||||
}
|
||||
for (const a of attrs) out.push({ el, ...a });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Unconditional, including the entries that argue for having no dialog. The
|
||||
// whole registry is clean of this shape today, so there is no exemption to
|
||||
// carve out and no reason to leave a door open for one. A control that
|
||||
// genuinely should fire without a gate still has somewhere to go: the
|
||||
// confirmed elements above, or a named handler the file can be read at.
|
||||
for (const entry of REGISTRY) {
|
||||
const files = [entry.file, ...(entry.triggers ?? [])].filter((f) =>
|
||||
f.endsWith(".tsx"),
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
it(`${file} — no inline destroying handler`, () => {
|
||||
const offenders = handlers(read(file))
|
||||
.filter(
|
||||
(h) =>
|
||||
!CONFIRMED_ELEMENTS.has(h.el) && !CONFIRMED_ATTRS.test(h.attr),
|
||||
)
|
||||
.filter((h) => D1.test(h.body) || D3.test(h.body))
|
||||
.map((h) => `<${h.el} ${h.attr}=${h.body.slice(0, 80)}`);
|
||||
expect(
|
||||
offenders,
|
||||
`${file}: a destroying request fires directly from an event handler. ` +
|
||||
`Route it through ConfirmButton / ConfirmDialog / DeleteButton, or ` +
|
||||
`move it behind a named handler the reviewer can read.`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("T4 — the registry answers the recoverability question", () => {
|
||||
it("every entry classifies what a person can get back", () => {
|
||||
for (const e of REGISTRY) {
|
||||
expect(
|
||||
["permanent", "tombstoned-no-restore", "restorable"],
|
||||
e.file,
|
||||
).toContain(e.recovery);
|
||||
expect(e.destroys.trim().length, e.file).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
it("nothing permanent is left unconfirmed without an argument", () => {
|
||||
const bare = REGISTRY.filter(
|
||||
(e) =>
|
||||
e.recovery === "permanent" &&
|
||||
e.confirm.length === 0 &&
|
||||
(e.unconfirmed?.length ?? 0) === 0,
|
||||
).map((e) => e.file);
|
||||
expect(bare).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* A minimal reader for the physical names `prisma/schema.prisma` declares.
|
||||
*
|
||||
* Prisma emits three kinds of identifier straight into the SQL it sends: the
|
||||
* enum type name, the table name, and the column name. Each one is the `@@map`
|
||||
* / `@map` value when present and the declared name verbatim when absent. If
|
||||
* any of the three names something the database does not have, the statement
|
||||
* dies at the driver with `42704` / `42P01` / `42703` — and nothing earlier in
|
||||
* the pipeline can see it, because generation, typecheck, and every mocked
|
||||
* unit test read the logical name only.
|
||||
*
|
||||
* Consumers: `prisma-enum-mapping-guard.test.ts` (the convention, no database)
|
||||
* and `tests/integration/prisma-schema-name-drift.test.ts` (the physical
|
||||
* comparison against a migrated Postgres).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const SCHEMA_PATH = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../prisma/schema.prisma",
|
||||
);
|
||||
|
||||
export interface DeclaredEnum {
|
||||
/** The enum name in the datamodel. */
|
||||
name: string;
|
||||
/** The `@@map` value, or null when the declaration carries none. */
|
||||
mappedName: string | null;
|
||||
/** The name Prisma puts in the SQL — `@@map` when present, else `name`. */
|
||||
physicalName: string;
|
||||
}
|
||||
|
||||
export interface DeclaredField {
|
||||
name: string;
|
||||
/** The `@map` value, or null when the field carries none. */
|
||||
mappedName: string | null;
|
||||
/** The column name Prisma puts in the SQL. */
|
||||
physicalName: string;
|
||||
}
|
||||
|
||||
export interface DeclaredModel {
|
||||
name: string;
|
||||
mappedName: string | null;
|
||||
/** The table name Prisma puts in the SQL. */
|
||||
physicalName: string;
|
||||
/** Scalar + enum fields only — relation fields carry no column. */
|
||||
fields: DeclaredField[];
|
||||
}
|
||||
|
||||
export interface DeclaredSchema {
|
||||
enums: DeclaredEnum[];
|
||||
models: DeclaredModel[];
|
||||
}
|
||||
|
||||
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*(.*)$/;
|
||||
|
||||
/** Drop `//` and `///` lines so prose can never read as a declaration. */
|
||||
function stripComments(block: string): string {
|
||||
return block
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the datamodel. Deliberately regex-shaped rather than a real parser: the
|
||||
* guard has to run inside `pnpm test` with no database and no Prisma engine,
|
||||
* and the three declarations it reads (`enum`, `model`, field lines) are the
|
||||
* most stable syntax in the file. Both consumers assert a floor on how much
|
||||
* they found, so a parser that silently stops matching fails loudly instead of
|
||||
* passing vacuously.
|
||||
*/
|
||||
export function readDeclaredSchema(
|
||||
schemaPath: string = SCHEMA_PATH,
|
||||
): DeclaredSchema {
|
||||
const source = readFileSync(schemaPath, "utf8");
|
||||
const enums: DeclaredEnum[] = [];
|
||||
const rawModels: Array<{ name: string; body: string }> = [];
|
||||
|
||||
const block = /^(model|enum)\s+(\w+)\s*\{([\s\S]*?)^\}/gm;
|
||||
for (let m = block.exec(source); m !== null; m = block.exec(source)) {
|
||||
const [, kind, name, body] = m;
|
||||
const clean = stripComments(body);
|
||||
const mappedName = BLOCK_MAP.exec(clean)?.[1] ?? null;
|
||||
if (kind === "enum") {
|
||||
enums.push({ name, mappedName, physicalName: mappedName ?? name });
|
||||
} else {
|
||||
rawModels.push({ name, body: clean });
|
||||
}
|
||||
}
|
||||
|
||||
const modelNames = new Set(rawModels.map((m) => m.name));
|
||||
|
||||
const models = rawModels.map(({ name, body }) => {
|
||||
const mappedName = BLOCK_MAP.exec(body)?.[1] ?? null;
|
||||
const fields: DeclaredField[] = [];
|
||||
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;
|
||||
// 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;
|
||||
const fieldMapped = FIELD_MAP.exec(rest)?.[1] ?? null;
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
mappedName: fieldMapped,
|
||||
physicalName: fieldMapped ?? fieldName,
|
||||
});
|
||||
}
|
||||
return { name, mappedName, physicalName: mappedName ?? name, fields };
|
||||
});
|
||||
|
||||
return { enums, models };
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Structural guard — a Prisma enum cannot name a type the migrations never
|
||||
* created.
|
||||
*
|
||||
* `EnvironmentLocationSource` was declared without an `@@map` while migration
|
||||
* 0217 created the type as `environment_location_source`. Prisma writes the
|
||||
* enum's physical name straight into the SQL, so every insert into
|
||||
* `environment_contexts` asked Postgres for `public."EnvironmentLocationSource"`
|
||||
* and got `42704`. The module read "0 days recorded" from the day it shipped.
|
||||
*
|
||||
* Nothing upstream could see it: the type is right in the generated client, the
|
||||
* datamodel is internally consistent, and every mocked unit test only ever
|
||||
* exercises the logical name. One enum out of forty-nine drifted, and it took a
|
||||
* self-hoster reading Postgres error codes to find it.
|
||||
*
|
||||
* So the physical name is checked against the only other place that states it —
|
||||
* the migration SQL. `CREATE TYPE`, `ALTER TYPE … RENAME TO`, and `DROP TYPE`
|
||||
* are replayed in migration order, and every declared enum has to land on a
|
||||
* type that survives to the end. The `@@map`-present rule is asserted
|
||||
* separately, because an unmapped PascalCase enum is the specific mistake that
|
||||
* happened and the message should say so rather than "type not found".
|
||||
*
|
||||
* Limits, stated plainly. This reads SQL text, not a database: it proves the
|
||||
* migrations create the type, not that any particular deployment's database
|
||||
* matches its migration ledger. It covers enum types only. The comprehensive
|
||||
* check — every enum, table, and column name compared against a real migrated
|
||||
* Postgres — is `tests/integration/prisma-schema-name-drift.test.ts`, which
|
||||
* this guard exists to front-run at unit speed.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { readDeclaredSchema } from "./helpers/prisma-schema-names";
|
||||
|
||||
const MIGRATIONS_DIR = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../prisma/migrations",
|
||||
);
|
||||
|
||||
const CREATE_TYPE = /CREATE\s+TYPE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([\w.]+)"?/gi;
|
||||
const RENAME_TYPE =
|
||||
/ALTER\s+TYPE\s+"?([\w.]+)"?\s+RENAME\s+TO\s+"?([\w.]+)"?/gi;
|
||||
const DROP_TYPE = /DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?"?([\w.]+)"?/gi;
|
||||
|
||||
/** `public.foo` and `foo` are the same type; the guard compares bare names. */
|
||||
function bare(name: string): string {
|
||||
return name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statements that are commented out (the rollback recipes several migrations
|
||||
* carry in a `--` block) must not count as executed SQL.
|
||||
*/
|
||||
function executableSql(sql: string): string {
|
||||
return sql
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*--/.test(line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Replay every migration in order and return the types left standing. */
|
||||
function liveEnumTypes(): Set<string> {
|
||||
const live = new Set<string>();
|
||||
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
for (const dir of dirs) {
|
||||
let sql: string;
|
||||
try {
|
||||
sql = readFileSync(join(MIGRATIONS_DIR, dir, "migration.sql"), "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const statements = executableSql(sql);
|
||||
|
||||
// Order within one file matters: a migration that drops and recreates a
|
||||
// type under a new name has to be replayed as written, so each statement
|
||||
// kind is applied at its own offset rather than kind-by-kind.
|
||||
const events: Array<{ at: number; apply: () => void }> = [];
|
||||
for (const m of statements.matchAll(CREATE_TYPE)) {
|
||||
const name = bare(m[1]);
|
||||
events.push({ at: m.index, apply: () => void live.add(name) });
|
||||
}
|
||||
for (const m of statements.matchAll(RENAME_TYPE)) {
|
||||
const from = bare(m[1]);
|
||||
const to = bare(m[2]);
|
||||
events.push({
|
||||
at: m.index,
|
||||
apply: () => {
|
||||
live.delete(from);
|
||||
live.add(to);
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const m of statements.matchAll(DROP_TYPE)) {
|
||||
const name = bare(m[1]);
|
||||
events.push({ at: m.index, apply: () => void live.delete(name) });
|
||||
}
|
||||
for (const event of events.sort((a, b) => a.at - b.at)) event.apply();
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
describe("prisma enums — the declared type is the migrated type", () => {
|
||||
const { enums } = readDeclaredSchema();
|
||||
|
||||
it("reads the schema (a parser that stopped matching cannot pass vacuously)", () => {
|
||||
expect(enums.length).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it("declares an @@map on every enum", () => {
|
||||
const unmapped = enums
|
||||
.filter((declared) => declared.mappedName === null)
|
||||
.map((declared) => declared.name);
|
||||
expect(
|
||||
unmapped,
|
||||
unmapped.length > 0
|
||||
? `Enum(s) without @@map — Prisma will ask Postgres for the PascalCase ` +
|
||||
`type name, which no migration creates: ${unmapped.join(", ")}`
|
||||
: undefined,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds the migrated types (a broken replay cannot pass vacuously)", () => {
|
||||
expect(liveEnumTypes().size).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it("points every enum at a type the migrations leave standing", () => {
|
||||
const live = liveEnumTypes();
|
||||
const missing = enums
|
||||
.filter((declared) => !live.has(declared.physicalName))
|
||||
.map((declared) => `${declared.name} → "${declared.physicalName}"`);
|
||||
expect(
|
||||
missing,
|
||||
missing.length > 0
|
||||
? `Enum(s) naming a type no migration creates:\n ${missing.join("\n ")}`
|
||||
: undefined,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -212,6 +212,10 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/medications/compliance/route.ts",
|
||||
"src/app/api/medications/intake/route.ts",
|
||||
"src/app/api/medications/intake/bulk/route.ts",
|
||||
// The account-wide dose-history import is the same class as the
|
||||
// per-medication importer below: it writes intake rows and reads its own job.
|
||||
"src/app/api/medications/intake/dose-history-import/route.ts",
|
||||
"src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts",
|
||||
// NB: `medications/extract` is NOT exempt — it gates on
|
||||
// `requireAssistantSurface("coach")` (the NL-extraction is an assistant
|
||||
// surface), so the inventory already counts it as a delegated gate.
|
||||
|
||||
@@ -3,6 +3,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
import {
|
||||
JOB_FAILURE_WINDOW_HOURS,
|
||||
readFailingQueues,
|
||||
} from "@/lib/jobs/job-failures";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Build timestamp (set at build time via next.config)
|
||||
@@ -57,6 +61,12 @@ export const GET = apiHandler(async () => {
|
||||
appSettings?.webPushVapidSubject,
|
||||
);
|
||||
const workerStatus = getWorkerStatus();
|
||||
// `worker.errors` is one unnamed counter, which is how a queue that failed on
|
||||
// every run for months could sit behind a number nobody could act on. The
|
||||
// queue itself knows which job broke and why, so that is what the status
|
||||
// reports. `null` = no queue schema to ask (web-only deployment); an empty
|
||||
// array = asked, nothing is failing.
|
||||
const failingJobs = await readFailingQueues();
|
||||
|
||||
return apiSuccess({
|
||||
version: process.env.npm_package_version ?? "0.1.0",
|
||||
@@ -66,6 +76,10 @@ export const GET = apiHandler(async () => {
|
||||
startTime: START_TIME,
|
||||
database: "connected",
|
||||
worker: workerStatus,
|
||||
failingJobs:
|
||||
failingJobs === null
|
||||
? null
|
||||
: { windowHours: JOB_FAILURE_WINDOW_HOURS, queues: failingJobs },
|
||||
counts: {
|
||||
users: userCount,
|
||||
measurements: measurementCount,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* `GET /api/allergies/{id}` — one owned, live allergy record.
|
||||
* `PATCH /api/allergies/{id}` — edit it (partial; field-by-field).
|
||||
* `DELETE /api/allergies/{id}` — soft-delete it (idempotent; `{ deleted: true }`).
|
||||
* `DELETE /api/allergies/{id}` — delete it outright (`{ deleted: true }`; 404
|
||||
* once it is gone). The confirmation dialog promises it cannot be undone, and
|
||||
* nothing here needs a tombstone to keep that promise honest.
|
||||
*
|
||||
* Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`;
|
||||
* the body never carries it. The free-text `reaction` + `note` are encrypted
|
||||
@@ -117,23 +119,25 @@ export const DELETE = apiHandler(
|
||||
const { id } = await params;
|
||||
const existing = await prisma.allergy.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Allergy not found", 404);
|
||||
}
|
||||
|
||||
if (existing.deletedAt === null) {
|
||||
await prisma.allergy.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
await auditLog("allergy.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: id },
|
||||
});
|
||||
}
|
||||
// Hard delete. The confirmation says it can't be undone, and nothing here
|
||||
// needs a tombstone: no restore route reaches an allergy, no other table
|
||||
// references one, it is absent from the `/api/sync/changes` delta feed,
|
||||
// and every read already filters `deletedAt: null`. A row already carrying
|
||||
// a tombstone from before this changed is removed too, so the record
|
||||
// converges on what the dialog promised.
|
||||
await prisma.allergy.delete({ where: { id } });
|
||||
|
||||
await auditLog("allergy.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.delete", entity_type: "allergy", entity_id: id },
|
||||
|
||||
@@ -12,6 +12,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { OPEN_METEO_ATTRIBUTION } from "@/lib/environment/open-meteo";
|
||||
import { ENVIRONMENT_FETCH_QUEUE } from "@/lib/jobs/environment-fetch";
|
||||
import { readQueueFailureForUser } from "@/lib/jobs/job-failures";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,37 +23,43 @@ export const GET = apiHandler(async () => {
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const [profile, travel, contextCount, latest] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
homeLat: true,
|
||||
homeLon: true,
|
||||
homeLabel: true,
|
||||
homeTimezone: true,
|
||||
homeSince: true,
|
||||
timezone: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentTravelLocation.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lat: true,
|
||||
lon: true,
|
||||
label: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentContext.count({ where: { userId: user.id } }),
|
||||
prisma.environmentContext.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "desc" },
|
||||
select: { date: true, fetchedAt: true },
|
||||
}),
|
||||
]);
|
||||
const [profile, travel, contextCount, latest, lastFetchFailure] =
|
||||
await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
homeLat: true,
|
||||
homeLon: true,
|
||||
homeLabel: true,
|
||||
homeTimezone: true,
|
||||
homeSince: true,
|
||||
timezone: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentTravelLocation.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lat: true,
|
||||
lon: true,
|
||||
label: true,
|
||||
},
|
||||
}),
|
||||
prisma.environmentContext.count({ where: { userId: user.id } }),
|
||||
prisma.environmentContext.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "desc" },
|
||||
select: { date: true, fetchedAt: true },
|
||||
}),
|
||||
// A day count on its own cannot tell an empty module from a broken one —
|
||||
// which is exactly how a job that failed on every run since the module
|
||||
// shipped could render as "0 days recorded" and nothing else. The queue
|
||||
// knows; the overview asks it.
|
||||
readQueueFailureForUser(ENVIRONMENT_FETCH_QUEUE, user.id),
|
||||
]);
|
||||
|
||||
const home =
|
||||
profile?.homeLat != null && profile?.homeLon != null
|
||||
@@ -72,6 +80,7 @@ export const GET = apiHandler(async () => {
|
||||
has_home: home !== null,
|
||||
travel_count: travel.length,
|
||||
context_days: contextCount,
|
||||
last_fetch_failed: lastFetchFailure !== null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,6 +92,10 @@ export const GET = apiHandler(async () => {
|
||||
latestDate: latest?.date ?? null,
|
||||
latestFetchedAt: latest?.fetchedAt?.toISOString() ?? null,
|
||||
},
|
||||
// null = the last background runs did not fail (or there is no queue to
|
||||
// ask). Non-null names the failure so a zero day count is never left to
|
||||
// stand alone. No error text: that message is written for an operator.
|
||||
lastFetchFailure,
|
||||
attribution: OPEN_METEO_ATTRIBUTION,
|
||||
});
|
||||
});
|
||||
@@ -695,7 +695,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// ("you've logged 42 BP readings") stays grounded.
|
||||
const presentToolPayloads = [
|
||||
...(workoutEvidence === null ? [] : [workoutEvidence]),
|
||||
...(loop.toolResults ?? []).map((r) => r.data),
|
||||
// A miss carries no `data` but may carry `available` — the bounded
|
||||
// out-of-window aggregate rule 3 lets the model cite. Ground it.
|
||||
...(loop.toolResults ?? []).map((r) => r.data ?? r.available),
|
||||
];
|
||||
toolResultPayloads =
|
||||
presentToolPayloads.length > 0
|
||||
|
||||
@@ -68,6 +68,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
||||
@@ -597,10 +598,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// v1.10.0 — fold a notable derived wellness signal (readiness / recovery
|
||||
// shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block.
|
||||
// Reads the one derived contract; null (the common case) costs nothing.
|
||||
const derivedSex =
|
||||
dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE"
|
||||
? (dbUser.gender as "MALE" | "FEMALE")
|
||||
: null;
|
||||
const derivedSex = toProfileSex(dbUser?.gender);
|
||||
const derivedBriefing = await detectDerivedBriefingSignals(userId, {
|
||||
ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null),
|
||||
sex: derivedSex,
|
||||
|
||||
@@ -29,6 +29,7 @@ const ledger: Record<string, unknown> = {
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
failingSince: null,
|
||||
};
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
|
||||
@@ -239,3 +240,40 @@ describe("/api/integrations/status — sync-health verdict on every entry", () =
|
||||
expect(nightscout!.syncHealth).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/integrations/status — a provider that has delivered nothing for weeks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(perIntegrationLedger)) {
|
||||
delete perIntegrationLedger[key];
|
||||
}
|
||||
userFind.mockResolvedValue({});
|
||||
whoopFind.mockResolvedValue(null);
|
||||
polarAvailable.mockResolvedValue(true);
|
||||
ouraAvailable.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("reports it as failing and dates the verdict from the streak, not the retry", async () => {
|
||||
const failingSince = new Date(
|
||||
Date.now() - 17 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
perIntegrationLedger.nightscout = {
|
||||
state: "error_transient",
|
||||
// The hourly cron is still running and a sibling leg can still stamp a
|
||||
// success, so neither of these tells the user anything.
|
||||
lastSuccessAt: new Date(Date.now() - 20 * 60_000).toISOString(),
|
||||
lastAttemptAt: new Date(Date.now() - 4 * 60_000).toISOString(),
|
||||
failingSince,
|
||||
lastError: "Nightscout sync HTTP 502",
|
||||
};
|
||||
userFind.mockResolvedValue({ nightscoutUrlEncrypted: "enc" });
|
||||
|
||||
const nightscout = (await fetchEntries()).find(
|
||||
(entry) => entry.integration === "nightscout",
|
||||
)!;
|
||||
expect(nightscout.syncHealth).toEqual({
|
||||
verdict: "failing",
|
||||
since: failingSince,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getIntegrationStatus,
|
||||
getPersistentFailureThreshold,
|
||||
type IntegrationKey,
|
||||
type IntegrationStatusSnapshot,
|
||||
} from "@/lib/integrations/status";
|
||||
import {
|
||||
getSourceMetricFreshness,
|
||||
@@ -155,6 +156,25 @@ export const GET = apiHandler(async () => {
|
||||
const now = Date.now();
|
||||
const whoopConnected = !!whoopConn?.whoopUserId;
|
||||
|
||||
/**
|
||||
* The ledger fields the envelope publishes, named one by one.
|
||||
*
|
||||
* `failingSince` is deliberately not among them. It is the input to
|
||||
* `syncHealth.since`, which every consumer already reads, and putting both on
|
||||
* the wire would be two names for one fact — the second of which nothing
|
||||
* renders.
|
||||
*/
|
||||
function publish(snapshot: IntegrationStatusSnapshot) {
|
||||
return {
|
||||
integration: snapshot.integration,
|
||||
state: snapshot.state,
|
||||
lastSuccessAt: snapshot.lastSuccessAt,
|
||||
lastAttemptAt: snapshot.lastAttemptAt,
|
||||
lastError: snapshot.lastError,
|
||||
consecutiveFailuresByKind: snapshot.consecutiveFailuresByKind,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The sync-health verdict + the per-metric staleness flags, resolved from
|
||||
* data already in hand — no additional query. Every entry carries it,
|
||||
@@ -168,6 +188,7 @@ export const GET = apiHandler(async () => {
|
||||
state: string;
|
||||
lastSuccessAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
failingSince: string | null;
|
||||
},
|
||||
opts: {
|
||||
connected?: boolean;
|
||||
@@ -181,6 +202,7 @@ export const GET = apiHandler(async () => {
|
||||
state: snapshot.state,
|
||||
lastSuccessAt: snapshot.lastSuccessAt,
|
||||
lastAttemptAt: snapshot.lastAttemptAt,
|
||||
failingSinceAt: snapshot.failingSince,
|
||||
legacyLastSyncedAt: opts.legacyLastSyncedAt ?? null,
|
||||
cadence: INTEGRATION_CADENCE[integration],
|
||||
now,
|
||||
@@ -225,7 +247,7 @@ export const GET = apiHandler(async () => {
|
||||
threshold: getPersistentFailureThreshold(),
|
||||
integrations: [
|
||||
{
|
||||
...withingsStatus,
|
||||
...publish(withingsStatus),
|
||||
configured: withingsConfigured,
|
||||
connected: !!withingsConn,
|
||||
connectedAt: withingsConn?.createdAt?.toISOString() ?? null,
|
||||
@@ -249,7 +271,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & WithingsExtras,
|
||||
{
|
||||
...whoopStatus,
|
||||
...publish(whoopStatus),
|
||||
configured: whoopConfigured,
|
||||
connected: whoopConnected,
|
||||
connectedAt: whoopConnected
|
||||
@@ -272,7 +294,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & WhoopExtras,
|
||||
{
|
||||
...fitbitStatus,
|
||||
...publish(fitbitStatus),
|
||||
configured: fitbitConfigured,
|
||||
connected: fitbitConnected,
|
||||
connectedAt: fitbitConn?.createdAt?.toISOString() ?? null,
|
||||
@@ -289,7 +311,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & FitbitExtras,
|
||||
{
|
||||
...googleHealthStatus,
|
||||
...publish(googleHealthStatus),
|
||||
configured: googleHealthConfigured,
|
||||
connected: googleHealthConnected,
|
||||
connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null,
|
||||
@@ -312,7 +334,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & GoogleHealthExtras,
|
||||
{
|
||||
...polarStatus,
|
||||
...publish(polarStatus),
|
||||
// `connected` = a stored access token; `configured` mirrors it (the
|
||||
// OAuth card has no separate "credentials saved but disconnected" view
|
||||
// beyond `hasOwnCredentials`). The card greys out the connect button
|
||||
@@ -329,7 +351,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
{
|
||||
...ouraStatus,
|
||||
...publish(ouraStatus),
|
||||
connected: ouraConnected,
|
||||
configured: ouraConnected,
|
||||
available: ouraAvailable,
|
||||
@@ -342,7 +364,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
{
|
||||
...stravaStatus,
|
||||
...publish(stravaStatus),
|
||||
connected: stravaConnected,
|
||||
configured: stravaConnected,
|
||||
available: stravaAvailable,
|
||||
@@ -357,7 +379,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
{
|
||||
...nightscoutStatus,
|
||||
...publish(nightscoutStatus),
|
||||
// Nightscout is a URL the self-hoster pastes once; `configured` (a
|
||||
// stored URL) is the connect marker, and a fully-public instance has
|
||||
// no token. The card reads these off the envelope now instead of its
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete.
|
||||
*
|
||||
* PATCH re-derives the server-authoritative `nextDueAt` after applying
|
||||
* the (mutually-exclusive cadence) edit. DELETE soft-deletes (tombstone)
|
||||
* for parity with the rest of the tree.
|
||||
* the (mutually-exclusive cadence) edit. DELETE removes the row; the
|
||||
* confirmation dialog says the reminder is permanently deleted, and nothing
|
||||
* here needs a tombstone to keep that promise honest.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -206,12 +207,13 @@ export const DELETE = apiHandler(
|
||||
return apiError("Measurement reminder not found", 404);
|
||||
}
|
||||
|
||||
// Soft-delete (tombstone) parity with the rest of the tree. A
|
||||
// re-delete of an already-tombstoned row is idempotent.
|
||||
await prisma.measurementReminder.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
// Hard delete. The confirmation says "permanently deleted", and this row
|
||||
// has nothing that a tombstone would serve: it is not in the
|
||||
// `/api/sync/changes` delta feed, no restore route reaches it, no other
|
||||
// table references it, and every read already filters `deletedAt: null`.
|
||||
// The tombstone bought parity with tables that need one and cost the
|
||||
// dialog its accuracy, so the row goes.
|
||||
await prisma.measurementReminder.delete({ where: { id } });
|
||||
|
||||
await auditLog("measurementReminder.delete", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readMedicationIntakeImportJob } from "@/lib/medications/intake-import-job-status";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string; jobId: string }>;
|
||||
@@ -20,20 +20,12 @@ export const GET = apiHandler(
|
||||
meta: { job_id: jobId, medication_id: medicationId },
|
||||
});
|
||||
|
||||
const row = await prisma.medicationIntakeImportJob.findFirst({
|
||||
where: { id: jobId, medicationId, userId: user.id },
|
||||
});
|
||||
if (!row) return apiError("Import job not found", 404);
|
||||
|
||||
return apiSuccess({
|
||||
jobId: row.id,
|
||||
status: row.status,
|
||||
progress: row.progress,
|
||||
result: row.result,
|
||||
failureReason: row.failureReason,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
startedAt: row.startedAt?.toISOString() ?? null,
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
});
|
||||
const job = await readMedicationIntakeImportJob(
|
||||
user.id,
|
||||
jobId,
|
||||
medicationId,
|
||||
);
|
||||
if (!job) return apiError("Import job not found", 404);
|
||||
return apiSuccess(job);
|
||||
},
|
||||
);
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{ "datum": "2026-05-31", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-01", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-02", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-03", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-04", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-05", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-06", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-07", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-08", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-09", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-10", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-11", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-12", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-13", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-14", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-15", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-16", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-17", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-18", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-19", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-20", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-21", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-22", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-23", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-24", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-25", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-26", "uhrzeit": "08:30:00", "zaehler": 1.0 },
|
||||
{ "datum": "2026-06-27", "uhrzeit": "08:30:00", "zaehler": 1.0 }
|
||||
]
|
||||
@@ -4,6 +4,9 @@
|
||||
* The request process validates and persists the complete payload, then
|
||||
* returns a polling handle without performing intake writes itself.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -233,14 +236,23 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
status: "queued",
|
||||
// A legacy `zaehler` on the first row rides along and decides nothing:
|
||||
// both keys come from the instant, so two different times can never
|
||||
// collapse onto one key again.
|
||||
// This body carries one time per row and nothing to say which slot it
|
||||
// belonged to, so `scheduledFor` mirrors the take: every dose it
|
||||
// describes is ad-hoc, anchored on itself. The export-reading route is
|
||||
// the one with two facts to write.
|
||||
payload: {
|
||||
entries: [
|
||||
{
|
||||
idempotencyKey: "import-m1-41",
|
||||
idempotencyKey: `import-m1-${Date.parse("2026-01-01T07:00:00")}`,
|
||||
scheduledFor: expect.any(String),
|
||||
takenAt: expect.any(String),
|
||||
},
|
||||
{
|
||||
idempotencyKey: expect.stringMatching(/^import-m1-\d+$/),
|
||||
idempotencyKey: `import-m1-${Date.parse("2026-01-01T19:00:00")}`,
|
||||
scheduledFor: expect.any(String),
|
||||
takenAt: expect.any(String),
|
||||
},
|
||||
],
|
||||
@@ -248,7 +260,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
|
||||
progress: expect.objectContaining({
|
||||
processed: 0,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
total: 2,
|
||||
}),
|
||||
}),
|
||||
@@ -412,3 +424,69 @@ describe("POST /api/medications/[id]/intake/import — future bound and limiter"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake/import — the reported export (#650)", () => {
|
||||
// The file attached to the report: 28 doses, one per day, all at 08:30, and
|
||||
// `"zaehler": 1.0` on every single row — a dose quantity, not a row counter.
|
||||
// The old key derivation used that field verbatim, so all 28 entries queued
|
||||
// under `import-m1-1`, one row was written and 27 were reported back as
|
||||
// duplicates of each other.
|
||||
const REPORTED_EXPORT = JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, "fixtures", "reported-intake-export.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as Array<{ datum: string; uhrzeit: string; zaehler: number }>;
|
||||
|
||||
it("is the reported shape (a fixture that drifted would prove nothing)", () => {
|
||||
expect(REPORTED_EXPORT).toHaveLength(28);
|
||||
expect(new Set(REPORTED_EXPORT.map((row) => row.zaehler)).size).toBe(1);
|
||||
expect(
|
||||
new Set(REPORTED_EXPORT.map((row) => `${row.datum}T${row.uhrzeit}`)).size,
|
||||
).toBe(28);
|
||||
});
|
||||
|
||||
it("queues 28 distinct doses instead of collapsing them onto one key", async () => {
|
||||
const res = await POST(postReq(REPORTED_EXPORT), ROUTE_CTX);
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
const created = vi.mocked(prisma.medicationIntakeImportJob.create).mock
|
||||
.calls[0][0] as unknown as {
|
||||
data: {
|
||||
payload: {
|
||||
entries: Array<{ takenAt: string; idempotencyKey: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const entries = created.data.payload.entries;
|
||||
expect(entries).toHaveLength(28);
|
||||
expect(new Set(entries.map((entry) => entry.idempotencyKey)).size).toBe(28);
|
||||
expect(new Set(entries.map((entry) => entry.takenAt)).size).toBe(28);
|
||||
// The key is a function of the instant and nothing else.
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
const row = REPORTED_EXPORT[index];
|
||||
const instant = Date.parse(`${row.datum}T${row.uhrzeit}`);
|
||||
expect(entry.idempotencyKey).toBe(`import-m1-${instant}`);
|
||||
expect(entry.takenAt).toBe(new Date(instant).toISOString());
|
||||
}
|
||||
});
|
||||
|
||||
it("keys two rows at the same instant identically, whatever else they carry", async () => {
|
||||
await POST(
|
||||
postReq([
|
||||
{ datum: "2026-03-01", uhrzeit: "08:00:00", zaehler: 7 },
|
||||
{ datum: "2026-03-01", uhrzeit: "08:00:00", zaehler: 9 },
|
||||
]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
const created = vi.mocked(prisma.medicationIntakeImportJob.create).mock
|
||||
.calls[0][0] as unknown as {
|
||||
data: { payload: { entries: Array<{ idempotencyKey: string }> } };
|
||||
};
|
||||
const keys = created.data.payload.entries.map(
|
||||
(entry) => entry.idempotencyKey,
|
||||
);
|
||||
expect(keys[0]).toBe(keys[1]);
|
||||
});
|
||||
});
|
||||
@@ -21,14 +21,36 @@ import {
|
||||
type MedicationImportProgress,
|
||||
} from "@/lib/jobs/medication-intake-import";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildMedicationImportEntries } from "@/lib/medications/intake-import-payload";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
|
||||
// The entry shape carried a third field, `zaehler`, and the replay key was
|
||||
// built from it whenever it was present. That reads as a per-row counter,
|
||||
// which is what the example beside this dialog showed — but the field was
|
||||
// optional, its uniqueness was never stated and never checked, and nothing
|
||||
// told anyone it carried identity. A submission that repeated one value down
|
||||
// the column collapsed a month of history onto one key: the first row
|
||||
// imported, every later one lost the `(userId, idempotencyKey)` unique and
|
||||
// came back reported as a duplicate of a dose it had nothing to do with.
|
||||
//
|
||||
// The reported file repeated `1.0` because it was modelled on the example this
|
||||
// dialog itself displayed, which counted upward and explained nothing. So the
|
||||
// fault was never in the submission; it was in a field that decided identity
|
||||
// while presenting itself as a quantity.
|
||||
//
|
||||
// `zaehler` is gone from the contract rather than accepted-and-ignored. A
|
||||
// payload that still carries it imports unchanged — `z.object` drops
|
||||
// unrecognised keys — it simply no longer decides anything. The key now comes
|
||||
// from the instant (`buildMedicationImportEntries`), which is also the grain
|
||||
// the database already enforces: the live-row unique on
|
||||
// `(user_id, medication_id, scheduled_for, source)` makes an imported dose
|
||||
// unique per instant whatever the key says, so a re-import of a file first
|
||||
// imported under the old key still dedups.
|
||||
const importEntrySchema = z.object({
|
||||
datum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
uhrzeit: z.string().regex(/^\d{2}:\d{2}:\d{2}$/),
|
||||
zaehler: z.union([z.number().int(), z.string()]).optional(),
|
||||
});
|
||||
|
||||
function isValidImportTimestamp(datum: string, uhrzeit: string): boolean {
|
||||
@@ -149,19 +171,13 @@ export const POST = apiHandler(
|
||||
|
||||
const entries = parsed.data;
|
||||
const normalized: MedicationImportPayload = {
|
||||
entries: entries.map((entry) => {
|
||||
const takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`);
|
||||
const idempotencyKey = entry.zaehler
|
||||
? `import-${id}-${String(entry.zaehler)}`
|
||||
: `import-${id}-${takenAt.getTime()}`;
|
||||
return { takenAt: takenAt.toISOString(), idempotencyKey };
|
||||
}),
|
||||
entries: buildMedicationImportEntries(id, entries),
|
||||
};
|
||||
const progress: MedicationImportProgress = {
|
||||
processed: 0,
|
||||
total: normalized.entries.length,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
skippedByReason: {},
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* v1.4.25 W19b — per-pen inventory item operations.
|
||||
*
|
||||
* PATCH /api/medications/[id]/inventory/[itemId]
|
||||
* - mark-as-first-use (starts the 30-day clock manually)
|
||||
* - set or clear the opening date (`markAsFirstUseAt`); for a pen
|
||||
* or an ampoule it also starts / stops the in-use clock
|
||||
* - mark-as-used-up (terminal — operator override when a pen is
|
||||
* physically discarded but the dose ledger hasn't reached zero)
|
||||
* - update printed expiry (e.g. carton label correction)
|
||||
@@ -117,11 +118,17 @@ export const PATCH = apiHandler(
|
||||
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
|
||||
let nextPrintedExpiry = existing.printedExpiry;
|
||||
|
||||
if (markAsFirstUseAt) {
|
||||
// The clock can be set manually if the user opened the pen
|
||||
// without logging an intake event.
|
||||
if (markAsFirstUseAt !== undefined) {
|
||||
// The opening date can be set manually if the user opened the
|
||||
// container without logging an intake event — and cleared
|
||||
// (null) when it was never opened at all. An auto-open the
|
||||
// consumption hook wrote from a back-dated intake is the one
|
||||
// the user most often has to take back, and until now nothing
|
||||
// could unset it.
|
||||
nextFirstUseAt = markAsFirstUseAt;
|
||||
if (nextState === "ACTIVE") nextState = "IN_USE";
|
||||
if (nextState === "ACTIVE" && markAsFirstUseAt !== null) {
|
||||
nextState = "IN_USE";
|
||||
}
|
||||
}
|
||||
|
||||
if (printedExpiry !== undefined) {
|
||||
@@ -145,7 +152,11 @@ export const PATCH = apiHandler(
|
||||
nextState = "USED_UP";
|
||||
}
|
||||
|
||||
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
|
||||
const nextExpiresAt = computeExpiresAt(
|
||||
nextFirstUseAt,
|
||||
nextPrintedExpiry,
|
||||
existing.containerType,
|
||||
);
|
||||
|
||||
// Re-run the canonical state machine over the composed next-row
|
||||
// view. The clause-by-clause updates above set the state ad-hoc
|
||||
@@ -160,6 +171,7 @@ export const PATCH = apiHandler(
|
||||
nextState = computeInventoryState(
|
||||
{
|
||||
state: nextState,
|
||||
containerType: existing.containerType,
|
||||
unitsTotal: Number(existing.unitsTotal),
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
|
||||
@@ -269,6 +269,8 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
state: "ACTIVE" as const,
|
||||
// A pen — the container kind that carries the post-opening clock.
|
||||
containerType: "PEN" as const,
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 4,
|
||||
firstUseAt: null,
|
||||
@@ -384,6 +386,78 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a back-dated first use on a blister IN_USE with no expiresAt", async () => {
|
||||
// The same back-dated stamp on a tablet pack must not write the
|
||||
// remaining tablets off: a blister carries no post-opening clock,
|
||||
// so the only deadline it could have is a printed one, and this row
|
||||
// has none.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
|
||||
...existingActive,
|
||||
containerType: "BLISTER",
|
||||
unitsTotal: 60,
|
||||
unitsRemaining: 54,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
...existingActive,
|
||||
state: "IN_USE",
|
||||
} as never);
|
||||
|
||||
const backdated = new Date(
|
||||
Date.now() - 45 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
const res = await PATCH(
|
||||
jsonReq(
|
||||
"http://localhost/api/medications/med-1/inventory/inv-1",
|
||||
{ markAsFirstUseAt: backdated },
|
||||
"PATCH",
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0];
|
||||
expect(updateCall.data).toMatchObject({
|
||||
state: "IN_USE",
|
||||
firstUseAt: new Date(backdated),
|
||||
expiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the opening date when markAsFirstUseAt is null", async () => {
|
||||
// The correction path for a container the consumption hook
|
||||
// auto-opened by mistake: unsetting the date returns the row to
|
||||
// ACTIVE and drops any in-use deadline with it.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
|
||||
...existingActive,
|
||||
state: "IN_USE",
|
||||
firstUseAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000),
|
||||
expiresAt: new Date(Date.now() + 25 * 24 * 60 * 60 * 1000),
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
...existingActive,
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(
|
||||
jsonReq(
|
||||
"http://localhost/api/medications/med-1/inventory/inv-1",
|
||||
{ markAsFirstUseAt: null },
|
||||
"PATCH",
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0];
|
||||
expect(updateCall.data).toMatchObject({
|
||||
state: "ACTIVE",
|
||||
firstUseAt: null,
|
||||
expiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects 404 when the item belongs to another user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readMedicationIntakeImportJob } from "@/lib/medications/intake-import-job-status";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ jobId: string }>;
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteContext) => {
|
||||
const { user } = await requireAuth();
|
||||
const { jobId } = await params;
|
||||
annotate({
|
||||
action: { name: "medication.intake.history.import.status" },
|
||||
meta: { job_id: jobId },
|
||||
});
|
||||
|
||||
// No medication argument: this reads the account-wide job, and the helper
|
||||
// narrows on `medicationId: null` so a per-medication job id cannot be read
|
||||
// through here (or the other way round).
|
||||
const job = await readMedicationIntakeImportJob(user.id, jobId);
|
||||
if (!job) return apiError("Import job not found", 404);
|
||||
return apiSuccess(job);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* The dose-history import kickoff contract.
|
||||
*
|
||||
* The route parses, matches, and hands the write to the background job. What is
|
||||
* asserted here is the contract around that: the verdict it returns, that a dry
|
||||
* run writes nothing at all, that the job it enqueues is account-wide with the
|
||||
* pre-queue refusals already seeded into its progress, and that a file it cannot
|
||||
* read is refused with a code naming the actual problem.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const prismaMock = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "user-1" }]),
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeImportJob: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
...prismaMock,
|
||||
$transaction: vi.fn((callback: (tx: typeof prismaMock) => unknown) =>
|
||||
callback(prismaMock),
|
||||
),
|
||||
},
|
||||
toJson: (value: unknown) => value,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: 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 { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
user: { id: "user-1", username: "owner", role: "USER", email: null },
|
||||
};
|
||||
|
||||
const HEADER =
|
||||
"Date,Scheduled Date,Medication,Nickname,Dosage,Scheduled Dosage,Unit,Status,Archived,Codings";
|
||||
|
||||
/** Two takeable rows for one medication, plus a row recording no decision. */
|
||||
const CSV = [
|
||||
HEADER,
|
||||
"2025-01-01 08:20:00 +1030,2025-01-01 08:00:00 +1030,Ramipril,Ramipril,1.0,1.0,count,Taken,No,",
|
||||
"2025-01-02 08:05:00 +1030,2025-01-02 08:00:00 +1030,Ramipril,Ramipril,0.5,1.0,count,Taken,Yes,",
|
||||
"2025-01-03 08:00:00 +1030,2025-01-03 08:00:00 +1030,Ramipril,Ramipril,,1.0,count,Not Interacted,No,",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
function postReq(
|
||||
body: string,
|
||||
opts: { contentType?: string; query?: string } = {},
|
||||
) {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/medications/intake/dose-history-import${opts.query ?? ""}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": opts.contentType ?? "text/csv" },
|
||||
body,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(
|
||||
SESSION_OK as unknown as Awaited<ReturnType<typeof getSession>>,
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: new Date(),
|
||||
} as unknown as Awaited<ReturnType<typeof checkRateLimit>>);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1", name: "Ramipril", externalSource: null },
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeImportJob.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medicationIntakeImportJob.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeImportJob.create).mockResolvedValue({
|
||||
id: "job-1",
|
||||
} as never);
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({
|
||||
send: vi.fn().mockResolvedValue("boss-1"),
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/medications/intake/dose-history-import", () => {
|
||||
it("returns the whole verdict and a pollable handle", async () => {
|
||||
const res = await POST(postReq(CSV));
|
||||
expect(res.status).toBe(202);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({
|
||||
dryRun: false,
|
||||
jobId: "job-1",
|
||||
status: "queued",
|
||||
statusUrl: "/api/medications/intake/dose-history-import/job-1/status",
|
||||
file: {
|
||||
rowsRead: 3,
|
||||
queued: 2,
|
||||
refused: 1,
|
||||
refusedByReason: [{ reason: "status_no_dose_information", count: 1 }],
|
||||
unmatchedMedications: [],
|
||||
ambiguousMedications: [],
|
||||
mirroredMedications: [],
|
||||
unknownColumns: [],
|
||||
codingsNotRead: 0,
|
||||
// Read and reported, never a reason to drop the dose.
|
||||
fromArchivedMedications: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enqueues an account-wide job carrying both instants and the seeded refusals", async () => {
|
||||
await POST(postReq(CSV));
|
||||
expect(prisma.medicationIntakeImportJob.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "user-1",
|
||||
// No medication owns a file covering a regimen; each entry names its own.
|
||||
medicationId: null,
|
||||
status: "queued",
|
||||
payload: {
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2024-12-31T21:30:00.000Z",
|
||||
takenAt: "2024-12-31T21:50:00.000Z",
|
||||
idempotencyKey: `import-med-1-${Date.parse("2024-12-31T21:30:00.000Z")}`,
|
||||
},
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2025-01-01T21:30:00.000Z",
|
||||
takenAt: "2025-01-01T21:35:00.000Z",
|
||||
idempotencyKey: `import-med-1-${Date.parse("2025-01-01T21:30:00.000Z")}`,
|
||||
// 0.5 against a scheduled 1.0 — a real deviation, so it is kept.
|
||||
doseTaken: "0.5",
|
||||
},
|
||||
],
|
||||
},
|
||||
progress: {
|
||||
processed: 0,
|
||||
total: 2,
|
||||
imported: 0,
|
||||
// Seeded, so the count at the end of the run covers the whole file.
|
||||
skippedByReason: { status_no_dose_information: 1 },
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("writes nothing on a dry run", async () => {
|
||||
const res = await POST(postReq(CSV, { query: "?dryRun=1" }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.dryRun).toBe(true);
|
||||
expect(body.data.jobId).toBeNull();
|
||||
expect(body.data.file.queued).toBe(2);
|
||||
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
|
||||
expect(getGlobalBoss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the medications it could not match", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
const res = await POST(postReq(CSV, { query: "?dryRun=1" }));
|
||||
const body = await res.json();
|
||||
expect(body.data.file.unmatchedMedications).toEqual(["Ramipril"]);
|
||||
expect(body.data.file.queued).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses the JSON shape with the reason it cannot be imported", async () => {
|
||||
const res = await POST(
|
||||
postReq(
|
||||
JSON.stringify([
|
||||
{
|
||||
displayText: "Ramipril",
|
||||
scheduledDate: "2025-01-01 08:00:00 +1030",
|
||||
status: "Taken",
|
||||
isArchived: false,
|
||||
},
|
||||
]),
|
||||
{ contentType: "application/json" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe("json_carries_no_intake_time");
|
||||
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the missing column when the header cannot carry a dose", async () => {
|
||||
const res = await POST(postReq("Date,Nickname\n2025-01-01,x\n"));
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "missing_required_columns",
|
||||
detail: "Medication, Status",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a body it has no parser for rather than guessing", async () => {
|
||||
const res = await POST(postReq(CSV, { contentType: "application/x-yaml" }));
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
|
||||
it("refuses a second run while one is still going", async () => {
|
||||
vi.mocked(prisma.medicationIntakeImportJob.findFirst).mockResolvedValue({
|
||||
id: "job-0",
|
||||
} as never);
|
||||
const res = await POST(postReq(CSV));
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("looks only at account-wide work when deciding whether one is already running", async () => {
|
||||
await POST(postReq(CSV));
|
||||
// The in-progress check and the stale sweep both narrow on
|
||||
// `medicationId: null`. Without that narrowing, an import running on a
|
||||
// single medication's own page would refuse this one — and the sweep would
|
||||
// reach across and fail it.
|
||||
expect(prisma.medicationIntakeImportJob.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
medicationId: null,
|
||||
status: { in: ["queued", "running"] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.medicationIntakeImportJob.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
medicationId: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails the job and answers 503 when the worker cannot take it", async () => {
|
||||
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
|
||||
const res = await POST(postReq(CSV));
|
||||
expect(res.status).toBe(503);
|
||||
// The job row is not left sitting in `queued` with nothing to pick it up.
|
||||
expect(prisma.medicationIntakeImportJob.update).toHaveBeenCalledWith({
|
||||
where: { id: "job-1" },
|
||||
data: expect.objectContaining({
|
||||
status: "failed",
|
||||
failureReason: "Background worker enqueue failed",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses once the import quota for the hour is spent", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date(),
|
||||
} as unknown as Awaited<ReturnType<typeof checkRateLimit>>);
|
||||
const res = await POST(postReq(CSV));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.medication.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Import a dose history exported from another medication tracker.
|
||||
*
|
||||
* The existing per-medication import takes a hand-written list of dates and
|
||||
* times for one medication. That is what a self-hoster with a ten-column,
|
||||
* 3,395-row export covering sixteen medications was left translating by hand,
|
||||
* guessing at what each field meant. This route reads that file's own shape, so
|
||||
* nobody has to.
|
||||
*
|
||||
* What the shape earns it: the export keeps the scheduled time and the time the
|
||||
* dose was actually taken in two separate columns, which is exactly the pair
|
||||
* `MedicationIntakeEvent` already models as `scheduledFor` + `takenAt`. That
|
||||
* distinction is the first casualty of translating a file by hand, and it is the
|
||||
* one that decides whether a dose reads as on time or late for the rest of its
|
||||
* life.
|
||||
*
|
||||
* Body is the file's raw text — `text/csv` for the ten-column export,
|
||||
* `application/json` for the documented JSON shape. Capped at 16 MB, the same
|
||||
* ceiling the measurement CSV import carries.
|
||||
*
|
||||
* `?dryRun=1` parses, matches every row against the record, and returns the
|
||||
* whole verdict WITHOUT writing: how many rows land, how many are refused and
|
||||
* for which reason, which medication names match nothing. A bulk import of years
|
||||
* of history should be inspectable before it happens.
|
||||
*
|
||||
* The write itself runs on the same background job the per-medication import
|
||||
* uses — chunked, heartbeated, resumable, with the rollup recompute chunked
|
||||
* behind it. A path that works on 28 rows and dies on 30,000 is not an import.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
MEDICATION_INTAKE_IMPORT_QUEUE,
|
||||
MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
|
||||
MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
|
||||
groupMedicationImportSkips,
|
||||
totalMedicationImportSkips,
|
||||
type MedicationImportPayload,
|
||||
type MedicationImportProgress,
|
||||
} from "@/lib/jobs/medication-intake-import";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
parseAutoExportCsv,
|
||||
parseAutoExportJson,
|
||||
} from "@/lib/medications/import/auto-export-parse";
|
||||
import {
|
||||
planAutoExportImport,
|
||||
type AutoExportPlan,
|
||||
} from "@/lib/medications/import/auto-export-plan";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
/** Same ceiling as the measurement CSV import. */
|
||||
const MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Rows the importer will queue in one submission.
|
||||
*
|
||||
* The write loop reads the whole payload out of the job row on every chunk, so
|
||||
* the cost of a submission grows with rows × chunks. At 30,000 rows that is a
|
||||
* few hundred megabytes of read across a couple of hundred short transactions —
|
||||
* slow but bounded, heartbeated, and resumable. Beyond it the shape stops being
|
||||
* the right one, and saying so is better than accepting a file that then crawls.
|
||||
*/
|
||||
const MAX_QUEUED_ROWS = 30_000;
|
||||
|
||||
/**
|
||||
* Names echoed back so the person can act on them. Bounded: an unmatched name is
|
||||
* a hint, and a file naming five hundred unknown medications needs a different
|
||||
* conversation than a longer list.
|
||||
*/
|
||||
const MAX_NAMES_REPORTED = 50;
|
||||
|
||||
/** Blast-radius bucket shared with every other bulk import surface. */
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
function cap(names: readonly string[]): string[] {
|
||||
return names.slice(0, MAX_NAMES_REPORTED);
|
||||
}
|
||||
|
||||
/** The file-level verdict, identical for a dry run and a real submission. */
|
||||
function describePlan(plan: AutoExportPlan) {
|
||||
return {
|
||||
rowsRead: plan.rowsRead,
|
||||
queued: plan.entries.length,
|
||||
refused: totalMedicationImportSkips(plan.skippedByReason),
|
||||
refusedByReason: groupMedicationImportSkips(plan.skippedByReason),
|
||||
unmatchedMedications: cap(plan.unmatchedMedications),
|
||||
ambiguousMedications: cap(plan.ambiguousMedications),
|
||||
mirroredMedications: cap(plan.mirroredMedications),
|
||||
unknownColumns: cap(plan.unknownColumns),
|
||||
codingsNotRead: plan.csvCodingsIgnored,
|
||||
fromArchivedMedications: plan.fromArchivedMedications,
|
||||
};
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const dryRunParam = new URL(request.url).searchParams.get("dryRun");
|
||||
const dryRun = dryRunParam === "1" || dryRunParam === "true";
|
||||
annotate({
|
||||
action: { name: "medication.intake.history.import" },
|
||||
meta: { dry_run: dryRun },
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`import:${user.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError(`Maximum ${RATE_LIMIT_MAX} imports per hour`, 429);
|
||||
}
|
||||
|
||||
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
|
||||
const isJson = contentType.includes("application/json");
|
||||
const isText =
|
||||
contentType.includes("text/csv") || contentType.includes("text/plain");
|
||||
if (!isJson && !isText) {
|
||||
return apiError("Content-Type must be text/csv or application/json", 415);
|
||||
}
|
||||
|
||||
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_BYTES) {
|
||||
return apiError("File exceeds the 16 MB limit", 413);
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = await request.text();
|
||||
} catch {
|
||||
return apiError("Could not read the request body", 400);
|
||||
}
|
||||
if (Buffer.byteLength(text, "utf8") > MAX_BYTES) {
|
||||
return apiError("File exceeds the 16 MB limit", 413);
|
||||
}
|
||||
|
||||
const parsed = isJson ? parseAutoExportJson(text) : parseAutoExportCsv(text);
|
||||
if (parsed.fatal) {
|
||||
annotate({
|
||||
action: { name: "medication.intake.history.import.refused" },
|
||||
meta: { reason: parsed.fatal.reason },
|
||||
});
|
||||
// The reason travels as a machine-readable code so the client can render the
|
||||
// sentence that names the actual problem — a JSON export that carries no
|
||||
// intake time needs a different answer than a truncated file.
|
||||
return apiError("The file could not be read", 422, {
|
||||
errorCode: parsed.fatal.reason,
|
||||
...(parsed.fatal.detail === undefined
|
||||
? {}
|
||||
: { detail: parsed.fatal.detail }),
|
||||
});
|
||||
}
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
// Every medication, active or not: a dose taken in 2023 belongs to the
|
||||
// medication whether or not the person has since stopped taking it.
|
||||
select: { id: true, name: true, externalSource: true },
|
||||
});
|
||||
const plan = planAutoExportImport(parsed, medications);
|
||||
|
||||
if (plan.entries.length > MAX_QUEUED_ROWS) {
|
||||
return apiError(
|
||||
`The file holds ${plan.entries.length} importable rows — the limit is ${MAX_QUEUED_ROWS} per import`,
|
||||
422,
|
||||
{ errorCode: "too_many_rows" },
|
||||
);
|
||||
}
|
||||
|
||||
const file = describePlan(plan);
|
||||
if (dryRun) {
|
||||
return apiSuccess({ dryRun: true, jobId: null, statusUrl: null, file });
|
||||
}
|
||||
|
||||
const payload: MedicationImportPayload = { entries: plan.entries };
|
||||
// Rows refused before the queue are seeded into the progress the worker
|
||||
// advances, so the count the person reads at the end covers the whole file and
|
||||
// not just the part that reached the database.
|
||||
const progress: MedicationImportProgress = {
|
||||
processed: 0,
|
||||
total: plan.entries.length,
|
||||
imported: 0,
|
||||
skippedByReason: plan.skippedByReason,
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
|
||||
const admission = await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`
|
||||
SELECT "id"
|
||||
FROM "users"
|
||||
WHERE "id" = ${user.id}
|
||||
FOR UPDATE
|
||||
`;
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(
|
||||
now.getTime() - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
|
||||
);
|
||||
await tx.medicationIntakeImportJob.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: null,
|
||||
status: { in: ["queued", "running"] },
|
||||
OR: [
|
||||
{ heartbeatAt: { lt: staleBefore } },
|
||||
{ heartbeatAt: null, startedAt: { lt: staleBefore } },
|
||||
{
|
||||
heartbeatAt: null,
|
||||
startedAt: null,
|
||||
createdAt: { lt: staleBefore },
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: "failed",
|
||||
failureReason: "Medication intake import abandoned",
|
||||
heartbeatAt: now,
|
||||
completedAt: now,
|
||||
},
|
||||
});
|
||||
const activeJob = await tx.medicationIntakeImportJob.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: null,
|
||||
status: { in: ["queued", "running"] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (activeJob) return { active: true as const };
|
||||
|
||||
const importJob = await tx.medicationIntakeImportJob.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: null,
|
||||
status: "queued",
|
||||
payload: toJson(payload),
|
||||
progress: toJson(progress),
|
||||
},
|
||||
});
|
||||
return { active: false as const, importJob };
|
||||
});
|
||||
if (admission.active) {
|
||||
return apiError("A dose-history import is already in progress", 409);
|
||||
}
|
||||
const { importJob } = admission;
|
||||
|
||||
const statusUrl = `/api/medications/intake/dose-history-import/${importJob.id}/status`;
|
||||
let bossJobId: string | null = null;
|
||||
try {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) throw new Error("worker unavailable");
|
||||
bossJobId = await boss.send(
|
||||
MEDICATION_INTAKE_IMPORT_QUEUE,
|
||||
{ jobId: importJob.id },
|
||||
MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
|
||||
);
|
||||
if (!bossJobId) throw new Error("queue rejected job");
|
||||
await prisma.medicationIntakeImportJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: { pgBossJobId: bossJobId },
|
||||
});
|
||||
} catch {
|
||||
const completedAt = new Date();
|
||||
await prisma.medicationIntakeImportJob.update({
|
||||
where: { id: importJob.id },
|
||||
data: {
|
||||
status: "failed",
|
||||
failureReason: "Background worker enqueue failed",
|
||||
heartbeatAt: completedAt,
|
||||
completedAt,
|
||||
},
|
||||
});
|
||||
await auditLog("medication.intake.import.kickoff.denied", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { jobId: importJob.id, reason: "enqueue_failed" },
|
||||
});
|
||||
return apiError("Background worker is not available", 503);
|
||||
}
|
||||
|
||||
await auditLog("medication.intake.history.import.kickoff", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
jobId: importJob.id,
|
||||
bossJobId,
|
||||
rowsRead: file.rowsRead,
|
||||
queued: file.queued,
|
||||
refused: file.refused,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.intake.history.import.kickoff" },
|
||||
meta: {
|
||||
job_id: importJob.id,
|
||||
rows_read: file.rowsRead,
|
||||
queued: file.queued,
|
||||
refused: file.refused,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
dryRun: false,
|
||||
jobId: importJob.id,
|
||||
status: "queued" as const,
|
||||
statusUrl,
|
||||
file,
|
||||
},
|
||||
202,
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import {
|
||||
INTEGRATION_CADENCE,
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
|
||||
/**
|
||||
* Nightscout connection status for the current user (v1.17.0).
|
||||
@@ -45,5 +49,18 @@ export const GET = apiHandler(async () => {
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
// `connected: true` here means "a connection exists", not "it is working"
|
||||
// — a pipe dead for a fortnight still answers true. The verdict is the
|
||||
// liveness truth, resolved by the same rule the Settings envelope uses so
|
||||
// this route cannot drift into a second opinion.
|
||||
syncHealth: resolveSyncVerdict({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
failingSinceAt: status.failingSince,
|
||||
cadence: INTEGRATION_CADENCE["nightscout"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { update: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/auth/session")>();
|
||||
return {
|
||||
...actual,
|
||||
getSession: vi.fn(),
|
||||
setOnboardingPendingCookie: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/onboarding/complete", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(req({}));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
/**
|
||||
* Onboarding validated gender against a narrower enum than the profile
|
||||
* schema, so a value the account is allowed to hold rejected the whole
|
||||
* submission — height, date of birth and display name included.
|
||||
*/
|
||||
it("accepts every gender the profile stores, including OTHER", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(req({ gender: "OTHER", heightCm: 180 }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({ gender: "OTHER", heightCm: 180 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still refuses an unsupported gender", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(req({ gender: "diverse" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,11 @@ const onboardingSchema = z.object({
|
||||
displayName: z.string().trim().min(1).max(50).optional(),
|
||||
heightCm: z.number().min(50).max(300).optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
gender: z.enum(["MALE", "FEMALE"]).optional(),
|
||||
// Same three values the profile schema stores. A narrower enum here
|
||||
// rejected the whole onboarding submission over a field the account
|
||||
// is allowed to hold, and left the value unrecordable until the person
|
||||
// found the setting again afterwards.
|
||||
gender: z.enum(["MALE", "FEMALE", "OTHER"]).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import {
|
||||
INTEGRATION_CADENCE,
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — Oura connection status for the current user.
|
||||
@@ -53,5 +57,18 @@ export const GET = apiHandler(async () => {
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
// `connected: true` here means "a connection exists", not "it is working"
|
||||
// — a pipe dead for a fortnight still answers true. The verdict is the
|
||||
// liveness truth, resolved by the same rule the Settings envelope uses so
|
||||
// this route cannot drift into a second opinion.
|
||||
syncHealth: resolveSyncVerdict({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
failingSinceAt: status.failingSince,
|
||||
cadence: INTEGRATION_CADENCE["oura"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import {
|
||||
INTEGRATION_CADENCE,
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — Polar connection status for the current user.
|
||||
@@ -54,5 +58,18 @@ export const GET = apiHandler(async () => {
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
// `connected: true` here means "a connection exists", not "it is working"
|
||||
// — a pipe dead for a fortnight still answers true. The verdict is the
|
||||
// liveness truth, resolved by the same rule the Settings envelope uses so
|
||||
// this route cannot drift into a second opinion.
|
||||
syncHealth: resolveSyncVerdict({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
failingSinceAt: status.failingSince,
|
||||
cadence: INTEGRATION_CADENCE["polar"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,37 @@ import {
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
resolveWipeDelegate,
|
||||
USER_RESET,
|
||||
wipeDelegateKey,
|
||||
WIPE_MODELS,
|
||||
} from "@/lib/data-wipe/wipe-plan";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Delete all user-owned health/integration data while keeping the account.
|
||||
* Budget for the wipe transaction.
|
||||
*
|
||||
* Prisma's default interactive-transaction timeout is 5 s, which was already
|
||||
* optimistic for thirteen `deleteMany` statements and is not survivable for
|
||||
* the full user-scoped set on an account with a large import behind it. The
|
||||
* whole action is one transaction on purpose — a half-applied wipe is worse
|
||||
* than none, because the person is told it succeeded either way.
|
||||
*/
|
||||
const WIPE_TRANSACTION_TIMEOUT_MS = 120_000;
|
||||
const WIPE_TRANSACTION_MAX_WAIT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Delete every row this account owns, keeping the account and the credentials
|
||||
* that sign into it.
|
||||
*
|
||||
* The set of tables is not written here. It is declared once in
|
||||
* `@/lib/data-wipe/wipe-plan` and held against `prisma/schema.prisma` by
|
||||
* `src/__tests__/data-wipe-completeness.test.ts`, so a model added to the
|
||||
* schema cannot quietly land outside the wipe — which is exactly how this
|
||||
* route came to delete thirteen tables out of eighty-odd while telling people
|
||||
* it had deleted all of them.
|
||||
*
|
||||
* v1.23 — gated by a fresh step-up for MFA-enrolled accounts (see the account
|
||||
* deletion route); single-factor accounts keep the typed-confirmation-only
|
||||
@@ -40,92 +66,53 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const measurements = await tx.measurement.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const intakeEvents = await tx.medicationIntakeEvent.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const medications = await tx.medication.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const moodEntries = await tx.moodEntry.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
// v1.4.39 W-MOOD — wipe the persisted rollup partition for this
|
||||
// user so the next analytics read returns an empty envelope.
|
||||
await tx.moodEntryRollup.deleteMany({ where: { userId } });
|
||||
const apiTokens = await tx.apiToken.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const withingsConnections = await tx.withingsConnection.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const notificationChannels = await tx.notificationChannel.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const pushSubscriptions = await tx.pushSubscription.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const dataBackups = await tx.dataBackup.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const achievements = await tx.userAchievement.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const telegramDeletions = await tx.telegramScheduledDeletion.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const auditLogs = await tx.auditLog.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
const counts = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const perModel: Record<string, number> = {};
|
||||
for (const model of WIPE_MODELS) {
|
||||
const { count } = await resolveWipeDelegate(tx, model).deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
if (count > 0) perModel[wipeDelegateKey(model)] = count;
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
telegramBotToken: null,
|
||||
telegramChatId: null,
|
||||
telegramEnabled: false,
|
||||
withingsClientIdEncrypted: null,
|
||||
withingsClientSecretEncrypted: null,
|
||||
onboardingCompletedAt: null,
|
||||
},
|
||||
});
|
||||
// The account row survives; the personal data carried on its own columns
|
||||
// does not. Same classification contract as the model list.
|
||||
await tx.user.update({ where: { id: userId }, data: USER_RESET });
|
||||
|
||||
return {
|
||||
measurements: measurements.count,
|
||||
intakeEvents: intakeEvents.count,
|
||||
medications: medications.count,
|
||||
moodEntries: moodEntries.count,
|
||||
apiTokens: apiTokens.count,
|
||||
withingsConnections: withingsConnections.count,
|
||||
notificationChannels: notificationChannels.count,
|
||||
pushSubscriptions: pushSubscriptions.count,
|
||||
dataBackups: dataBackups.count,
|
||||
achievements: achievements.count,
|
||||
telegramDeletions: telegramDeletions.count,
|
||||
auditLogs: auditLogs.count,
|
||||
};
|
||||
});
|
||||
return perModel;
|
||||
},
|
||||
{
|
||||
timeout: WIPE_TRANSACTION_TIMEOUT_MS,
|
||||
maxWait: WIPE_TRANSACTION_MAX_WAIT_MS,
|
||||
},
|
||||
);
|
||||
|
||||
const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
||||
|
||||
// Written after the transaction commits, so the row cannot be removed by the
|
||||
// AuditLog delete inside it. The person's whole audit history goes; this one
|
||||
// row stays as the receipt for the erasure they asked for. Account deletion
|
||||
// takes the other branch and purges even this, because there is no longer an
|
||||
// account for it to belong to.
|
||||
await auditLog("user.data.clear", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: result,
|
||||
details: { deletedRows, models: counts },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "settings.data.clear" }, meta: result });
|
||||
annotate({
|
||||
action: { name: "settings.data.clear" },
|
||||
meta: {
|
||||
deleted_rows: deletedRows,
|
||||
models_wiped: WIPE_MODELS.length,
|
||||
models_with_rows: Object.keys(counts).length,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.16.9 — every cached payload was built on the rows just deleted;
|
||||
// hard-evict the user's buckets so no surface serves pre-wipe data.
|
||||
invalidateUserData(userId);
|
||||
|
||||
return apiSuccess({ cleared: true, ...result });
|
||||
return apiSuccess({ cleared: true, deletedRows, models: counts });
|
||||
});
|
||||
@@ -201,4 +201,31 @@ describe("GET /api/sleep/night", () => {
|
||||
const res = await GET(req("date=2026-06-04"));
|
||||
expect(res.status).toBe(200); // never 500 on the reconstruction edge
|
||||
});
|
||||
it("cannot serve an in-bed headline that contradicts its stage legend", async () => {
|
||||
// A device wrote the same night twice: an envelope 16:06 -> 02:09:59 and
|
||||
// a corrected one 16:06 -> 02:18:59. Raw-summing them served a 613-minute
|
||||
// headline next to a 1217-minute legend on the same screen.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
stage("2026-07-25T02:09:59.000Z", "IN_BED", 603 + 59 / 60),
|
||||
stage("2026-07-25T02:18:59.000Z", "IN_BED", 612 + 59 / 60),
|
||||
stage("2026-07-24T21:55:42.000Z", "CORE", 319.7),
|
||||
stage("2026-07-24T23:30:36.000Z", "DEEP", 94.9),
|
||||
stage("2026-07-24T23:37:31.200Z", "AWAKE", 6.92),
|
||||
stage("2026-07-25T01:53:25.200Z", "REM", 135.9),
|
||||
] as never);
|
||||
const res = await GET(req("date=2026-07-25"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.main.inBedMinutes).toBe(613);
|
||||
expect(body.data.main.stages.IN_BED).toBe(613);
|
||||
// One in-bed bar on the hypnogram, not the night drawn twice over.
|
||||
expect(
|
||||
body.data.main.segments.filter(
|
||||
(seg: { stage: string }) => seg.stage === "IN_BED",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(body.data.main.stages.CORE).toBe(320);
|
||||
expect(body.data.main.asleepMinutes).toBe(551);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import {
|
||||
INTEGRATION_CADENCE,
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
|
||||
/**
|
||||
* v1.28.x — Strava connection status for the current user.
|
||||
@@ -53,5 +57,18 @@ export const GET = apiHandler(async () => {
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
// `connected: true` here means "a connection exists", not "it is working"
|
||||
// — a pipe dead for a fortnight still answers true. The verdict is the
|
||||
// liveness truth, resolved by the same rule the Settings envelope uses so
|
||||
// this route cannot drift into a second opinion.
|
||||
syncHealth: resolveSyncVerdict({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
failingSinceAt: status.failingSince,
|
||||
cadence: INTEGRATION_CADENCE["strava"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -297,4 +297,84 @@ describe("PATCH /api/user/profile", () => {
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
/**
|
||||
* The whole profile PATCH is one write, so a rejected gender took every
|
||||
* sibling change down with it — someone switching their time format lost
|
||||
* the save over a field they never touched.
|
||||
*/
|
||||
it("clears gender on an empty string and still persists the sibling change", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
displayName: null,
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
locale: null,
|
||||
timeFormat: "H24",
|
||||
dateFormat: "AUTO",
|
||||
moodReminderEnabled: false,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumberEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(req({ timeFormat: "H24", gender: "" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ timeFormat: "H24", gender: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts OTHER and writes it through", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
displayName: null,
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: "OTHER",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: null,
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
moodReminderEnabled: false,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumberEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(req({ gender: "OTHER" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ gender: "OTHER" }),
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as { data: { gender: string | null } };
|
||||
expect(body.data.gender).toBe("OTHER");
|
||||
});
|
||||
|
||||
it("still refuses an unsupported gender, with a message that names the field", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PATCH(req({ timeFormat: "H24", gender: "diverse" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toContain("Gender must be MALE, FEMALE or OTHER");
|
||||
expect(body.error).not.toContain("Invalid option");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
@@ -189,18 +190,20 @@ function CoachPlansBody() {
|
||||
</>
|
||||
)}
|
||||
{group === "past" && (
|
||||
<Button
|
||||
type="button"
|
||||
<ConfirmButton
|
||||
slot="coach-plan-delete"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-delete"
|
||||
onClick={() => remove.mutate(plan.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.remove")}
|
||||
</Button>
|
||||
icon={<Trash2 className="size-3.5" aria-hidden="true" />}
|
||||
label={t("coach.plans.remove")}
|
||||
title={t("coach.plans.removeTitle")}
|
||||
body={t("coach.plans.removeBody")}
|
||||
confirmLabel={t("coach.plans.removeConfirm")}
|
||||
disabled={busy && !remove.isPending}
|
||||
pending={remove.isPending}
|
||||
onConfirm={() => remove.mutate(plan.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -173,6 +173,7 @@ export default function InsightsBlutdruckPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
onVisibleStats={onVisibleStats}
|
||||
showDataTable
|
||||
/>
|
||||
|
||||
{/* v1.12.4 — target card sits between the chart and the assessment on
|
||||
|
||||
@@ -123,6 +123,7 @@ export default function InsightsBmiPage() {
|
||||
valueBands={BMI_BANDS}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
showDataTable
|
||||
/>
|
||||
|
||||
<MetricTargetSummary slug="bmi" />
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
@@ -93,7 +94,12 @@ export default function InsightsMedikamentePage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
|
||||
const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({
|
||||
const {
|
||||
data: comprehensive,
|
||||
isLoading: isComprehensiveLoading,
|
||||
isError: isComprehensiveError,
|
||||
refetch: refetchComprehensive,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
queryFn: async () => {
|
||||
return apiGet<ComprehensiveMedicationData>("/api/insights/comprehensive");
|
||||
@@ -153,6 +159,25 @@ export default function InsightsMedikamentePage() {
|
||||
);
|
||||
}
|
||||
|
||||
// A failed comprehensive read leaves `medications` empty, which is
|
||||
// indistinguishable from an account that tracks none — and the empty state
|
||||
// then invites someone on five daily medications to add the medications the
|
||||
// app already holds. A read failure gets its own branch, before the length
|
||||
// check, mirroring the sibling overview at `/insights`.
|
||||
if (isComprehensiveError) {
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.medicationCompliance")}
|
||||
explainerMetric="medications"
|
||||
>
|
||||
<QueryErrorCard
|
||||
description={t("insights.loadError")}
|
||||
onRetry={() => refetchComprehensive()}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (medications.length === 0) {
|
||||
// v1.4.27 F17 — medication compliance is event-driven so the
|
||||
// gate reads `medications.length > 0`. CTA targets `/medications`
|
||||
|
||||
@@ -158,6 +158,7 @@ export default function InsightsPulsPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
onVisibleStats={onVisibleStats}
|
||||
showDataTable
|
||||
/>
|
||||
|
||||
{/* S11 — the intraday "shape of the day" layer: 10-minute mean heart
|
||||
|
||||
@@ -162,6 +162,7 @@ export default function InsightsGewichtPage() {
|
||||
userTimezone={user?.timezone}
|
||||
valueScale={weightScale}
|
||||
onVisibleStats={onVisibleStats}
|
||||
showDataTable
|
||||
/>
|
||||
|
||||
<MetricTargetSummary slug="weight" />
|
||||
|
||||
@@ -154,6 +154,7 @@ import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
|
||||
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
|
||||
import { buildDashboardBands } from "@/lib/dashboard/bands";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
// v1.18.6 — the pure first-paint / skeleton-reservation gates moved to
|
||||
@@ -718,7 +719,7 @@ export default function DashboardPageClient({
|
||||
() =>
|
||||
buildDashboardBands({
|
||||
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
|
||||
gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
|
||||
gender: toProfileSex(user?.gender),
|
||||
heightCm: user?.heightCm ?? null,
|
||||
}),
|
||||
[user],
|
||||
|
||||
@@ -754,8 +754,9 @@ export default function PrivacyPage() {
|
||||
</li>
|
||||
<li>
|
||||
Profile metadata: locale, timezone, date of birth (optional),
|
||||
biological sex (optional), height (optional) — used to compute
|
||||
age-adjusted target ranges and BMI.
|
||||
gender (optional, one of male, female or other), height
|
||||
(optional) — used to compute age-adjusted target ranges and
|
||||
BMI.
|
||||
</li>
|
||||
</ul>
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
|
||||
@@ -155,6 +155,10 @@ describe("read-failure branch is wired on every swept surface", () => {
|
||||
"src/components/custom-metrics/custom-metric-values-list.tsx",
|
||||
"src/app/achievements/page.tsx",
|
||||
"src/components/insights/nutrients/micronutrients-card.tsx",
|
||||
// A failed comprehensive read used to fall through to "you haven't added
|
||||
// any medications yet" — with a CTA inviting someone on a full daily
|
||||
// regimen to re-enter what the app already holds.
|
||||
"src/app/insights/medications/page.tsx",
|
||||
// The three sleep-rhythm cards share one read, so the PAGE owns the single
|
||||
// error notice rather than each card painting its own copy of it.
|
||||
"src/app/insights/sleep/page.tsx",
|
||||
|
||||
@@ -45,6 +45,14 @@ export interface WorkerStatus {
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/** One queue with exhausted-retry failures, as `/api/admin/status` reports it. */
|
||||
export interface FailingQueue {
|
||||
queue: string;
|
||||
failures: number;
|
||||
lastFailedAt: string;
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string;
|
||||
nodeVersion: string;
|
||||
@@ -53,6 +61,13 @@ export interface SystemStatus {
|
||||
startTime: string;
|
||||
database: string;
|
||||
worker: WorkerStatus;
|
||||
/**
|
||||
* Queues whose jobs exhausted their retries recently. `null` means the
|
||||
* deployment has no pg-boss schema to ask (web-only); an empty `queues`
|
||||
* array means it was asked and nothing is failing. The two are rendered
|
||||
* differently on purpose — absence is not health.
|
||||
*/
|
||||
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
|
||||
counts: {
|
||||
users: number;
|
||||
measurements: number;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -170,14 +171,16 @@ export function AiServerKeySection() {
|
||||
{t("admin.aiServerKey.save")}
|
||||
</Button>
|
||||
{configured && (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="admin-ai-server-key-remove"
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={save.isPending}
|
||||
data-slot="admin-ai-server-key-remove"
|
||||
>
|
||||
{t("admin.aiServerKey.remove")}
|
||||
</Button>
|
||||
label={t("admin.aiServerKey.remove")}
|
||||
title={t("admin.aiServerKey.removeTitle")}
|
||||
body={t("admin.aiServerKey.removeBody")}
|
||||
confirmLabel={t("admin.aiServerKey.removeConfirm")}
|
||||
pending={save.isPending}
|
||||
onConfirm={handleRemove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -180,21 +181,19 @@ export function CentralCodexSection() {
|
||||
)}
|
||||
|
||||
{isConnected ? (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="admin-central-codex-disconnect"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnecting}
|
||||
data-slot="admin-central-codex-disconnect"
|
||||
>
|
||||
{disconnecting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("admin.centralCodex.disconnectButton")}
|
||||
</Button>
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label={t("admin.centralCodex.disconnectButton")}
|
||||
title={t("admin.centralCodex.disconnectTitle")}
|
||||
body={t("admin.centralCodex.disconnectBody")}
|
||||
confirmLabel={t("admin.centralCodex.disconnectConfirm")}
|
||||
pending={disconnecting}
|
||||
onConfirm={handleDisconnect}
|
||||
/>
|
||||
) : deviceCode ? (
|
||||
<div className="border-primary bg-primary/5 space-y-3 rounded-lg border-l-4 p-4">
|
||||
<p className="text-sm font-medium">
|
||||
|
||||
@@ -23,7 +23,12 @@ import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
import {
|
||||
StatusItem,
|
||||
usePublicVersion,
|
||||
useSystemStatus,
|
||||
type FailingQueue,
|
||||
} from "./_shared";
|
||||
|
||||
// v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host
|
||||
// metrics chart so the system-status page only ships it when an admin
|
||||
@@ -43,6 +48,83 @@ const HostMetricsChart = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The queues whose jobs gave up, named.
|
||||
*
|
||||
* Before this, a background job that threw on every single run showed up as one
|
||||
* digit in an unnamed `errors` counter, and the module it fed rendered an empty
|
||||
* screen as if empty were the answer. A broken queue has to say its own name,
|
||||
* so this card is rendered whenever there is anything to report and stays
|
||||
* silent otherwise — an operator should be able to trust that a quiet card
|
||||
* means quiet queues, which is why the "cannot ask" case renders its own line
|
||||
* rather than looking identical to health.
|
||||
*/
|
||||
function FailingJobsCard({
|
||||
failingJobs,
|
||||
}: {
|
||||
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
|
||||
if (failingJobs === null) {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.failingJobs.title")}
|
||||
description={t("admin.failingJobs.description")}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-4 pl-7 text-sm">
|
||||
{t("admin.failingJobs.unavailable")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.failingJobs.title")}
|
||||
description={t("admin.failingJobs.description")}
|
||||
/>
|
||||
{failingJobs.queues.length === 0 ? (
|
||||
<p
|
||||
data-testid="failing-jobs-none"
|
||||
className="text-muted-foreground mt-4 pl-7 text-sm"
|
||||
>
|
||||
{t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
|
||||
</p>
|
||||
) : (
|
||||
<ul data-testid="failing-jobs-list" className="mt-4 space-y-3 pl-7">
|
||||
{failingJobs.queues.map((queue) => (
|
||||
<li key={queue.queue} className="text-sm">
|
||||
<p className="text-foreground font-medium">
|
||||
<span className="font-mono">{queue.queue}</span>{" "}
|
||||
<span className="text-destructive font-normal">
|
||||
{tCount("admin.failingJobs.count", queue.failures, {
|
||||
count: queue.failures,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.failingJobs.lastFailed", {
|
||||
when: formatDateTime(queue.lastFailedAt),
|
||||
})}
|
||||
</p>
|
||||
{queue.lastError.length > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 font-mono text-xs break-all">
|
||||
{queue.lastError}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemStatusSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -216,6 +298,7 @@ export function SystemStatusSection() {
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
{status && <FailingJobsCard failingJobs={status.failingJobs} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
ChartDataTable,
|
||||
type ChartDataTableProps,
|
||||
type ChartTablePoint,
|
||||
} from "../chart-data-table";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const BASE_TS = Date.UTC(2026, 5, 1, 12, 0, 0);
|
||||
|
||||
function points(count: number): ChartTablePoint[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
date: `d${i}`,
|
||||
timestamp: BASE_TS + i * 86_400_000,
|
||||
BLOOD_PRESSURE_SYS: 120 + i,
|
||||
BLOOD_PRESSURE_DIA: 78 + i,
|
||||
}));
|
||||
}
|
||||
|
||||
function props(over: Partial<ChartDataTableProps> = {}): ChartDataTableProps {
|
||||
return {
|
||||
points: points(3),
|
||||
columns: [
|
||||
{ key: "BLOOD_PRESSURE_SYS", label: "Systolic" },
|
||||
{ key: "BLOOD_PRESSURE_DIA", label: "Diastolic" },
|
||||
],
|
||||
unit: "mmHg",
|
||||
formatValue: (v) => `<${v.toFixed(1)}>`,
|
||||
formatDate: (d) => `day:${d.getUTCDate()}`,
|
||||
bucket: "day",
|
||||
metricLabel: "Blood pressure",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<ChartDataTable>", () => {
|
||||
it("renders nothing when the chart drew no points", () => {
|
||||
expect(render(<ChartDataTable {...props({ points: [] })} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when no series carries a column", () => {
|
||||
expect(render(<ChartDataTable {...props({ columns: [] })} />)).toBe("");
|
||||
});
|
||||
|
||||
it("starts collapsed, with the region hidden and the trigger unexpanded", () => {
|
||||
const html = render(<ChartDataTable {...props()} />);
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toMatch(/hidden=""\s+data-slot="chart-data-table-region"/);
|
||||
});
|
||||
|
||||
it("names the point count in the trigger so the size reads while closed", () => {
|
||||
const html = render(<ChartDataTable {...props({ points: points(7) })} />);
|
||||
expect(html).toContain("Data points (7)");
|
||||
});
|
||||
|
||||
it("prints one row per drawn point, newest first", () => {
|
||||
const html = render(<ChartDataTable {...props()} />);
|
||||
const rows = html.match(/data-slot="chart-data-table-row"/g) ?? [];
|
||||
expect(rows).toHaveLength(3);
|
||||
// The fixture runs 1 → 3 June; the table must lead with the 3rd.
|
||||
expect(html.indexOf("day:3")).toBeLessThan(html.indexOf("day:1"));
|
||||
});
|
||||
|
||||
// The whole point of the feature: the table prints the chart's own
|
||||
// formatter output, so a cell and the tooltip cannot show two numbers for
|
||||
// one point.
|
||||
it("prints values through the injected formatter verbatim", () => {
|
||||
const html = render(<ChartDataTable {...props()} />);
|
||||
expect(html).toContain("<120.0>");
|
||||
expect(html).toContain("<78.0>");
|
||||
});
|
||||
|
||||
it("puts the unit in the column header and leaves the cells bare", () => {
|
||||
const html = render(<ChartDataTable {...props()} />);
|
||||
expect(html).toContain("Systolic (mmHg)");
|
||||
expect(html).toContain("Diastolic (mmHg)");
|
||||
expect(html).not.toContain("<120.0> mmHg");
|
||||
});
|
||||
|
||||
it("omits the unit suffix when the chart has none", () => {
|
||||
const html = render(<ChartDataTable {...props({ unit: undefined })} />);
|
||||
expect(html).toContain(">Systolic<");
|
||||
expect(html).not.toContain("Systolic (");
|
||||
});
|
||||
|
||||
it("marks a gap as absent rather than inventing a number", () => {
|
||||
const gapped: ChartTablePoint[] = [
|
||||
{ date: "d0", timestamp: BASE_TS, BLOOD_PRESSURE_SYS: 120 },
|
||||
];
|
||||
const html = render(<ChartDataTable {...props({ points: gapped })} />);
|
||||
expect(html).toContain("—");
|
||||
expect(html).toContain("No value");
|
||||
expect(html).not.toContain("<0.0>");
|
||||
});
|
||||
|
||||
it("captions a weekly bucket as an average, never as a daily reading", () => {
|
||||
const html = render(<ChartDataTable {...props({ bucket: "week" })} />);
|
||||
expect(html).toContain("One row per week of Blood pressure");
|
||||
expect(html).toContain("the average of that week's days");
|
||||
});
|
||||
|
||||
it("captions a monthly bucket as an average", () => {
|
||||
const html = render(<ChartDataTable {...props({ bucket: "month" })} />);
|
||||
expect(html).toContain("One row per month of Blood pressure");
|
||||
});
|
||||
|
||||
it("captions a daily bucket without claiming an average", () => {
|
||||
const html = render(<ChartDataTable {...props()} />);
|
||||
expect(html).toContain("One row per day of Blood pressure");
|
||||
expect(html).not.toContain("the average of");
|
||||
});
|
||||
|
||||
it("makes the date the row header so a row reads as a labelled record", () => {
|
||||
const html = render(<ChartDataTable {...props()} />);
|
||||
expect(html).toContain('scope="row"');
|
||||
expect(html).toContain('scope="col"');
|
||||
});
|
||||
|
||||
// The release just fixed a horizontal-overflow defect; wide content has to
|
||||
// scroll inside its own box, and a long series must not grow the page.
|
||||
it("scrolls inside its own box in both axes", () => {
|
||||
const html = render(<ChartDataTable {...props({ points: points(200) })} />);
|
||||
expect(html).toMatch(
|
||||
/data-slot="chart-data-table-region"[^>]*class="[^"]*overflow-y-auto/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="table-container"[^>]*class="[^"]*overflow-x-auto/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
|
||||
/**
|
||||
* The table under the chart must show what the chart drew.
|
||||
*
|
||||
* These render the real `<HealthChart>` so the table is fed by the component's
|
||||
* own resolution path — the range slice, the bucketing, the row order — rather
|
||||
* than by a fixture handed straight to the table. If the chart's points and the
|
||||
* table's rows can ever diverge, that is the failure this catches.
|
||||
*/
|
||||
|
||||
/** `spacingDays` apart, so a fixed point count can span a chosen range. */
|
||||
function series(count: number, spacingDays = 1) {
|
||||
const base = Date.UTC(2026, 0, 1, 12, 0, 0);
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
date: `p${i}`,
|
||||
timestamp: base + i * spacingDays * 86_400_000,
|
||||
PULSE: 60 + i,
|
||||
}));
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: null, isLoading: false }),
|
||||
}));
|
||||
|
||||
async function renderChart(
|
||||
data: unknown[],
|
||||
extraProps: Record<string, unknown> = {},
|
||||
) {
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data, isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en" initialMessages={enMessages}>
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" {...extraProps} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
return html;
|
||||
}
|
||||
|
||||
function rowCount(html: string): number {
|
||||
return (html.match(/data-slot="chart-data-table-row"/g) ?? []).length;
|
||||
}
|
||||
|
||||
describe("<HealthChart> — data table", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("does not render a table unless the mount asks for one", async () => {
|
||||
const html = await renderChart(series(10));
|
||||
expect(html).not.toContain('data-slot="chart-data-table"');
|
||||
});
|
||||
|
||||
it("renders one row per drawn point when the mount opts in", async () => {
|
||||
const html = await renderChart(series(10), { showDataTable: true });
|
||||
expect(html).toContain('data-slot="chart-data-table"');
|
||||
expect(rowCount(html)).toBe(10);
|
||||
expect(html).toContain("Data points (10)");
|
||||
});
|
||||
|
||||
it("prints the drawn values through the chart's own tooltip formatter", async () => {
|
||||
const html = await renderChart(series(3), { showDataTable: true });
|
||||
// One decimal, the same `formatTooltipValue` the hover read-out uses.
|
||||
expect(html).toContain(">60.0<");
|
||||
expect(html).toContain(">61.0<");
|
||||
expect(html).toContain(">62.0<");
|
||||
});
|
||||
|
||||
// The overlays are drawn FROM the points, not measured. A table of "the data
|
||||
// points behind the chart" that carried them would claim more than the chart
|
||||
// recorded.
|
||||
it("carries no column for the derived overlay series", async () => {
|
||||
const html = await renderChart(series(10), {
|
||||
showDataTable: true,
|
||||
chartKey: "pulse",
|
||||
});
|
||||
expect(html).not.toContain("PULSE_ma");
|
||||
expect(html).not.toContain("PULSE_trend");
|
||||
expect(html).not.toContain("PULSE_compare");
|
||||
// Exactly two column headers: the date and the one series.
|
||||
const headers = html.match(/scope="col"/g) ?? [];
|
||||
expect(headers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("honours the visible range rather than the whole fetched series", async () => {
|
||||
// 40 points fetched, the default range tab draws the newest 30.
|
||||
const html = await renderChart(series(40), { showDataTable: true });
|
||||
expect(rowCount(html)).toBe(30);
|
||||
expect(html).toContain("Data points (30)");
|
||||
});
|
||||
|
||||
it("captions a bucketed range as an average instead of implying daily readings", async () => {
|
||||
// 30 points four days apart span 116 days, so the chart buckets to ISO
|
||||
// weeks and several points share a bucket. The rows are weekly means and
|
||||
// the caption has to say so.
|
||||
const html = await renderChart(series(30, 4), { showDataTable: true });
|
||||
expect(html).toContain("One row per week of");
|
||||
expect(html).toContain("the average of that week");
|
||||
expect(html).not.toContain("One row per day of");
|
||||
// Weekly buckets collapse the 30 daily points into fewer rows.
|
||||
expect(rowCount(html)).toBeLessThan(30);
|
||||
expect(rowCount(html)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows no table at all when the window holds nothing", async () => {
|
||||
const html = await renderChart([], { showDataTable: true });
|
||||
expect(html).not.toContain('data-slot="chart-data-table"');
|
||||
});
|
||||
|
||||
it("stays off a mini chart even when the prop is set", async () => {
|
||||
const html = await renderChart(series(10), {
|
||||
showDataTable: true,
|
||||
mini: true,
|
||||
});
|
||||
expect(html).not.toContain('data-slot="chart-data-table"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChartBucketType } from "@/lib/charts/bucket-time-series";
|
||||
|
||||
/**
|
||||
* The data points behind the chart, as a table.
|
||||
*
|
||||
* A chart shows the shape and hides the numbers. A doctor's appointment needs
|
||||
* the numbers, and so does anyone moving readings into a spreadsheet. This
|
||||
* renders the series the chart just drew, row per point, column per metric.
|
||||
*
|
||||
* It takes the FINALISED array — the same expression Recharts is handed, after
|
||||
* the range slice, the week/month bucketing and the display scale — and it
|
||||
* takes the chart's own value and date formatters as props. Nothing is
|
||||
* re-derived here, so the table cannot disagree with the line above it or with
|
||||
* the tooltip: they are the same numbers put through the same formatter.
|
||||
*
|
||||
* Derived overlays (moving average, trend line, comparison period) are NOT
|
||||
* columns. They are drawn from the points rather than measured, and a table of
|
||||
* "the data points behind the chart" that carried them would be claiming more
|
||||
* than the chart recorded.
|
||||
*
|
||||
* Collapsed by default: the chart is the primary read, and a hundred rows
|
||||
* expanded under every metric page would bury everything below it. The point
|
||||
* count rides in the trigger so the size is legible without opening. Once open
|
||||
* the region scrolls in its own box rather than growing the page.
|
||||
*
|
||||
* No Collapsible primitive exists in the UI kit, so this follows the same
|
||||
* button + region disclosure the glucose panel uses: `aria-expanded` on the
|
||||
* trigger, `aria-controls` / `hidden` on the region.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Structurally the chart's own `ChartDataPoint`. Declared here rather than
|
||||
* imported so the table stays independent of the chart module (which is behind
|
||||
* a lazy boundary and would otherwise import in a cycle).
|
||||
*/
|
||||
export interface ChartTablePoint {
|
||||
date: string;
|
||||
timestamp: number;
|
||||
[key: string]: string | number | undefined | [number, number];
|
||||
}
|
||||
|
||||
export interface ChartDataTableColumn {
|
||||
/** The point key to read — a `MeasurementType` string. */
|
||||
key: string;
|
||||
/** Resolved column label, from the chart's own series-label helper. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ChartDataTableProps {
|
||||
/** The finalised points, in the chart's own order (oldest first). */
|
||||
points: readonly ChartTablePoint[];
|
||||
columns: readonly ChartDataTableColumn[];
|
||||
/** Display unit for every column; appended to each header when present. */
|
||||
unit?: string | undefined;
|
||||
/** The chart's value formatter — the one its tooltip uses. */
|
||||
formatValue: (value: number) => string;
|
||||
/** The chart's date formatter — the one its axis and tooltip use. */
|
||||
formatDate: (date: Date) => string;
|
||||
/** The grain of one row, so the caption never implies a daily reading. */
|
||||
bucket: ChartBucketType;
|
||||
/** Metric name for the table caption. */
|
||||
metricLabel: string;
|
||||
}
|
||||
|
||||
const BUCKET_CAPTION_KEY: Record<ChartBucketType, string> = {
|
||||
day: "charts.dataTable.captionDay",
|
||||
week: "charts.dataTable.captionWeek",
|
||||
month: "charts.dataTable.captionMonth",
|
||||
};
|
||||
|
||||
export function ChartDataTable({
|
||||
points,
|
||||
columns,
|
||||
unit,
|
||||
formatValue,
|
||||
formatDate,
|
||||
bucket,
|
||||
metricLabel,
|
||||
}: ChartDataTableProps) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const regionId = useId();
|
||||
|
||||
if (points.length === 0 || columns.length === 0) return null;
|
||||
|
||||
// Newest first, matching the measurement list — the two places the app
|
||||
// prints the same readings must not disagree on reading order.
|
||||
const rows = [...points].reverse();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-data-table"
|
||||
className="border-border mt-3 border-t pt-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="chart-data-table-toggle"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span>{t("charts.dataTable.toggle", { count: points.length })}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn("size-4 transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={regionId}
|
||||
hidden={!open}
|
||||
data-slot="chart-data-table-region"
|
||||
className="max-h-96 overflow-y-auto overscroll-contain pt-2"
|
||||
>
|
||||
<Table>
|
||||
<TableCaption className="mt-2 text-left text-xs">
|
||||
{t(BUCKET_CAPTION_KEY[bucket], { metric: metricLabel })}
|
||||
</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">
|
||||
{t("charts.dataTable.columnDate")}
|
||||
</TableHead>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.key} scope="col" className="text-right">
|
||||
{unit ? `${column.label} (${unit})` : column.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((point) => (
|
||||
<TableRow key={point.timestamp} data-slot="chart-data-table-row">
|
||||
{/* The date is the row's header, so a screen reader reads
|
||||
"12 July, systolic 128" rather than a bare number. */}
|
||||
<TableHead
|
||||
scope="row"
|
||||
className="text-foreground h-auto p-2 font-normal"
|
||||
>
|
||||
{formatDate(new Date(point.timestamp))}
|
||||
</TableHead>
|
||||
{columns.map((column) => {
|
||||
const value = point[column.key];
|
||||
const numeric =
|
||||
typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: null;
|
||||
return (
|
||||
<TableCell
|
||||
key={column.key}
|
||||
className="text-foreground text-right tabular-nums"
|
||||
>
|
||||
{numeric === null ? (
|
||||
<>
|
||||
<span aria-hidden="true">—</span>
|
||||
<span className="sr-only">
|
||||
{t("charts.dataTable.noValue")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
formatValue(numeric)
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -32,6 +32,7 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -687,20 +688,20 @@ export function LogDaySheet({
|
||||
footer={
|
||||
<>
|
||||
{rowId ? (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="cycle-day-delete"
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
disabled={busy}
|
||||
className="text-destructive hover:text-destructive mr-auto"
|
||||
aria-label={t("cycle.sheet.delete")}
|
||||
>
|
||||
{deleteDay.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("cycle.sheet.delete")}
|
||||
</Button>
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label={t("cycle.sheet.delete")}
|
||||
ariaLabel={t("cycle.sheet.delete")}
|
||||
title={t("cycle.sheet.deleteTitle")}
|
||||
body={t("cycle.sheet.deleteBody")}
|
||||
confirmLabel={t("cycle.sheet.deleteConfirm")}
|
||||
disabled={busy && !deleteDay.isPending}
|
||||
pending={deleteDay.isPending}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1199,6 +1200,7 @@ function SymptomChip({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const deleteCustom = useDeleteCustomSymptom();
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
|
||||
async function handleRemove() {
|
||||
if (!custom) return;
|
||||
@@ -1222,7 +1224,7 @@ function SymptomChip({
|
||||
{custom ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
disabled={deleteCustom.isPending}
|
||||
aria-label={t("cycle.symptom.custom.remove", { label })}
|
||||
className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 relative -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors before:absolute before:-inset-2 before:content-[''] focus-visible:ring-2 focus-visible:outline-none"
|
||||
@@ -1230,6 +1232,18 @@ function SymptomChip({
|
||||
<X className="size-2.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
{custom ? (
|
||||
<ConfirmDialog
|
||||
slot="cycle-custom-symptom-remove"
|
||||
open={confirmRemove}
|
||||
onOpenChange={setConfirmRemove}
|
||||
title={t("cycle.symptom.custom.removeTitle")}
|
||||
body={t("cycle.symptom.custom.removeBody", { label })}
|
||||
confirmLabel={t("cycle.symptom.custom.removeConfirm")}
|
||||
pending={deleteCustom.isPending}
|
||||
onConfirm={handleRemove}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{active ? (
|
||||
<div
|
||||
|
||||
@@ -433,6 +433,7 @@ export function HealthKitMetricPage({
|
||||
valueScale={resolvedScale}
|
||||
valueOffset={resolvedOffset}
|
||||
onVisibleStats={onVisibleStats}
|
||||
showDataTable
|
||||
/>
|
||||
{targetSummarySlug ? (
|
||||
<MetricTargetSummary slug={targetSummarySlug} />
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -230,20 +231,19 @@ export function InsightsEditMode({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="insights-edit-reset"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={busy}
|
||||
data-slot="insights-edit-reset"
|
||||
>
|
||||
{resetMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("insights.editMode.reset")}
|
||||
</Button>
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
label={t("insights.editMode.reset")}
|
||||
title={t("insights.editMode.resetTitle")}
|
||||
body={t("insights.editMode.resetBody")}
|
||||
confirmLabel={t("insights.editMode.resetConfirm")}
|
||||
disabled={busy && !resetMutation.isPending}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate()}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
MoodTagMetricCrosstab,
|
||||
type MoodTagMetricCrosstabRow,
|
||||
} from "../mood-tag-metric-crosstab";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function row(
|
||||
over: Partial<MoodTagMetricCrosstabRow> = {},
|
||||
): MoodTagMetricCrosstabRow {
|
||||
return {
|
||||
tag: "alcohol",
|
||||
labelKey: "mood.tag.alcohol",
|
||||
label: null,
|
||||
categoryKey: "consumption",
|
||||
icon: "Wine",
|
||||
metricKey: "nextDayRestingHeartRate",
|
||||
display: "bpm",
|
||||
mode: "nextDay",
|
||||
withDays: 12,
|
||||
withoutDays: 14,
|
||||
withAvg: 62.4,
|
||||
withoutAvg: 54.1,
|
||||
delta: 8.3,
|
||||
pValue: 0.002,
|
||||
qValue: 0.02,
|
||||
confidence: "high",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<MoodTagMetricCrosstab>", () => {
|
||||
it("renders nothing for an empty list", () => {
|
||||
expect(render(<MoodTagMetricCrosstab rows={[]} />)).toBe("");
|
||||
});
|
||||
|
||||
it("labels a next-day resting-heart-rate row and prints its bpm unit", () => {
|
||||
const html = render(<MoodTagMetricCrosstab rows={[row()]} />);
|
||||
expect(html).toContain("Next-day resting heart rate");
|
||||
expect(html).toContain("+8.3 bpm");
|
||||
expect(html).toContain('data-metric="nextDayRestingHeartRate"');
|
||||
});
|
||||
|
||||
it("labels a next-day HRV row and prints its ms unit", () => {
|
||||
const html = render(
|
||||
<MoodTagMetricCrosstab
|
||||
rows={[
|
||||
row({
|
||||
metricKey: "nextDayHeartRateVariability",
|
||||
display: "ms",
|
||||
withAvg: 38.2,
|
||||
withoutAvg: 55.6,
|
||||
delta: -17.4,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next-day heart-rate variability");
|
||||
expect(html).toContain("-17.4 ms");
|
||||
expect(html).toContain('data-metric="nextDayHeartRateVariability"');
|
||||
});
|
||||
|
||||
it("captions a next-day row with the day-after wording", () => {
|
||||
const html = render(<MoodTagMetricCrosstab rows={[row()]} />);
|
||||
expect(html).toContain("the day after the 12 days you tagged this");
|
||||
});
|
||||
|
||||
// The board mixes metrics of opposite valence — a higher next-day resting
|
||||
// heart rate is worse, a higher active energy is better — so the delta
|
||||
// read-out must stay neutral rather than assert a verdict in colour.
|
||||
it("keeps the delta read-out neutral instead of colouring it by sign", () => {
|
||||
const up = render(<MoodTagMetricCrosstab rows={[row()]} />);
|
||||
const down = render(
|
||||
<MoodTagMetricCrosstab rows={[row({ delta: -8.3 })]} />,
|
||||
);
|
||||
for (const html of [up, down]) {
|
||||
expect(html).not.toContain("var(--success)");
|
||||
expect(html).not.toContain("var(--destructive)");
|
||||
}
|
||||
expect(up).toContain('data-direction="up"');
|
||||
expect(down).toContain('data-direction="down"');
|
||||
});
|
||||
});
|
||||
@@ -16,9 +16,15 @@ import type { MoodInfluenceConfidence } from "./mood-tag-influence";
|
||||
* relations surface uses; only FDR-surviving rows reach here. Observational
|
||||
* only: the generic "associations, not causes" caveat lives once in the
|
||||
* page-level Insights footer, so this card no longer repeats it.
|
||||
*
|
||||
* The delta read-out is NEUTRAL, matching the rated-factor board. Once the
|
||||
* board carries next-day resting heart rate alongside sleep and active energy,
|
||||
* a positive delta is good on one row and bad on the next, so a green/red
|
||||
* sign would assert a health verdict the association does not support. The
|
||||
* sign prefix and `data-direction` carry the direction instead.
|
||||
*/
|
||||
|
||||
export type MoodCrosstabDisplay = "hours" | "kcal" | "score";
|
||||
export type MoodCrosstabDisplay = "hours" | "kcal" | "score" | "bpm" | "ms";
|
||||
export type MoodCrosstabMode = "sameDay" | "nextDay";
|
||||
|
||||
export interface MoodTagMetricCrosstabRow {
|
||||
@@ -49,12 +55,16 @@ const METRIC_LABEL_KEY: Record<string, string> = {
|
||||
activeEnergy: "insights.mood.crosstab.metricActiveEnergy",
|
||||
sleepDuration: "insights.mood.crosstab.metricSleepDuration",
|
||||
nextDayRecovery: "insights.mood.crosstab.metricNextDayRecovery",
|
||||
nextDayRestingHeartRate: "insights.mood.crosstab.metricNextDayRestingHr",
|
||||
nextDayHeartRateVariability: "insights.mood.crosstab.metricNextDayHrv",
|
||||
};
|
||||
|
||||
const UNIT_KEY: Record<MoodCrosstabDisplay, string> = {
|
||||
hours: "insights.mood.crosstab.unitHours",
|
||||
kcal: "insights.mood.crosstab.unitKcal",
|
||||
score: "insights.mood.crosstab.unitScore",
|
||||
bpm: "insights.mood.crosstab.unitBpm",
|
||||
ms: "insights.mood.crosstab.unitMs",
|
||||
};
|
||||
|
||||
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
|
||||
@@ -101,9 +111,6 @@ export function MoodTagMetricCrosstab({
|
||||
const unit = t(UNIT_KEY[row.display]);
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
|
||||
// Semantic feedback tokens (not raw `--dracula-*`): the delta read-out
|
||||
// is COLORED TEXT, so it needs the Alucard light-mode override for AA.
|
||||
const deltaColor = up ? "var(--success)" : "var(--destructive)";
|
||||
return (
|
||||
<li
|
||||
key={`${row.metricKey}:${row.tag}`}
|
||||
@@ -129,10 +136,7 @@ export function MoodTagMetricCrosstab({
|
||||
metric: metricLabel,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-sm font-semibold tabular-nums"
|
||||
style={{ color: deltaColor }}
|
||||
>
|
||||
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
|
||||
{deltaText}
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -82,7 +82,20 @@ export function SectionHeading({
|
||||
</div>
|
||||
</div>
|
||||
{action ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{action}</div>
|
||||
// The action slot must be able to SHRINK. It was `shrink-0`, which
|
||||
// pins a flex item at its max-content width — fine for the compact
|
||||
// control the slot was designed for, wrong for the prose every real
|
||||
// caller passes today (the provenance method caption, the
|
||||
// device-score subtitle, the sleep-quality subtitle). A sentence at
|
||||
// max-content is ~1.2-1.5k px, and because the row sits inside the
|
||||
// AuthShell `<main>` — whose `overflow-y:auto` makes `overflow-x`
|
||||
// compute to `auto` — that width became a horizontally scrollable
|
||||
// page at every phone width, growing with the locale's string length.
|
||||
// `min-w-0` lifts the flex `min-width:auto` floor so the text can wrap
|
||||
// to the row width instead. The parent's `flex-wrap` still moves the
|
||||
// slot onto its own line before anything is squeezed, so a compact
|
||||
// control keeps its size.
|
||||
<div className="flex min-w-0 items-center gap-2">{action}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* #650 — the intake import says what actually happened.
|
||||
*
|
||||
* The report was a file of 28 doses where 27 were "skipped" and the dialog
|
||||
* answered in success tone with a duplicate count, so nothing on screen said
|
||||
* the history had not landed. The cause is fixed elsewhere; this pins the
|
||||
* telling, which is what turned a recoverable problem into a silent one.
|
||||
*
|
||||
* Project convention is SSR-only component tests (vitest runs `node`;
|
||||
* `@testing-library/react` is not installed), so the view takes its state as a
|
||||
* prop and each case is rendered directly.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
IntakeImportResultView,
|
||||
type IntakeImportResultState,
|
||||
} from "../intake-import-result";
|
||||
|
||||
/** `<li>` elements only — the lucide icon markup carries `<line>` tags. */
|
||||
function countListItems(html: string): number {
|
||||
return html.split("<li>").length - 1;
|
||||
}
|
||||
|
||||
function render(state: IntakeImportResultState): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<IntakeImportResultView result={state} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** What the reported run would have produced before the key was fixed. */
|
||||
const ALL_SKIPPED: IntakeImportResultState = {
|
||||
kind: "outcome",
|
||||
imported: 0,
|
||||
skipped: 27,
|
||||
skipReasons: [{ reason: "duplicate_in_file", count: 27 }],
|
||||
};
|
||||
|
||||
describe("<IntakeImportResultView> — an import that wrote nothing", () => {
|
||||
it("renders as a failure, never a success", () => {
|
||||
const html = render(ALL_SKIPPED);
|
||||
expect(html).toContain('data-outcome="failed"');
|
||||
expect(html).not.toContain('data-outcome="success"');
|
||||
expect(html).toContain("Nothing was imported");
|
||||
expect(html).toContain("27");
|
||||
});
|
||||
|
||||
it("names the skip reason once with its count, not once per entry", () => {
|
||||
const html = render(ALL_SKIPPED);
|
||||
expect(html).toContain("27 entries skipped");
|
||||
expect(html).toContain("same date and time");
|
||||
// One list item for one reason — 27 entries must not become 27 lines.
|
||||
expect(countListItems(html)).toBe(1);
|
||||
});
|
||||
|
||||
it("separates the two skip reasons instead of calling both duplicates", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 4,
|
||||
skipped: 5,
|
||||
skipReasons: [
|
||||
{ reason: "already_recorded", count: 3 },
|
||||
{ reason: "duplicate_in_file", count: 2 },
|
||||
],
|
||||
});
|
||||
expect(html).toContain("3 entries skipped");
|
||||
expect(html).toContain("already recorded");
|
||||
expect(html).toContain("2 entries skipped");
|
||||
expect(html).toContain("another entry in the same file");
|
||||
expect(countListItems(html)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeImportResultView> — the other outcomes", () => {
|
||||
it("renders a mixed run as a warning, not a tick", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 26,
|
||||
skipped: 2,
|
||||
skipReasons: [{ reason: "already_recorded", count: 2 }],
|
||||
});
|
||||
expect(html).toContain('data-outcome="partial"');
|
||||
expect(html).toContain("26 imported, 2 skipped");
|
||||
});
|
||||
|
||||
it("renders a clean run as a success", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 28,
|
||||
skipped: 0,
|
||||
skipReasons: [],
|
||||
});
|
||||
expect(html).toContain('data-outcome="success"');
|
||||
expect(html).toContain("28 intakes imported");
|
||||
expect(countListItems(html)).toBe(0);
|
||||
});
|
||||
|
||||
it("gives a file with no intakes its own message rather than either", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
skipReasons: [],
|
||||
});
|
||||
expect(html).toContain('data-outcome="empty"');
|
||||
expect(html).not.toContain('data-outcome="success"');
|
||||
expect(html).toContain("no intakes");
|
||||
});
|
||||
|
||||
it("falls back to a stated absence for an unrecognised reason", () => {
|
||||
const html = render({
|
||||
kind: "outcome",
|
||||
imported: 0,
|
||||
skipped: 1,
|
||||
skipReasons: [{ reason: "something_new", count: 1 }],
|
||||
});
|
||||
expect(html).toContain("reason not recorded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeImportResultView> — notices are not outcomes", () => {
|
||||
it("reports a loaded file in neutral tone, not as an import success", () => {
|
||||
const html = render({
|
||||
kind: "notice",
|
||||
tone: "info",
|
||||
text: 'File "intakes.json" loaded',
|
||||
});
|
||||
expect(html).toContain('data-tone="info"');
|
||||
expect(html).not.toContain("text-success");
|
||||
expect(html).not.toContain("data-outcome");
|
||||
});
|
||||
|
||||
it("reports a refusal in destructive tone", () => {
|
||||
const html = render({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: "Import failed",
|
||||
});
|
||||
expect(html).toContain("text-destructive");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Bestand item-row dates: visible on the row, editable in the correction
|
||||
* dialog.
|
||||
*
|
||||
* Both dates decide the container's state — the printed expiry directly,
|
||||
* the opening date through the post-opening window on a pen or an
|
||||
* ampoule — and the opening date is written by the intake consumption
|
||||
* hook without the user ever typing it. Until now neither was rendered
|
||||
* anywhere and neither could be corrected, so a container could read
|
||||
* EXPIRED with nothing on screen to explain why.
|
||||
*
|
||||
* Project convention is SSR-only component tests (`renderToStaticMarkup`).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const src = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"src/components/medications/sections/inventory-section.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// The Radix Dialog / Select portal at runtime, so their bodies never
|
||||
// materialise in static markup. Collapse the primitives to plain
|
||||
// wrappers (same trick as the AddInventoryDialog suite).
|
||||
vi.mock("@/components/ui/dialog", () => {
|
||||
const Pass = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
return {
|
||||
Dialog: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div data-slot="mock-dialog">{children}</div>
|
||||
),
|
||||
DialogContent: Pass,
|
||||
DialogDescription: Pass,
|
||||
DialogFooter: Pass,
|
||||
DialogHeader: Pass,
|
||||
DialogTitle: Pass,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/select", () => {
|
||||
const Pass = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
return {
|
||||
Select: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children?: React.ReactNode;
|
||||
}) => <div data-mock-select-value={value}>{children}</div>,
|
||||
SelectContent: Pass,
|
||||
SelectItem: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div data-slot="mock-select-item">{children}</div>
|
||||
),
|
||||
SelectTrigger: Pass,
|
||||
SelectValue: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { InventorySection, AddInventoryDialog, AdjustInventoryDialog } =
|
||||
await import("../sections/inventory-section");
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function seedItems(items: unknown[]) {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: { items, meta: { total: items.length } },
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
|
||||
containerType: "PEN" | "AMPOULE" | "BLISTER" | "INHALER" | "BOTTLE" | "OTHER";
|
||||
unitsTotal: number | null;
|
||||
unitsRemaining: number | null;
|
||||
printedExpiry: string | null;
|
||||
firstUseAt: string | null;
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
id: "i1",
|
||||
state: "IN_USE",
|
||||
containerType: "BLISTER",
|
||||
unitsTotal: 60,
|
||||
unitsRemaining: 54,
|
||||
} satisfies Omit<Row, "printedExpiry" | "firstUseAt">;
|
||||
|
||||
describe("<InventorySection> — item-row dates", () => {
|
||||
it("renders the opening date on the row", () => {
|
||||
seedItems([
|
||||
{
|
||||
...BASE,
|
||||
firstUseAt: "2026-06-12T00:00:00.000Z",
|
||||
printedExpiry: null,
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="inventory-item-dates"');
|
||||
expect(html).toContain("Geöffnet 12.06.2026");
|
||||
});
|
||||
|
||||
it("renders the printed expiry on the row", () => {
|
||||
seedItems([
|
||||
{
|
||||
...BASE,
|
||||
state: "ACTIVE",
|
||||
firstUseAt: null,
|
||||
printedExpiry: "2027-06-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
expect(html).toContain("Haltbar bis 01.06.2027");
|
||||
});
|
||||
|
||||
it("separates the two dates when the row carries both", () => {
|
||||
seedItems([
|
||||
{
|
||||
...BASE,
|
||||
firstUseAt: "2026-06-12T00:00:00.000Z",
|
||||
printedExpiry: "2027-06-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
expect(html).toContain("Geöffnet 12.06.2026");
|
||||
expect(html).toContain("Haltbar bis 01.06.2027");
|
||||
expect(html).toContain("<span>·</span>");
|
||||
});
|
||||
|
||||
it("shows no date line when neither date is recorded", () => {
|
||||
// Absence reads as absence — no placeholder, no invented date.
|
||||
seedItems([{ ...BASE, firstUseAt: null, printedExpiry: null }]);
|
||||
const html = render(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="inventory-item-dates"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AdjustInventoryDialog> — editable dates", () => {
|
||||
const item: Row = {
|
||||
...BASE,
|
||||
firstUseAt: "2026-06-12T00:00:00.000Z",
|
||||
printedExpiry: "2027-06-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function renderDialog(over: Partial<Row> = {}): string {
|
||||
return render(
|
||||
<AdjustInventoryDialog
|
||||
medicationId="med-1"
|
||||
item={{ ...item, ...over }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("offers a printed-expiry field prefilled from the item", () => {
|
||||
const html = renderDialog();
|
||||
expect(html).toContain('id="inventory-edit-expiry"');
|
||||
expect(html).toContain("Aufgedrucktes Haltbarkeitsdatum");
|
||||
expect(html).toContain('value="2027-06-01"');
|
||||
});
|
||||
|
||||
it("offers an opening-date field prefilled from the item", () => {
|
||||
const html = renderDialog();
|
||||
expect(html).toContain('id="inventory-edit-opened"');
|
||||
expect(html).toContain("Geöffnet am");
|
||||
expect(html).toContain('value="2026-06-12"');
|
||||
});
|
||||
|
||||
it("leaves both date fields empty when the item records neither", () => {
|
||||
const html = renderDialog({ firstUseAt: null, printedExpiry: null });
|
||||
expect(html).toContain('id="inventory-edit-expiry"');
|
||||
expect(html).toContain('id="inventory-edit-opened"');
|
||||
expect(html).not.toContain("2027-06-01");
|
||||
expect(html).not.toContain("2026-06-12");
|
||||
});
|
||||
|
||||
it("explains the 30-day window beside the opening date on a pen", () => {
|
||||
const html = renderDialog({ containerType: "PEN" });
|
||||
expect(html).toContain("30 Tage nach dem Öffnen");
|
||||
});
|
||||
|
||||
it("omits the window explanation for a container that has none", () => {
|
||||
const html = renderDialog({ containerType: "BLISTER" });
|
||||
expect(html).not.toContain("30 Tage nach dem Öffnen");
|
||||
expect(html).toContain("noch verschlossen");
|
||||
});
|
||||
|
||||
it("keeps the remaining-units correction alongside the dates", () => {
|
||||
const html = renderDialog();
|
||||
expect(html).toContain('id="inventory-adjust-remaining"');
|
||||
});
|
||||
|
||||
// Submitting is out of SSR reach, so the wire contract is pinned
|
||||
// against the source (same approach as the register-dialog suite).
|
||||
it("sends each date only when it actually changed", () => {
|
||||
// Absent means untouched on the server: saving a unit correction
|
||||
// must not rewrite a date the user never opened.
|
||||
expect(src).toMatch(
|
||||
/\.\.\.\(expiry !== initialExpiry && \{\s*\n?\s*printedExpiry: fromLocalDayInput\(expiry\),/,
|
||||
);
|
||||
expect(src).toMatch(
|
||||
/\.\.\.\(opened !== initialOpened && \{\s*\n?\s*markAsFirstUseAt: fromLocalDayInput\(opened\),/,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a cleared date as null, not as an empty string", () => {
|
||||
// null is the deliberate "there is no such date"; the route reads it
|
||||
// as a clear, which is the way back out of a wrong auto-open.
|
||||
expect(src).toMatch(
|
||||
/function fromLocalDayInput\(day: string\): string \| null \{\s*\n\s*if \(!day\) return null;/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AddInventoryDialog> — printed expiry on create", () => {
|
||||
it("still offers the expiry field", () => {
|
||||
// Regression guard: the create path always offered the printed
|
||||
// expiry; the correction path is the addition.
|
||||
const html = render(
|
||||
<AddInventoryDialog
|
||||
medicationId="med-1"
|
||||
defaultUnitsTotal={30}
|
||||
unitsPerDose={1}
|
||||
initialContainerType="BLISTER"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('id="inventory-add-expiry"');
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,11 @@ function seedItems(
|
||||
}
|
||||
|
||||
describe("<InventorySection> — summary availability", () => {
|
||||
it("one expired pen with units ⇒ headline 0, expired units as muted suffix", () => {
|
||||
it("only expired stock ⇒ the headline says so instead of '0 of 0'", () => {
|
||||
// "0 von 0 Dosen übrig" beside "+ 4 Einheiten abgelaufen" reads as
|
||||
// two contradicting statements: the first says nothing was ever
|
||||
// recorded, the second says stock was written off. One honest line
|
||||
// replaces both.
|
||||
seedItems([
|
||||
{
|
||||
id: "i1",
|
||||
@@ -81,9 +85,9 @@ describe("<InventorySection> — summary availability", () => {
|
||||
const html = render(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
expect(html).toContain("0 von 0 Dosen übrig");
|
||||
expect(html).toContain('data-slot="inventory-expired-suffix"');
|
||||
expect(html).toContain("+ 4 Einheiten abgelaufen");
|
||||
expect(html).toContain("Kein nutzbarer Bestand: 4 Einheiten");
|
||||
expect(html).not.toContain("0 von 0 Dosen übrig");
|
||||
expect(html).not.toContain('data-slot="inventory-expired-suffix"');
|
||||
// The item row itself stays visible with its state badge.
|
||||
expect(html).toContain("Abgelaufen");
|
||||
});
|
||||
@@ -134,5 +138,25 @@ describe("<InventorySection> — summary availability", () => {
|
||||
);
|
||||
expect(html).toContain("2 von 4 Dosen übrig");
|
||||
expect(html).toContain("+ 3 Einheiten abgelaufen");
|
||||
expect(html).not.toContain("Kein nutzbarer Bestand");
|
||||
});
|
||||
|
||||
it("keeps the plain empty-supply reading when nothing was written off", () => {
|
||||
// Every container drained: zero available AND zero expired. That is
|
||||
// the honest "0 of 0", and it must not be replaced.
|
||||
seedItems([
|
||||
{
|
||||
id: "i1",
|
||||
state: "USED_UP",
|
||||
containerType: "PEN",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 0,
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
expect(html).toContain("0 von 0 Dosen übrig");
|
||||
expect(html).not.toContain("Kein nutzbarer Bestand");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,11 @@ import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react";
|
||||
|
||||
import {
|
||||
IntakeImportResultView,
|
||||
type IntakeImportResultState,
|
||||
} from "@/components/medications/intake-import-result";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
@@ -51,10 +56,7 @@ export function IntakeImportDialog({
|
||||
const { t } = useTranslations();
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [resultType, setResultType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [result, setResult] = useState<IntakeImportResultState | null>(null);
|
||||
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -62,7 +64,6 @@ export function IntakeImportDialog({
|
||||
function resetImportForm() {
|
||||
setJsonText("");
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
setSelectedFileName(null);
|
||||
setFileInputKey((prev) => prev + 1);
|
||||
}
|
||||
@@ -77,18 +78,25 @@ export function IntakeImportDialog({
|
||||
if (!file) return;
|
||||
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
JSON.parse(content);
|
||||
setJsonText(content);
|
||||
setSelectedFileName(file.name);
|
||||
setResult(t("medications.importFileLoaded", { name: file.name }));
|
||||
setResultType("success");
|
||||
// A readable file is a step, not an import. It says so in neutral tone —
|
||||
// the success affordance belongs to a run that actually wrote rows.
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "info",
|
||||
text: t("medications.importFileLoaded", { name: file.name }),
|
||||
});
|
||||
} catch {
|
||||
setResult(t("medications.importInvalidJson"));
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: t("medications.importInvalidJson"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +104,6 @@ export function IntakeImportDialog({
|
||||
if (!medicationId || !jsonText.trim()) return;
|
||||
setImporting(true);
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
let data = JSON.parse(jsonText.trim());
|
||||
@@ -114,37 +121,41 @@ export function IntakeImportDialog({
|
||||
statusUrl: string;
|
||||
}>(`/api/medications/${medicationId}/intake/import`, data);
|
||||
const d = await waitForMedicationIntakeImport(kickoff.statusUrl);
|
||||
setResult(
|
||||
t("medications.importResult", { imported: d.imported }) +
|
||||
(d.skippedDuplicates > 0
|
||||
? `, ${t("medications.importDuplicatesSkipped", { count: d.skippedDuplicates })}`
|
||||
: "") +
|
||||
(d.skippedInvalid > 0
|
||||
? `, ${t("medications.importInvalidSkipped", { count: d.skippedInvalid })}`
|
||||
: ""),
|
||||
);
|
||||
setResultType("success");
|
||||
setResult({
|
||||
kind: "outcome",
|
||||
imported: d.imported,
|
||||
skipped: d.skipped,
|
||||
skipReasons: d.skipReasons,
|
||||
});
|
||||
// An import can land today-dated doses, so clear the Today hero +
|
||||
// dashboard through the blessed helper (they are unmounted behind the
|
||||
// import dialog on the detail page).
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch (err) {
|
||||
if (err instanceof MedicationIntakeImportError) {
|
||||
setResult(t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: t("medications.importFailed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!(err instanceof ApiError)) throw err;
|
||||
setResult(err.message || t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text: err.message || t("medications.importFailed"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(
|
||||
err instanceof SyntaxError
|
||||
? t("medications.importInvalidFormat")
|
||||
: (err as Error).message || t("medications.importFailed"),
|
||||
);
|
||||
setResultType("error");
|
||||
setResult({
|
||||
kind: "notice",
|
||||
tone: "error",
|
||||
text:
|
||||
err instanceof SyntaxError
|
||||
? t("medications.importInvalidFormat")
|
||||
: (err as Error).message || t("medications.importFailed"),
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -226,8 +237,8 @@ export function IntakeImportDialog({
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43"},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42"}
|
||||
]`}
|
||||
</pre>
|
||||
<Textarea
|
||||
@@ -239,15 +250,9 @@ export function IntakeImportDialog({
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
<div role="status" aria-live="polite">
|
||||
{result && <IntakeImportResultView result={result} />}
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* What the intake-import dialog says once an attempt has finished.
|
||||
*
|
||||
* Split out of the dialog so the outcome can be rendered and asserted on its
|
||||
* own — the dialog itself is a sheet with a query client and a file input, and
|
||||
* the thing that has to be right is the sentence at the end of the run.
|
||||
*
|
||||
* The rules are the ones the settings import panel already follows, reused
|
||||
* rather than restated: `classifyImportOutcome` decides the tone from the
|
||||
* counts, so an import that wrote nothing can never carry the success tick,
|
||||
* and `ImportOutcomeLine` owns the tick and the success colour. The skips are
|
||||
* grouped by reason with a count, because a file that trips one rule trips it
|
||||
* on every row and a per-row list is the same sentence a hundred times.
|
||||
*
|
||||
* This dialog was not covered when that work landed for the CSV and JSON
|
||||
* cards; the medication intake import is the third import surface and it now
|
||||
* answers the same way.
|
||||
*/
|
||||
import {
|
||||
classifyImportOutcome,
|
||||
type ImportOutcome,
|
||||
} from "@/components/settings/import-panel/import-result-state";
|
||||
import { ImportOutcomeLine } from "@/components/settings/import-panel/import-result-view";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** A skip group exactly as the import job reports it. */
|
||||
export interface IntakeImportSkipGroup {
|
||||
reason: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `notice` covers everything that happened before the importer had an opinion —
|
||||
* a file was read, the JSON did not parse, the request was refused. `outcome`
|
||||
* is the import itself.
|
||||
*/
|
||||
export type IntakeImportResultState =
|
||||
| { kind: "notice"; tone: "info" | "error"; text: string }
|
||||
| {
|
||||
kind: "outcome";
|
||||
imported: number;
|
||||
skipped: number;
|
||||
skipReasons: IntakeImportSkipGroup[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Plain language per machine-readable skip reason. Written as literal `t(…)`
|
||||
* calls rather than a computed key so the i18n call-site coverage guard sees
|
||||
* every one of them.
|
||||
*/
|
||||
function useSkipReasonLabel(): (reason: string) => string {
|
||||
const { t } = useTranslations();
|
||||
const labels: Record<string, string> = {
|
||||
duplicate_in_file: t("medications.import.reasonDuplicateInFile"),
|
||||
already_recorded: t("medications.import.reasonAlreadyRecorded"),
|
||||
medication_not_found: t("medications.import.reasonMedicationNotFound"),
|
||||
medication_ambiguous: t("medications.import.reasonMedicationAmbiguous"),
|
||||
medication_is_mirrored: t("medications.import.reasonMedicationIsMirrored"),
|
||||
status_no_dose_information: t(
|
||||
"medications.import.reasonStatusNoDoseInformation",
|
||||
),
|
||||
status_reminder_event: t("medications.import.reasonStatusReminderEvent"),
|
||||
status_notification_not_sent: t(
|
||||
"medications.import.reasonStatusNotificationNotSent",
|
||||
),
|
||||
status_unknown: t("medications.import.reasonStatusUnknown"),
|
||||
missing_timestamp: t("medications.import.reasonMissingTimestamp"),
|
||||
missing_timezone_offset: t("medications.import.reasonMissingOffset"),
|
||||
unreadable_timestamp: t("medications.import.reasonUnreadableTimestamp"),
|
||||
implausible_timestamp: t("medications.import.reasonImplausibleTimestamp"),
|
||||
missing_medication: t("medications.import.reasonMissingMedication"),
|
||||
unreadable_dosage: t("medications.import.reasonUnreadableDosage"),
|
||||
unreadable_row: t("medications.import.reasonUnreadableRow"),
|
||||
};
|
||||
const fallback = t("medications.import.reasonUnknown");
|
||||
return (reason: string) => labels[reason] ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The grouped skip lines on their own, without the outcome sentence above them.
|
||||
*
|
||||
* Split out because a dry-run preview needs exactly these lines and must NOT
|
||||
* borrow the outcome sentence: a preview has written nothing, so classifying it
|
||||
* by the counts it WOULD produce would put a success tick on a run that never
|
||||
* happened. The reason vocabulary is shared; the verdict is not.
|
||||
*/
|
||||
export function IntakeImportSkipGroups({
|
||||
groups,
|
||||
testId = "intake-import-skip-groups",
|
||||
}: {
|
||||
groups: readonly IntakeImportSkipGroup[];
|
||||
testId?: string;
|
||||
}) {
|
||||
const { tCount } = useTranslations();
|
||||
const labelFor = useSkipReasonLabel();
|
||||
if (groups.length === 0) return null;
|
||||
return (
|
||||
<ul
|
||||
data-testid={testId}
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<li key={group.reason}>
|
||||
{tCount("medications.import.skipGroup", group.count, {
|
||||
count: group.count,
|
||||
reason: labelFor(group.reason),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function IntakeImportResultView({
|
||||
result,
|
||||
}: {
|
||||
result: IntakeImportResultState;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (result.kind === "notice") {
|
||||
return (
|
||||
<p
|
||||
data-testid="intake-import-notice"
|
||||
data-tone={result.tone}
|
||||
className={`text-sm ${result.tone === "error" ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{result.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const outcome: ImportOutcome = classifyImportOutcome({
|
||||
written: result.imported,
|
||||
skipped: result.skipped,
|
||||
});
|
||||
|
||||
const message = (() => {
|
||||
if (outcome === "empty") return t("medications.import.resultEmpty");
|
||||
if (outcome === "failed") {
|
||||
return t("medications.import.resultNothing", { skipped: result.skipped });
|
||||
}
|
||||
if (outcome === "partial") {
|
||||
return t("medications.import.resultPartial", {
|
||||
imported: result.imported,
|
||||
skipped: result.skipped,
|
||||
});
|
||||
}
|
||||
return t("medications.import.resultSuccess", { imported: result.imported });
|
||||
})();
|
||||
|
||||
return (
|
||||
<div data-testid="intake-import-result" className="space-y-1.5">
|
||||
<ImportOutcomeLine
|
||||
outcome={outcome}
|
||||
message={message}
|
||||
testId="intake-import-outcome"
|
||||
/>
|
||||
<IntakeImportSkipGroups groups={result.skipReasons} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,15 @@
|
||||
* `POST /api/medications/[id]/inventory` — quantity in units,
|
||||
* optional printed expiry. The quantity prefills from the
|
||||
* medication's `dosesPerUnit` when configured.
|
||||
* - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]` with
|
||||
* `unitsRemaining`) for stock corrections and manual withdrawals —
|
||||
* the count is set absolutely, clamped server-side to the item's
|
||||
* capacity, and the canonical state machine derives the next state
|
||||
* (0 ⇒ used up).
|
||||
* - CORRECT: a per-item adjust flow (`PATCH …/inventory/[itemId]`) for
|
||||
* stock corrections and manual withdrawals — the count is set
|
||||
* absolutely, clamped server-side to the item's capacity, and the
|
||||
* canonical state machine derives the next state (0 ⇒ used up). The
|
||||
* same flow edits the two dates that decide the state: the
|
||||
* carton-printed expiry and the date the container was opened. The
|
||||
* opening date is written by the intake consumption hook without the
|
||||
* user typing it, so it needs a correction path more than any other
|
||||
* field on the row.
|
||||
* - DELETE: a per-item trash affordance behind a destructive confirm
|
||||
* (`DELETE …/inventory/[itemId]`). Consumption stamps on intake
|
||||
* events that referenced the container stay in place; a later
|
||||
@@ -60,7 +64,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { SettingsGroup } from "@/components/medications/settings-group";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { formatUnitCount } from "@/components/medications/units-per-dose";
|
||||
import {
|
||||
@@ -117,6 +122,31 @@ interface InventoryItem {
|
||||
// the UI renders "—" instead of a fabricated 0.
|
||||
unitsTotal: number | null;
|
||||
unitsRemaining: number | null;
|
||||
/** ISO instant. The carton-printed expiry; null when none was recorded. */
|
||||
printedExpiry: string | null;
|
||||
/** ISO instant. When the container was opened; null while sealed. */
|
||||
firstUseAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both dates the row carries are entered — and were always stored — as a
|
||||
* calendar day at the browser's local midnight. Reading them back in the
|
||||
* same zone is what makes the value the user typed the value they see;
|
||||
* pinning to UTC would slide the day for anyone east of Greenwich.
|
||||
*/
|
||||
function toLocalDayInput(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** The inverse: a `yyyy-MM-dd` day back to the instant the API stores. */
|
||||
function fromLocalDayInput(day: string): string | null {
|
||||
if (!day) return null;
|
||||
const at = new Date(`${day}T00:00:00`);
|
||||
return Number.isNaN(at.getTime()) ? null : at.toISOString();
|
||||
}
|
||||
|
||||
interface InventoryResponse {
|
||||
@@ -139,6 +169,15 @@ const STATE_BADGE: Record<
|
||||
USED_UP: "outline",
|
||||
};
|
||||
|
||||
/**
|
||||
* Container kinds whose first opening starts a 30-day degradation clock.
|
||||
* Mirrors `IN_USE_CLOCK_CONTAINER_TYPES` in
|
||||
* `src/lib/medications/inventory/state-machine.ts` — the server owns the
|
||||
* transition; the client only needs to know which helper text to show
|
||||
* beside the opening date.
|
||||
*/
|
||||
const CLOCK_CONTAINER_TYPES: readonly ContainerType[] = ["PEN", "AMPOULE"];
|
||||
|
||||
/** Default container kind for the register flow, from the delivery form. */
|
||||
function defaultContainerType(deliveryForm: string | undefined): ContainerType {
|
||||
if (deliveryForm === "INJECTION") return "PEN";
|
||||
@@ -160,7 +199,8 @@ export function InventorySection({
|
||||
/** Defaults the register flow's container type. */
|
||||
deliveryForm?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const dateFormatPref = useDateFormatPreference();
|
||||
const queryClient = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
|
||||
@@ -244,6 +284,10 @@ export function InventorySection({
|
||||
const remaining = summary?.dosesRemaining ?? 0;
|
||||
const total = summary?.dosesTotal ?? 0;
|
||||
const expiredUnits = summary?.expiredUnits ?? 0;
|
||||
// Nothing available, but stock is sitting in containers that were
|
||||
// declared unusable. That is a different statement from "no supply
|
||||
// recorded" and the headline has to make the difference.
|
||||
const allExpired = totalUnits === 0 && expiredUnits > 0;
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
@@ -302,8 +346,17 @@ export function InventorySection({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("medications.detail.bestand.summary", { remaining, total })}
|
||||
{perDose !== 1 && (
|
||||
{/* "0 of 0 doses" beside "+ 54 units expired" read as two
|
||||
contradicting statements — the first says nothing was ever
|
||||
configured, the second says stock was written off. When the
|
||||
available pool is empty and expired stock is all that is
|
||||
left, say that instead. */}
|
||||
{allExpired
|
||||
? t("medications.detail.bestand.summaryAllExpired", {
|
||||
units: expiredUnits,
|
||||
})
|
||||
: t("medications.detail.bestand.summary", { remaining, total })}
|
||||
{perDose !== 1 && !allExpired && (
|
||||
<span className="text-muted-foreground block text-xs font-normal">
|
||||
{t("medications.detail.bestand.unitsDetail", {
|
||||
remaining: formatUnitCount(remainingUnits),
|
||||
@@ -311,7 +364,7 @@ export function InventorySection({
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{expiredUnits > 0 && (
|
||||
{expiredUnits > 0 && !allExpired && (
|
||||
<span
|
||||
className="text-muted-foreground block text-xs font-normal"
|
||||
data-slot="inventory-expired-suffix"
|
||||
@@ -432,6 +485,40 @@ export function InventorySection({
|
||||
{t(`medications.detail.bestand.state.${item.state}`)}
|
||||
</Badge>
|
||||
</span>
|
||||
{/* The two dates that decide the row's state. They used to
|
||||
be invisible here — a container could read EXPIRED with
|
||||
nothing on screen to explain why, and no way to correct
|
||||
the value that caused it. */}
|
||||
{(item.firstUseAt || item.printedExpiry) && (
|
||||
<span
|
||||
className="text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs"
|
||||
data-slot="inventory-item-dates"
|
||||
>
|
||||
{item.firstUseAt && (
|
||||
<span>
|
||||
{t("medications.detail.bestand.openedOn", {
|
||||
date: formatDate(
|
||||
toLocalDayInput(item.firstUseAt),
|
||||
dateFormatPref,
|
||||
locale,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{item.firstUseAt && item.printedExpiry && <span>·</span>}
|
||||
{item.printedExpiry && (
|
||||
<span>
|
||||
{t("medications.detail.bestand.expiresOn", {
|
||||
date: formatDate(
|
||||
toLocalDayInput(item.printedExpiry),
|
||||
dateFormatPref,
|
||||
locale,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
@@ -714,12 +801,20 @@ export function AddInventoryDialog({
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock correction for one item: sets the remaining-unit count
|
||||
* absolutely (covers both "I miscounted" and a manual withdrawal). The
|
||||
* server clamps to the item's capacity and re-derives the state; 0
|
||||
* marks the item used up.
|
||||
* Correct one container: the remaining-unit count (covers both "I
|
||||
* miscounted" and a manual withdrawal), the carton-printed expiry, and
|
||||
* the date it was opened. The server clamps the count to the item's
|
||||
* capacity and re-derives the state from the two dates; 0 units marks
|
||||
* the item used up.
|
||||
*
|
||||
* Both dates are editable because both decide the state — and the
|
||||
* opening date in particular is written by the intake consumption hook
|
||||
* without the user ever typing it, so an auto-open on a back-dated dose
|
||||
* needs a way back. Each field is sent only when it actually changed,
|
||||
* so saving a unit correction never rewrites a date the user did not
|
||||
* touch.
|
||||
*/
|
||||
function AdjustInventoryDialog({
|
||||
export function AdjustInventoryDialog({
|
||||
medicationId,
|
||||
item,
|
||||
onClose,
|
||||
@@ -735,6 +830,10 @@ function AdjustInventoryDialog({
|
||||
const [value, setValue] = useState(
|
||||
item.unitsRemaining == null ? "" : String(item.unitsRemaining),
|
||||
);
|
||||
const initialExpiry = toLocalDayInput(item.printedExpiry);
|
||||
const initialOpened = toLocalDayInput(item.firstUseAt);
|
||||
const [expiry, setExpiry] = useState(initialExpiry);
|
||||
const [opened, setOpened] = useState(initialOpened);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const formId = useId();
|
||||
|
||||
@@ -755,6 +854,15 @@ function AdjustInventoryDialog({
|
||||
await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, {
|
||||
// The wire field carries UNITS (v1.16.10 symmetric naming).
|
||||
unitsRemaining: parsed,
|
||||
// Absent means untouched on the server, so only changed fields
|
||||
// ride along. A cleared field sends null, which is a deliberate
|
||||
// "there is no such date" — not the same as leaving it alone.
|
||||
...(expiry !== initialExpiry && {
|
||||
printedExpiry: fromLocalDayInput(expiry),
|
||||
}),
|
||||
...(opened !== initialOpened && {
|
||||
markAsFirstUseAt: fromLocalDayInput(opened),
|
||||
}),
|
||||
});
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.adjustSuccess"));
|
||||
@@ -824,6 +932,50 @@ function AdjustInventoryDialog({
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-edit-expiry"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.editExpiryLabel")}
|
||||
</label>
|
||||
<DateField
|
||||
id="inventory-edit-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
aria-describedby="inventory-edit-expiry-helper"
|
||||
data-testid="inventory-edit-expiry"
|
||||
/>
|
||||
<p
|
||||
id="inventory-edit-expiry-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.editExpiryHelper")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-edit-opened"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.editOpenedLabel")}
|
||||
</label>
|
||||
<DateField
|
||||
id="inventory-edit-opened"
|
||||
value={opened}
|
||||
onChange={setOpened}
|
||||
aria-describedby="inventory-edit-opened-helper"
|
||||
data-testid="inventory-edit-opened"
|
||||
/>
|
||||
<p
|
||||
id="inventory-edit-opened-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{CLOCK_CONTAINER_TYPES.includes(item.containerType)
|
||||
? t("medications.detail.bestand.editOpenedHelperClock")
|
||||
: t("medications.detail.bestand.editOpenedHelper")}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
import {
|
||||
AnamnesisCard,
|
||||
buildAnamnesisAboutMeBody,
|
||||
@@ -139,9 +140,11 @@ export function BaselineForm() {
|
||||
const heightCm = heightAdapter.toCanonicalCm(form.height);
|
||||
if (heightCm !== null) profileBody.heightCm = heightCm;
|
||||
if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth;
|
||||
if (form.gender === "MALE" || form.gender === "FEMALE") {
|
||||
profileBody.gender = form.gender;
|
||||
}
|
||||
// Every value the profile stores travels; the guard drops only the
|
||||
// "prefer not to say" placeholder. It used to list two of the three
|
||||
// by hand, so a third-value answer vanished on submit without a word.
|
||||
const gender = toProfileSex(form.gender);
|
||||
if (gender) profileBody.gender = gender;
|
||||
if (Object.keys(profileBody).length > 0) {
|
||||
await apiPut("/api/auth/profile", profileBody);
|
||||
}
|
||||
@@ -241,6 +244,9 @@ export function BaselineForm() {
|
||||
<SelectItem value="FEMALE">
|
||||
{t("onboarding.baseline.genderFemale")}
|
||||
</SelectItem>
|
||||
<SelectItem value="OTHER">
|
||||
{t("onboarding.baseline.genderOther")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Settings → AI "About me": the Clear control cannot take the allergy line.
|
||||
*
|
||||
* The control used to fire `save.mutate({ aboutMe: "", allergies: "" })` from
|
||||
* a bare `onClick`. One tap wiped a person's free-text note AND their
|
||||
* allergies — the entry a clinician reads first, stored in a column that is
|
||||
* overwritten in place, with no tombstone and no restore route. The toast it
|
||||
* raised said "About-me note cleared", so the copy had been describing the
|
||||
* intended behaviour all along; the payload was the thing that was wrong.
|
||||
*
|
||||
* Two properties are pinned here, and the first matters more than the second:
|
||||
*
|
||||
* 1. SCOPE. The request carries `aboutMe` and no `allergies` key at all.
|
||||
* The PUT leaves an omitted field untouched by contract, so the payload
|
||||
* cannot carry the allergy line away even if the dialog were removed
|
||||
* again. A confirmation someone can delete is not a guarantee.
|
||||
* 2. GATE. Nothing goes out while the dialog is merely on screen. The
|
||||
* destructive call reaches the control only as `onConfirm`, so a
|
||||
* dismissed dialog leaves the request unmade — asserted here by
|
||||
* rendering, checking no request was issued, and only then invoking the
|
||||
* captured callback.
|
||||
*
|
||||
* `@testing-library/react` is not a dependency (SSR-only component tests), so
|
||||
* the dialog is not literally clicked. `ConfirmButton` is stubbed to capture
|
||||
* the props the panel hands it; that the real primitive routes `onConfirm`
|
||||
* through the dialog action alone is proved in
|
||||
* `src/components/ui/__tests__/confirm-button.test.tsx`.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
type AboutMePayload = {
|
||||
aboutMe: string | null;
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
coachFocus: string | null;
|
||||
pendingQuestions: string[];
|
||||
updatedAt: string;
|
||||
maxChars: number;
|
||||
fieldMaxChars: number;
|
||||
};
|
||||
|
||||
// Hoisted, because `vi.mock`'s factory is lifted above the imports and may not
|
||||
// close over a normal top-level binding. Typed with the real two-argument
|
||||
// signature so `mock.calls[0]` is a tuple and the payload assertion below
|
||||
// reads the second argument rather than `unknown[]`.
|
||||
const { apiPut } = vi.hoisted(() => ({
|
||||
apiPut: vi.fn<(url: string, payload: unknown) => Promise<AboutMePayload>>(
|
||||
async () => ({
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: "penicillin",
|
||||
coachFocus: null,
|
||||
pendingQuestions: [],
|
||||
updatedAt: "2026-07-26T00:00:00.000Z",
|
||||
maxChars: 4000,
|
||||
fieldMaxChars: 500,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api/api-fetch")>();
|
||||
return { ...actual, apiPut };
|
||||
});
|
||||
|
||||
/** Captured props of every `<ConfirmButton>` the panel rendered. */
|
||||
const confirmButtons: Record<string, unknown>[] = [];
|
||||
|
||||
vi.mock("@/components/ui/confirm-button", () => ({
|
||||
ConfirmButton: (props: Record<string, unknown>) => {
|
||||
confirmButtons.push(props);
|
||||
return (
|
||||
<button data-slot={String(props.slot)}>{String(props.label)}</button>
|
||||
);
|
||||
},
|
||||
ConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
const { AboutMeSection } = await import("../about-me-section");
|
||||
|
||||
const STORED: AboutMePayload = {
|
||||
aboutMe: "Shift work, training for a half marathon.",
|
||||
conditions: null,
|
||||
allergies: "penicillin",
|
||||
coachFocus: null,
|
||||
pendingQuestions: [],
|
||||
updatedAt: "2026-07-26T00:00:00.000Z",
|
||||
maxChars: 4000,
|
||||
fieldMaxChars: 500,
|
||||
};
|
||||
|
||||
function render(seed: Partial<typeof STORED> = {}) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
client.setQueryData(queryKeys.coachAboutMe(), { ...STORED, ...seed });
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AboutMeSection isAuthenticated />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
confirmButtons.length = 0;
|
||||
apiPut.mockClear();
|
||||
});
|
||||
|
||||
describe("the Clear control is scoped to the note", () => {
|
||||
it("routes through the shared confirmation, not a bare button", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="settings-about-me-clear"');
|
||||
expect(confirmButtons).toHaveLength(1);
|
||||
expect(typeof confirmButtons[0].onConfirm).toBe("function");
|
||||
});
|
||||
|
||||
it("hands the destroying call to the dialog, not to the control", () => {
|
||||
render();
|
||||
const props = confirmButtons[0];
|
||||
// The callback arrives ONLY as `onConfirm`. Anything that would let the
|
||||
// trigger act — an `onClick`, an `onSelect` — puts the request one tap
|
||||
// away again, which is the shape this whole change removed.
|
||||
expect(typeof props.onConfirm).toBe("function");
|
||||
for (const key of Object.keys(props)) {
|
||||
if (key === "onConfirm") continue;
|
||||
expect(key, "the trigger must carry no handler of its own").not.toMatch(
|
||||
/^on[A-Z]/,
|
||||
);
|
||||
}
|
||||
// And rendering it, on its own, asks the server for nothing.
|
||||
expect(apiPut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits the allergies field entirely once confirmed", async () => {
|
||||
render();
|
||||
(confirmButtons[0].onConfirm as () => void)();
|
||||
await vi.waitFor(() => expect(apiPut).toHaveBeenCalledTimes(1));
|
||||
|
||||
const [url, raw] = apiPut.mock.calls[0]!;
|
||||
const payload = raw as Record<string, unknown>;
|
||||
expect(url).toBe("/api/coach/about-me");
|
||||
expect(payload.aboutMe).toBe("");
|
||||
// Not "sent as the stored value" — absent. The route leaves an omitted
|
||||
// field alone, so absence is what protects the allergy line.
|
||||
expect(Object.keys(payload)).not.toContain("allergies");
|
||||
expect(payload.allergies).toBeUndefined();
|
||||
});
|
||||
|
||||
it("says in its dialog that allergies are not touched", () => {
|
||||
render();
|
||||
expect(confirmButtons[0].body).toBe(
|
||||
"This removes the free-text note only. Your allergies and intolerances stay exactly as they are.",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the control when there is no note, even with allergies stored", () => {
|
||||
const html = render({ aboutMe: null });
|
||||
expect(html).not.toContain('data-slot="settings-about-me-clear"');
|
||||
expect(confirmButtons).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearing allergies stays possible, in the field itself", () => {
|
||||
it("renders the allergy textarea, editable, carrying the stored value", () => {
|
||||
// Removing the field from the Clear payload only removes the untargeted
|
||||
// route. The targeted one has to still be there, or scoping the control
|
||||
// would have taken a capability away rather than a footgun.
|
||||
const html = render();
|
||||
const field = html.slice(
|
||||
html.indexOf('data-testid="settings-about-me-allergies"'),
|
||||
);
|
||||
const openTag = field.slice(0, field.indexOf(">"));
|
||||
expect(openTag).not.toContain("disabled");
|
||||
expect(html).toContain("penicillin");
|
||||
});
|
||||
});
|
||||
@@ -122,11 +122,13 @@ describe("Settings — CoachMemorySection", () => {
|
||||
|
||||
it("renders a per-fact forget control and the bulk forget-all action", () => {
|
||||
const html = render(buildFacts());
|
||||
expect(html).toContain('data-testid="settings-coach-memory-forget"');
|
||||
// The per-row control routes through `ConfirmButton`, which identifies
|
||||
// itself with `data-slot`; the bulk one is still a local dialog.
|
||||
expect(html).toContain('data-slot="settings-coach-memory-forget"');
|
||||
expect(html).toContain('data-testid="settings-coach-memory-forget-all"');
|
||||
// One forget button per fact (3 seeded).
|
||||
const forgetButtons = html.match(
|
||||
/data-testid="settings-coach-memory-forget"/g,
|
||||
/data-slot="settings-coach-memory-forget"/g,
|
||||
);
|
||||
expect(forgetButtons).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -3,65 +3,189 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Destructive settings controls ask first.
|
||||
* Destructive controls ask first.
|
||||
*
|
||||
* Signing every other device out, and revoking a trusted device, used to fire
|
||||
* on a single tap. Neither is undoable — the other devices simply have to log
|
||||
* in again, and a revoked trusted device asks for a second factor next time —
|
||||
* while every other destructive surface in the app confirms. On a shared
|
||||
* while every other destructive surface in the app confirmed. On a shared
|
||||
* screen a mis-tap is a support call.
|
||||
*
|
||||
* This reads the sources rather than rendering, because what it pins is
|
||||
* structural: that these particular controls route through `ConfirmButton`
|
||||
* and never call a revoke mutation straight from an `onClick`. A render test
|
||||
* would prove the dialog exists in one arrangement; this proves no control on
|
||||
* these cards can go back to firing directly.
|
||||
* The sweep that followed found fourteen more of the same shape, and this file
|
||||
* grew to hold them. It reads the sources rather than rendering, because what
|
||||
* it pins is structural: that these particular controls route through
|
||||
* `ConfirmButton` / `ConfirmDialog` and never call a destroying mutation
|
||||
* straight from an `onClick`. A render test would prove a dialog exists in one
|
||||
* arrangement; this proves no control on these surfaces can go back to firing
|
||||
* directly.
|
||||
*
|
||||
* It is one leg of three, and none of them stands alone:
|
||||
*
|
||||
* - here: each named control routes through the primitive;
|
||||
* - `src/components/ui/__tests__/confirm-button.test.tsx`: the primitive
|
||||
* hands the caller's callback to the dialog action and nowhere else, so a
|
||||
* dismissal cannot act;
|
||||
* - `src/__tests__/destructive-control-guard.test.ts`: no NEW destructive
|
||||
* control can arrive without being written down.
|
||||
*
|
||||
* Together those give "each guarded control refuses to act on a dismissed
|
||||
* dialog" without a click-through the SSR-only test setup cannot perform.
|
||||
*/
|
||||
|
||||
const CARDS = [
|
||||
function source(rel: string) {
|
||||
return readFileSync(join(process.cwd(), "src", rel), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every control that must open a confirmation before it destroys anything,
|
||||
* keyed by the `slot` it hands the primitive. A slot that disappears from its
|
||||
* file means the control was renamed, moved, or unwired — all three are worth
|
||||
* a red test.
|
||||
*/
|
||||
const CONTROLS: { file: string; slots: string[] }[] = [
|
||||
{
|
||||
file: "security-sessions-card.tsx",
|
||||
file: "components/settings/security-sessions-card.tsx",
|
||||
slots: ["revoke-session", "sign-out-everywhere"],
|
||||
},
|
||||
{
|
||||
file: "trusted-devices-card.tsx",
|
||||
file: "components/settings/trusted-devices-card.tsx",
|
||||
slots: ["revoke-trusted-device", "revoke-all-trusted-devices"],
|
||||
},
|
||||
// The allergy wipe. Scoped to the note as well as gated — see
|
||||
// `about-me-clear-scope.test.tsx` for the payload half.
|
||||
{
|
||||
file: "components/settings/about-me-section.tsx",
|
||||
slots: ["settings-about-me-clear"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/ai/codex-provider-form.tsx",
|
||||
slots: ["settings-codex-disconnect"],
|
||||
},
|
||||
{
|
||||
file: "components/admin/central-codex-section.tsx",
|
||||
slots: ["admin-central-codex-disconnect"],
|
||||
},
|
||||
{
|
||||
file: "components/admin/ai-server-key-section.tsx",
|
||||
slots: ["admin-ai-server-key-remove"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/account-section/avatar-section.tsx",
|
||||
slots: ["settings-avatar-remove"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/coach-memory-section.tsx",
|
||||
slots: ["settings-coach-memory-forget"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/sources-section.tsx",
|
||||
slots: ["settings-sources-reset"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/dashboard-layout-section.tsx",
|
||||
slots: ["settings-dashboard-layout-reset"],
|
||||
},
|
||||
{
|
||||
file: "components/insights/insights-edit-mode.tsx",
|
||||
slots: ["insights-edit-reset"],
|
||||
},
|
||||
{
|
||||
file: "components/targets/target-edit-sheet.tsx",
|
||||
slots: ["target-edit-reset"],
|
||||
},
|
||||
{ file: "app/coach/plans/page.tsx", slots: ["coach-plan-delete"] },
|
||||
{
|
||||
file: "components/cycle/log-day-sheet.tsx",
|
||||
slots: ["cycle-day-delete", "cycle-custom-symptom-remove"],
|
||||
},
|
||||
// Two controls, one interpolated slot each: the per-metric reset and the
|
||||
// override switch, which delete the same stored range.
|
||||
{
|
||||
file: "components/settings/thresholds-editor-section.tsx",
|
||||
slots: [
|
||||
"settings-thresholds-reset-all",
|
||||
"settings-thresholds-reset-${metric}",
|
||||
"settings-thresholds-override-off-${metric}",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function source(file: string) {
|
||||
return readFileSync(
|
||||
join(process.cwd(), "src", "components", "settings", file),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
describe("destructive controls confirm before firing", () => {
|
||||
for (const control of CONTROLS) {
|
||||
describe(control.file, () => {
|
||||
const src = source(control.file);
|
||||
|
||||
describe("destructive session controls confirm before firing", () => {
|
||||
for (const card of CARDS) {
|
||||
describe(card.file, () => {
|
||||
const src = source(card.file);
|
||||
|
||||
it("routes every destructive control through ConfirmButton", () => {
|
||||
expect(src).toContain("ConfirmButton");
|
||||
for (const slot of card.slots) {
|
||||
expect(src).toContain(`slot="${slot}"`);
|
||||
it("routes every named control through the shared confirmation", () => {
|
||||
expect(src).toMatch(/<Confirm(Button|Dialog)\b/);
|
||||
for (const slot of control.slots) {
|
||||
// A slot naming a metric is written as a template literal at the
|
||||
// call site; a fixed one is a plain string attribute.
|
||||
const written = slot.includes("${")
|
||||
? `slot={\`${slot}\`}`
|
||||
: `slot="${slot}"`;
|
||||
expect(src, `${control.file} lost slot ${slot}`).toContain(written);
|
||||
}
|
||||
});
|
||||
|
||||
it("never fires a revoke straight from an onClick", () => {
|
||||
// `onConfirm={() => revoke…}` is the confirmed path and is fine;
|
||||
// `onClick={() => revoke…}` is the unguarded one this forbids.
|
||||
const unguarded = src.match(/onClick=\{\(\) =>\s*revoke/g) ?? [];
|
||||
expect(unguarded).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives each control its own confirmation body", () => {
|
||||
// A shared generic body would let one control's dialog describe
|
||||
// another's consequence — the reason the copy is per-control.
|
||||
const bodies = src.match(/body=\{t\("([^"]+)"/g) ?? [];
|
||||
expect(bodies.length).toBe(card.slots.length);
|
||||
expect(new Set(bodies).size).toBe(card.slots.length);
|
||||
it("names the consequence in surface-specific copy", () => {
|
||||
// A dialog that says "Are you sure?" makes the person guess what they
|
||||
// are agreeing to, so every body is a key belonging to this surface.
|
||||
// Two controls MAY share one — the per-metric threshold reset and the
|
||||
// override switch do, because they perform the identical delete — so
|
||||
// what is pinned is the provenance of the copy, not its cardinality.
|
||||
const bodies = [...src.matchAll(/\bbody=\{([\s\S]{0,200}?)\}\n/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
expect(
|
||||
bodies.length,
|
||||
`${control.file}: no confirmation body found`,
|
||||
).toBeGreaterThan(0);
|
||||
const keys = bodies.flatMap((b) =>
|
||||
[...b.matchAll(/t\("([^"]+)"/g)].map((m) => m[1]),
|
||||
);
|
||||
expect(keys.length).toBeGreaterThanOrEqual(bodies.length);
|
||||
for (const key of keys) {
|
||||
expect(key, `${control.file}: generic dialog copy`).not.toMatch(
|
||||
/^common\./,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("the threshold override switch deletes behind the same dialog", () => {
|
||||
/**
|
||||
* The switch labelled "Custom range" performs the same irreversible delete
|
||||
* as the Reset button beside it — flipping it off drops the stored range —
|
||||
* from a control that reads as a display toggle. It was the one destructive
|
||||
* path in this sweep that no earlier pass named, precisely because it is not
|
||||
* shaped like a delete. `ConfirmButton` cannot wrap a switch, which is why
|
||||
* `ConfirmDialog` exists.
|
||||
*/
|
||||
const src = source("components/settings/thresholds-editor-section.tsx");
|
||||
const handler = src.slice(
|
||||
src.indexOf("onCheckedChange={(next)"),
|
||||
src.indexOf("disabled={busy}", src.indexOf("onCheckedChange={(next)")),
|
||||
);
|
||||
|
||||
it("does not reset straight from the switch handler", () => {
|
||||
expect(handler).toContain("setConfirmSwitchOff(true)");
|
||||
expect(
|
||||
handler,
|
||||
"flipping the switch must open the dialog, not perform the delete",
|
||||
).not.toMatch(/onReset\(\)/);
|
||||
});
|
||||
|
||||
it("leaves the switch on when the dialog is dismissed", () => {
|
||||
// `setOverrideMode(false)` lives inside `onConfirm`, not in the handler,
|
||||
// so a cancelled dialog leaves both the switch and the stored range alone.
|
||||
const confirmArm = src.slice(
|
||||
src.indexOf("<ConfirmDialog"),
|
||||
src.indexOf("/>", src.indexOf("<ConfirmDialog")),
|
||||
);
|
||||
expect(confirmArm).toContain("setOverrideMode(false)");
|
||||
expect(confirmArm).toContain("onReset()");
|
||||
expect(handler).not.toContain("setOverrideMode(false)");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user