Merge pull request #1035 from MBombeck/release/v1.39.1

Release v1.39.1
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-25 14:32:36 +02:00
commit 39b8dd708c
344 files changed
+24574 -4211

No files matched your search

+216
View File
@@ -1,5 +1,221 @@
# Changelog
## [1.39.1] — 2026-09-25
Medication history stops counting misses from before a medication existed,
and large accounts can run their nightly jobs, export and restore again. A
restore now runs in the background with progress. New: a procedure
history, medications kept as a record without tracking intake, and
readings from home-automation bridges marked as coming from an external
device.
### Added
- **Keep a medication as a record.** Every medication has a new Track
intake switch at the top of its Schedule tab and on the last page of the
medication form. With it off, the medication keeps its dose, start and
end dates and schedule, and you can still read and change them, but
nothing is due: no reminder on any channel, no dose on the doses card or
in the today list, no Taken and Skip buttons. It is left out of
adherence on the medication card, the dashboard, the doctor report and
in the Coach, and the list marks it "Record only". Doses you logged
before stay in its history. Switched back on, it is tracked from that
moment and the days it was off never count as missed. Every medication
starts with tracking on. Thanks to @Cnote43 for #1033.
- **A history of procedures and surgeries.** A visit can be of the new
kind "Procedure or surgery", with a body site in your own words and an
optional side (left, right or both). The body site is stored encrypted.
Checkups has a third tab, Procedures, listing every procedure newest
first, with a search by body site that also looks at the reason, so an
operation recorded earlier as a general visit still turns up. An
existing visit can be switched to the new kind without typing anything
again. The doctor report has a new Surgical history section covering
your whole history; the standard selection includes it, while a
selection you saved earlier keeps what you chose. A share link shows it
when the link includes it. Thanks to @Cnote43 for #1025.
- **Sleep stages can be sent by name.** A bridge such as Tasker or Home
Assistant can post one stage to `POST /api/measurements` with
`type: "SLEEP_DURATION"`, a `sleepStage` of `IN_BED`, `ASLEEP`,
`AWAKE`, `CORE`, `DEEP` or `REM`, and either `startDate` and `endDate`
or `measuredAt` (the end) and `value` (minutes). It is stored and
deduplicated like the stages the iPhone app sends, and a repeat answers
`200` with `status: duplicate`. Sent with the ingest token, the stage is
stored with the source `EXTERNAL`. Thanks to @ocurero for #972.
- **Long background jobs show up in the app log.** A job that runs for
more than a minute writes a line naming its queue when it finishes, and
one every two minutes while it runs, with how far it has got and how
much memory the app uses. A job past its time limit writes a warning,
and after a restart the log names every job the restart cut off.
- **Out-of-range upload entries say which limit applied.** An entry in
`/api/measurements/batch` skipped as `value_out_of_range` now carries
`convertedValue` and `range` (`min`, `max`, `unit`), so a value sent as
a fraction where a percentage was expected is easy to spot.
- **A rejected measurement upload names its reason.** The validation
`422` from `/api/measurements/batch` carries
`meta.errorCode: "measurement.batch.invalid"`, so a client can tell the
batch will never be accepted as sent.
- **Units per dose like 1½.** Units per dose used to take a whole number
or one of ¼, ⅓, ½, ⅔ and ¾, so one and a half tablets could not be
saved. Any value above 0 and up to 100 with up to four decimal places
now works, for the medication, for a single dose time and for the
packaging settings on the supply tab, which before could not be saved
for a medication taken as half a tablet. The form keeps its buttons and
adds Other, which reads 1.5, 1,5, 1 1/2 or 1½ and shows what will be
saved. The card shows 1½. Thanks to @gesus14 for asking in #1034.
### Changed
- **Readings sent with the ingest token are marked as coming from an
external device.** They used to be stored as manually entered, so later
nobody could tell a reading from the scale from one typed in by hand.
They now carry the source `EXTERNAL`, show an "External device" badge
and can be picked out with the source filter in the measurements list.
You can still correct them. A request that names any `source` at all is
refused with `422`; the server decides the source from the token, and
the Home Assistant example sends none. Readings pushed before this
update stay manual, because nothing tells them apart. On a day with both
a typed and a bridged reading, the daily totals, charts, health score
and doctor report count the typed one; before, both counted and could
inflate a running total. A re-push of entries sent before the update can
create duplicates once. Thanks to @Antiheld86 for #892.
- **A restore runs in the background.** Restoring a large account takes a
few minutes, and a reverse proxy that closes a request after 60 seconds
(nginx does by default) showed an error while the restore finished on
the server. After you confirm a restore, the backups page shows the
current step and how many readings have been written, and the progress
survives leaving or reloading the page. The account shows its current
data until the restored data is written in one go. Only one restore per
account runs at a time. A failure says why and what to do next, and the
backup stays on the list so you can start again without uploading the
file. A restore interrupted by a server restart before its data is
written starts again once by itself; a second interruption is left for
you to restart. A failure after the data is written, while charts are
rebuilt, is never run again: the data is restored and the charts catch
up by the next night.
- **A job that runs out of time says more on the admin page.** It names
how long the job had and that it was either too slow for that limit or
the app restarted while it ran, instead of only "job timed out".
- **Backups of several hundred megabytes can be uploaded.** The admin page
compresses the file before sending it, `.json.gz` files are accepted as
they are, and the limit is 512 MB. The off-host restore script can write
a compressed file for this.
### Fixed
- **All-time averages and counts no longer count some days twice.** Since
1.37.19 the all-time figures combined the stored daily summaries with the
raw readings older than five years, and a day that had both was counted
in each. Long histories were affected most, and one more day every day,
as days aged past the five-year mark. The all-time mean and count on a
metric's insights page, the device and resilience tiles, the glucose
reading count and the dashboard summaries now count every reading once,
so some of these figures change after the update. Nothing needs to be
repaired in the stored data.
- **Medication history before a medication was added.** If you added a
medication at 4 pm and then logged the 2 pm dose, "Last 90 days" showed
it as off-schedule and due at the evening slot, and listed a morning slot
from before the medication existed. The dose now counts for its own slot,
and slots from before you added the medication appear only if you logged
something for them. The adherence rate, the calendar, the doctor report
and the Coach follow the same rule; the doctor report and the Coach
used to count those earlier slots as missed. The morning slot no longer
turns into a forgotten dose a day later and breaks your miss-free
streak, and migration `0350` puts back the ones already marked that way.
Thanks to @sreeramachandramurthy for the report and the screenshots
(#1028).
- **A portable restore keeps when a medication was created.** It used to
date every medication to the day of the restore, and missed doses in the
restored history dropped out of the adherence rate. Backups exported
from 1.39.1 on carry the date. For older ones the restore uses the
earliest date the backup records for that medication, such as its first
dose or the start of its first schedule, and never a date after the
restore.
- **The adherence calendar no longer counts a skipped or missed entry
that belongs to no scheduled time as a dose taken.**
- **A dose attributed to a slot by hand reads the same as one logged
there.** A dose taken shortly before the slot's window counted as on
time on its own but as late once attributed. The confirmation now says
what attributing does.
- **Nightly jobs finish on large accounts.** On an account with more than
a million readings, for example after a multi-year Apple Health import,
the nightly clean-up of heart-rate, HRV, blood-oxygen, step and energy
samples and the backup could fail with "job timed out": the clean-up
loaded a type's whole history into memory and the backup built its whole
copy in memory, and the app ran out of memory and restarted. The
clean-up now works one day at a time with about a third of the memory,
and when it runs short of time it stops and continues where it left off.
The backup saves its copy piece by piece and keeps the previous one if a
run fails. On an account of 1.25 million readings in the standard 1 GB
container both finish in a few minutes while the app keeps answering.
Thanks to @mills1975 for the report (#1031).
- **Large accounts can export and restore.** Downloading the full export
of such an account, with or without a passphrase, ran the app out of
memory, and its weekly backup could not be restored, opened or
downloaded; the admin page wrongly said it "could not be decrypted".
Exports now stream out as they are written and backups are read in small
parts. On the same account the export takes about 25 seconds and a
restore about two and a half minutes, and the restored readings match
the backup.
- **An Apple Health export import needs much less memory.** An import of
300,000 readings held 12 MB instead of 53 MB and ran in 35 seconds
instead of 55. After an import, chart summaries are rebuilt only for the
period the import covered.
- **Smaller memory fixes for large accounts.** The half-hourly check for
personal records no longer loads a type's whole history. Rotating the
encryption key no longer reads every measurement at once, and its report
counts only rows that hold encrypted data. The one-time repair of step,
energy and distance records can no longer lose them when interrupted.
A retried off-host backup continues with the accounts it has not reached
yet.
- **The cycle calendar shows intercourse and other logged entries.** A day
with intercourse carries a small diamond, explained in the legend. A
pregnancy, progesterone or ovulation result, contraception, spotting, a
temperature, a mucus or cervix reading or a note shows as a small ring,
so such a day no longer looks empty. Screen readers name each entry.
Someone who sees your cycle through a shared account sees these marks
only if they could already open the day. Thanks to @Nazza01 for the
report and the screenshot (#1032).
- **Editing a vaccination dose no longer removes its documents.** Ever
since documents could be linked to doses (1.37.3), a dose opened for
editing did not know its linked documents: it showed "Link a document"
with nothing under it, and saving any change, such as a batch number,
quietly unlinked every document. That was our bug. The dose now shows
its linked pages and saving leaves them in place.
- **Document links are easier to tell apart and harder to remove by
accident.** On a document, each linked dose or visit shows its date, so
three COVID-19 doses no longer read as three identical chips. Clicking a
chip opens that dose, visit, condition or document; only the small x
removes a link, and you can undo right after. Thanks to @Cnote43 for
writing up every step (#1024).
- **`/api/user/profile` reports the time zone the server uses.** When the
stored zone is unusable it shows the instance default, as `/api/auth/me`
already did.
- **`upsert_failed` in the nutrient and cycle uploads means "send it
again".** The API description said to drop such an entry. A retry with
the same `Idempotency-Key` now writes again instead of getting the
earlier failure back.
### Upgrade notes
- Take a database backup before upgrading.
- Migrations: `0346` adds the `EXTERNAL` measurement source, `0347` adds
the table that tracks restore jobs, `0349` adds body site and side to
visits, `0350` returns misses wrongly stamped on slots before a
medication's creation to pending (nothing is deleted), and `0351` adds
the Track intake switch, on for every medication.
- `POST /api/admin/backups/{id}/restore` now answers `202` with a `jobId`
and a `statusUrl`. `GET /api/admin/backups/restores/{jobId}` returns the
job's status and result, `GET /api/admin/backups/restores` lists recent
restores, and a second restore of the same account answers `409` with
`meta.errorCode: backup.restore.active`.
- Everything else in the API is additive, and iPhone app 1.0.3 and 1.0.4
keep working. For a record-only medication the shipped app stops
reminding, but its card still shows Taken and Skip, and a schedule
change made on the phone is not applied. Change that schedule on the
web for now.
## [1.39.0] — 2026-09-25
AI is optional everywhere now: your data loads whatever the AI settings
+895 -127
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -60,8 +60,10 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
Read three things off the output before going further:
- the per-column line, `scanned` / `rotated` / `errors` / `dropped`. Treat
`errors > 0` as a hard failure and re-run after fixing the cause. A
- the per-column line, `scanned` / `rotated` / `errors` / `dropped`.
`scanned` counts the rows that hold ciphertext in that column, not every
row of the table. Treat `errors > 0` as a hard failure and re-run after
fixing the cause. A
`dropped` count is only ever a cache row the run could not read and
deleted rather than leave unreadable.
- `Columns walked: N/M registered`. The two numbers must match.
+33 -2
View File
@@ -532,7 +532,38 @@ test.describe("Backup and restore, through the settings surfaces", () => {
await openBackupsConsole(page);
const response = await restoreThroughDialog(page, requireSnapshot());
expect(response.status()).toBe(200);
// The restore runs on the `backup-restore` queue: the request answers 202
// with the job and its status route, and the worker inside the server
// under test does the work. Nothing below is read until the job says it
// is done, so every assertion is about the finished restore rather than
// a moment in the middle of it.
expect(response.status()).toBe(202);
const queued = (await response.json()) as {
data: { jobId: string; status: string; statusUrl: string };
};
expect(queued.data.status).toBe("queued");
expect(queued.data.statusUrl).toBe(
`/api/admin/backups/restores/${queued.data.jobId}`,
);
await expect
.poll(
async () => {
const res = await page.request.get(queued.data.statusUrl);
const body = (await res.json()) as {
data: {
status: string;
failure: { code: string } | null;
} | null;
};
// A failed job ends the wait with its code in the message instead
// of running the clock out on a state that will never change.
return body.data?.status === "failed"
? `failed: ${body.data.failure?.code ?? "unknown"}`
: body.data?.status;
},
{ timeout: 60_000, intervals: [250, 500, 1_000] },
)
.toBe("succeeded");
const afterRestore = await storedMeasurements();
@@ -564,7 +595,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
// And the reading that arrived after the snapshot is gone.
// `docs/ops/backup-restore.md` says the restore "replaces the account's
// data tables", and the route's own words are "one transaction replaces
// data tables", and `restore-backup.ts` puts it as "one transaction replaces
// every serialized owner-scoped class". Replacing is not merging: whoever
// restores yesterday's copy loses today's readings, and that is the
// documented behaviour rather than an accident of the implementation.
+99 -11
View File
@@ -293,6 +293,11 @@
"visitsColReason": "Anlass",
"visitsColOutcome": "Ergebnis",
"visitsColConditions": "Erkrankungen",
"surgicalHistoryTitle": "Operationen und Eingriffe",
"surgicalHistoryColDate": "Datum",
"surgicalHistoryColProcedure": "Eingriff",
"surgicalHistoryColBodySite": "Körperstelle",
"surgicalHistoryColOutcome": "Ergebnis",
"immunizationsTitle": "Impfungen",
"immunizationsColDate": "Datum",
"immunizationsColVaccine": "Impfstoff",
@@ -1201,6 +1206,16 @@
"yesterday": "Gestern",
"tomorrow": "Morgen",
"asNeededMarker": "Bei Bedarf",
"recordOnlyBadge": "Nur dokumentiert",
"trackIntake": {
"title": "Einnahmen erfassen",
"label": "Einnahmen erfassen",
"helperOn": "Dosen werden nach diesem Plan fällig, mit Erinnerungen und Therapietreue.",
"helperOff": "Nur dokumentiert: nichts wird fällig, keine Erinnerungen, keine Therapietreue.",
"enabledToast": "Einnahmen werden erfasst",
"disabledToast": "Einnahmen werden nicht erfasst",
"toggleFailed": "Einnahme-Erfassung konnte nicht geändert werden."
},
"withoutNotification": "Ohne Benachrichtigung",
"pausedSince": "Pausiert seit",
"takeNow": "Jetzt einnehmen",
@@ -1796,7 +1811,7 @@
"unpinnedToast": "Zuordnung gelöst",
"dueContext": "fällig gewesen: {time} ({delta})",
"confirmTitle": "Slot {time} zuordnen?",
"confirmBody": "Die Einnahme zählt für diesen Slot als verspätet — die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.",
"confirmBody": "Die Einnahme zählt für diesen Slot genau so, als wäre sie dort erfasst worden: pünktlich oder verspätet, je nachdem, wann sie genommen wurde. Die Quote wird also nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.",
"confirmCancel": "Abbrechen",
"confirmAction": "Zuordnen"
}
@@ -1927,6 +1942,7 @@
"nextDoseLabel": "Nächste Dosis",
"nextDoseNone": "Keine anstehende Dosis.",
"nextDoseAsNeeded": "Bei Bedarf — keine geplante Dosis.",
"nextDoseNotTracked": "Nicht erfasst, nur dokumentiert.",
"pausedHint": "Pausiert — keine Erinnerungen.",
"reminderLabel": "Erinnerung",
"reminderOn": "Aktiv",
@@ -2000,7 +2016,7 @@
"packagingEdit": "Bearbeiten",
"packagingDescription": "Lege fest, wie viele Einheiten eine Dosis verbraucht und wie groß eine neue Packung üblicherweise ist — z. B. nach einem Herstellerwechsel mit anderer Blistergröße.",
"packagingUnitsPerDoseLabel": "Einheiten pro Dosis",
"packagingUnitsPerDoseHelper": "Z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis (1–100). Bereits erfasste Einnahmen bleiben unverändert.",
"packagingUnitsPerDoseHelper": "Z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis oder 1,5 für anderthalb (über 0, bis 100). Bereits erfasste Einnahmen bleiben unverändert.",
"packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)",
"packagingDefaultPackHelper": "Vorbefüllung beim Erfassen neuer Packungen (1–1000). Leer lassen, wenn keine üblich ist.",
"packagingSuccess": "Packungsangaben gespeichert",
@@ -2198,6 +2214,13 @@
"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"
},
"unitsPerDoseField": {
"other": "Andere…",
"inputLabel": "Einheiten pro Dosis, anderer Wert",
"placeholder": "z. B. 1,5 oder 1 1/2",
"invalid": "Gib eine Zahl über 0 und bis 100 ein, zum Beispiel 1,5 oder 1 1/2.",
"preview": "Zählt als {value} pro Dosis"
}
},
"charts": {
@@ -4760,7 +4783,9 @@
"done": "{name} ist angelegt.",
"doneUnnamed": "Dein erstes Medikament ist angelegt.",
"nextDose": "Nächste Dosis: {when}.",
"reminders": "Erinnerungen folgen dem Plan, den du festgelegt hast."
"reminders": "Erinnerungen folgen dem Plan, den du festgelegt hast.",
"recordOnly": "Nur dokumentiert: keine Erinnerungen, nichts wird fällig.",
"asNeeded": "Bei Bedarf: keine Erinnerungen, erfasse eine Dosis, wenn du sie nimmst."
},
"log-reading": {
"title": "Einen Wert eintragen: {target}",
@@ -7169,7 +7194,7 @@
"uploadTitle": "Backup hochladen",
"uploadDescription": "Eine Backup-Datei validieren und als manuellen Snapshot speichern. Das Wiederherstellen ist ein separater Schritt.",
"uploadButton": "Datei hochladen",
"uploadHelp": "JSON-Datei passend zum aktuellen Backup-Schema. Max. 10 MB.",
"uploadHelp": "HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
"uploadSuccess": "Backup hochgeladen ({count} Datensätze)",
"uploadFailed": "Upload fehlgeschlagen",
"restore": "Wiederherstellen",
@@ -7244,7 +7269,39 @@
"unknownDetail": "Noch kein nächtlicher Lauf hat dieses Konto erfasst.",
"age": "vor {hours} h",
"scheduleNote": "Der Upload läuft alle {hours} h. Frisch heißt innerhalb eines Laufs, fällig heißt ein Lauf blieb ohne Ergebnis, veraltet heißt zwei."
}
},
"restoreStarted": "Wiederherstellung gestartet. Der Fortschritt steht über der Liste.",
"restoreActive": "Für dieses Konto läuft schon eine Wiederherstellung. Warte, bis sie fertig ist.",
"restoreJobQueuedTitle": "Wiederherstellung von {username} wartet auf den Start",
"restoreJobQueuedNote": "Sie beginnt, sobald der Hintergrunddienst sie übernimmt.",
"restoreJobRunningTitle": "{username} wird wiederhergestellt",
"restoreJobRunningNote": "Das Konto zeigt seine bisherigen Daten, bis die Wiederherstellung fertig ist. Du kannst die Seite verlassen; die Wiederherstellung läuft auf dem Server weiter.",
"restoreJobResumed": "Nach einem Neustart des Servers erneut gestartet.",
"restoreJobPhaseValidating": "Backup-Datei wird geprüft: {checked} Messwerte geprüft",
"restoreJobPhaseClearing": "Bisherige Daten des Kontos werden entfernt",
"restoreJobPhaseMeasurements": "Messwerte werden geschrieben: {done} von {total}",
"restoreJobPhaseSections": "Übrige Einträge werden geschrieben: {done} von {total} Bereichen",
"restoreJobPhaseRebuilding": "Diagramme und Zusammenfassungen werden neu berechnet",
"restoreJobSucceededTitle": "Wiederherstellung von {username} abgeschlossen",
"restoreJobSucceededDescription": "{count} Datensätze wiederhergestellt. Fertig {when}.",
"restoreJobFailedTitle": "Wiederherstellung von {username} nicht abgeschlossen",
"restoreJobFailedKept": "Es wurde nichts geändert. Das Backup bleibt erhalten, du kannst die Wiederherstellung also über seine Zeile erneut starten, ohne es noch einmal hochzuladen.",
"restoreFailureNotFound": "Das Backup wurde gelöscht, bevor die Wiederherstellung es lesen konnte.",
"restoreFailureChanged": "Das Backup wurde durch eine neuere Kopie ersetzt, während die Wiederherstellung wartete. Prüfe die neue Kopie und starte die Wiederherstellung dann erneut.",
"restoreFailureUndecryptable": "Das Backup ließ sich nicht entschlüsseln. Der Schlüssel, mit dem es geschrieben wurde, ist nicht mehr eingerichtet, oder die gespeicherte Kopie wurde verändert.",
"restoreFailureSchema": "Die Backup-Datei ist kein gültiges HealthLog-Backup.",
"restoreFailureVersion": "Das Backup stammt von einer neueren HealthLog-Version. Aktualisiere zuerst diesen Server.",
"restoreFailureOwnerMismatch": "Die Datei im Backup gehört zu einem anderen Konto als die Backup-Zeile.",
"restoreFailureOwnerMissing": "Das Konto, zu dem dieses Backup gehört, gibt es nicht mehr.",
"restoreFailureSectionMissing": "Die Datei ist unvollständig: Ihr Manifest nennt einen Bereich, den sie nicht enthält.",
"restoreFailureDocument": "Die Datei enthält Dokumente ohne deren Inhalt, wie ein portabler Export. Nimm eine Notfall-Sicherung.",
"restoreFailureTimeBudget": "Dieses Backup ist zu groß, um innerhalb des Zeitlimits des Hintergrundauftrags wiederhergestellt zu werden.",
"restoreFailureTransaction": "Die Datenbank hat die Wiederherstellung mittendrin abgelehnt, deshalb wurde alles zurückgerollt. Das Server-Log nennt den Fehler.",
"restoreFailureInterrupted": "Die Wiederherstellung wurde durch einen Neustart des Servers unterbrochen und zurückgerollt. Bricht sie erneut ab, reicht dem Server wahrscheinlich der Arbeitsspeicher nicht.",
"restoreFailureNotStarted": "Der Hintergrunddienst hat die Wiederherstellung nicht übernommen. Prüfe, ob er läuft.",
"restoreFailureEnqueue": "Die Wiederherstellung konnte nicht an den Hintergrunddienst übergeben werden. Prüfe, ob er läuft.",
"restoreFailureAfterCommit": "Die Daten wurden wiederhergestellt, aber ein Schritt danach ist fehlgeschlagen. Diagramme holen das womöglich erst beim nächsten nächtlichen Lauf nach.",
"restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler."
},
"danger-zone": {
"title": "Gefahrenzone",
@@ -8233,7 +8290,9 @@
"flowSpotting": "Schmierblutung",
"flowLight": "leicht",
"flowMedium": "mittel",
"flowHeavy": "stark"
"flowHeavy": "stark",
"legendIntercourse": "Geschlechtsverkehr",
"legendOtherEntries": "Weitere Einträge"
},
"predictions": {
"title": "Was als Nächstes kommt",
@@ -10393,6 +10452,7 @@
"scopeCount": "{count} Einträge aus {groups} Bereichen",
"scopeSensitive": "inkl. {names}",
"leafVisits": "Arztbesuche",
"leafSurgicalHistory": "Operationen und Eingriffe",
"leafImmunizations": "Impfungen",
"leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)"
},
@@ -10777,8 +10837,16 @@
"emergency": "Notfall",
"hospital": "Krankenhaus",
"therapy": "Therapietermin",
"other": "Arzttermin"
"other": "Arzttermin",
"procedure": "Eingriff oder Operation"
},
"laterality": {
"left": "Links",
"right": "Rechts",
"both": "Beidseitig",
"none": "Keine Angabe"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Termine",
"sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.",
"add": "Termin",
@@ -10820,6 +10888,9 @@
"reason": "Anlass",
"outcome": "Ergebnis",
"outcomeReveal": "Ergebnis eintragen",
"bodySite": "Körperstelle",
"bodySiteHint": "Zum Beispiel Knie oder Gallenblase.",
"laterality": "Seite",
"linkDocuments": "Dokumente",
"linkLabResults": "Laborwerte",
"linkConditions": "Erkrankungen",
@@ -10839,12 +10910,28 @@
},
"dashboard": {
"nextVisit": "Nächster Termin: {what} am {when}"
},
"procedures": {
"title": "Eingriffe und Operationen",
"description": "Alle Eingriffe und Operationen, die du hattest, die neuesten zuerst.",
"add": "Eingriff",
"searchLabel": "Nach Körperstelle filtern",
"searchPlaceholder": "Zum Beispiel Knie",
"sitesLabel": "Körperstellen",
"allSites": "Alle",
"count": "{shown} von {total}",
"emptyTitle": "Noch kein Eingriff erfasst",
"emptyDescription": "Erfasse einen Termin der Art Eingriff oder Operation, oder stelle einen vorhandenen Termin darauf um.",
"noMatchTitle": "Nichts passt zu diesem Filter",
"clearFilter": "Filter zurücksetzen",
"loadError": "Die Eingriffe konnten nicht geladen werden."
}
},
"checkups": {
"viewSwitchLabel": "Vorsorge oder Termine anzeigen",
"viewSwitchLabel": "Vorsorge, Termine oder Eingriffe anzeigen",
"tabVorsorge": "Vorsorge",
"tabVisits": "Termine"
"tabVisits": "Termine",
"tabProcedures": "Eingriffe"
},
"practitioners": {
"title": "Praxen und Ärzte",
@@ -10880,9 +10967,10 @@
"selectAll": "Alle auswählen",
"searchPlaceholder": "Suchen",
"searchNoMatch": "Kein Treffer",
"remove": "Entfernen",
"suggested": "Um dieses Datum",
"loadError": "Die Liste konnte nicht geladen werden."
"loadError": "Die Liste konnte nicht geladen werden.",
"removeNamed": "{name} entfernen",
"removed": "Verknüpfung mit {name} gelöst"
}
}
}
+99 -11
View File
@@ -329,6 +329,11 @@
"visitsColReason": "Reason",
"visitsColOutcome": "Outcome",
"visitsColConditions": "Conditions",
"surgicalHistoryTitle": "Surgical history",
"surgicalHistoryColDate": "Date",
"surgicalHistoryColProcedure": "Procedure",
"surgicalHistoryColBodySite": "Body site",
"surgicalHistoryColOutcome": "Outcome",
"immunizationsTitle": "Immunizations",
"immunizationsColDate": "Date",
"immunizationsColVaccine": "Vaccine",
@@ -1201,6 +1206,16 @@
"yesterday": "Yesterday",
"tomorrow": "Tomorrow",
"asNeededMarker": "As needed",
"recordOnlyBadge": "Record only",
"trackIntake": {
"title": "Intake tracking",
"label": "Track intake",
"helperOn": "Doses fall due on this schedule, with reminders and adherence.",
"helperOff": "Kept as a record: nothing falls due, no reminders, no adherence.",
"enabledToast": "Intake tracking on",
"disabledToast": "Intake tracking off",
"toggleFailed": "Could not change intake tracking."
},
"withoutNotification": "Without notification",
"pausedSince": "Paused since",
"takeNow": "Take now",
@@ -1796,7 +1811,7 @@
"unpinnedToast": "Attribution released",
"dueContext": "was due: {time} ({delta})",
"confirmTitle": "Attribute to slot {time}?",
"confirmBody": "The intake counts as taken late for this slot – the rate is never flattered. You can release the attribution at any time.",
"confirmBody": "The intake counts for this slot just as it would if it had been logged there: on time or late depending on when it was taken, so the rate is never flattered. You can release the attribution at any time.",
"confirmCancel": "Cancel",
"confirmAction": "Attribute"
}
@@ -1927,6 +1942,7 @@
"nextDoseLabel": "Next dose",
"nextDoseNone": "No upcoming dose.",
"nextDoseAsNeeded": "As needed — no planned dose.",
"nextDoseNotTracked": "Not tracked, kept as a record.",
"pausedHint": "Paused — no reminders.",
"reminderLabel": "Reminder",
"reminderOn": "On",
@@ -2000,7 +2016,7 @@
"packagingEdit": "Edit",
"packagingDescription": "Set how many units one dose consumes and how large a new pack usually is — e.g. after a manufacturer switch with a different blister size.",
"packagingUnitsPerDoseLabel": "Units per dose",
"packagingUnitsPerDoseHelper": "E.g. two 2 mg tablets for a 4 mg dose (1–100). Doses already recorded stay unchanged.",
"packagingUnitsPerDoseHelper": "E.g. two 2 mg tablets for a 4 mg dose, or 1.5 for one and a half (above 0, up to 100). Doses already recorded stay unchanged.",
"packagingDefaultPackLabel": "Default pack size (units)",
"packagingDefaultPackHelper": "Prefills the quantity when registering new packs (1–1000). Leave empty if none applies.",
"packagingSuccess": "Packaging details saved",
@@ -2198,6 +2214,13 @@
"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"
},
"unitsPerDoseField": {
"other": "Other…",
"inputLabel": "Units per dose, other value",
"placeholder": "e.g. 1.5 or 1 1/2",
"invalid": "Enter a number above 0 and up to 100, for example 1.5 or 1 1/2.",
"preview": "Counts as {value} per dose"
}
},
"charts": {
@@ -4760,7 +4783,9 @@
"done": "{name} is added.",
"doneUnnamed": "Your first medication is added.",
"nextDose": "Next dose: {when}.",
"reminders": "Reminders follow the schedule you set."
"reminders": "Reminders follow the schedule you set.",
"recordOnly": "Kept as a record: no reminders, nothing falls due.",
"asNeeded": "As needed: no reminders, log a dose when you take it."
},
"log-reading": {
"title": "Enter one reading: {target}",
@@ -7169,7 +7194,7 @@
"uploadTitle": "Upload backup",
"uploadDescription": "Validate a backup file and store it as a manual snapshot. Restoring is a separate step.",
"uploadButton": "Upload file",
"uploadHelp": "JSON file matching the current backup schema. Max 10 MB.",
"uploadHelp": "A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.",
"uploadSuccess": "Backup uploaded ({count} records)",
"uploadFailed": "Upload failed",
"restore": "Restore",
@@ -7244,7 +7269,39 @@
"unknownDetail": "No nightly run has recorded this account yet.",
"age": "{hours} h ago",
"scheduleNote": "The upload runs every {hours} h. Fresh means inside one run, due means one run produced nothing, stale means two did not."
}
},
"restoreStarted": "Restore started. Its progress shows above the list.",
"restoreActive": "A restore of this account is already running. Wait for it to finish.",
"restoreJobQueuedTitle": "Restore of {username} is waiting to start",
"restoreJobQueuedNote": "It starts as soon as the background worker picks it up.",
"restoreJobRunningTitle": "Restoring {username}",
"restoreJobRunningNote": "The account keeps showing its current data until the restore finishes. You can leave this page; the restore continues on the server.",
"restoreJobResumed": "Started again after the server restarted.",
"restoreJobPhaseValidating": "Checking the backup file: {checked} readings checked",
"restoreJobPhaseClearing": "Clearing the account's current data",
"restoreJobPhaseMeasurements": "Writing readings: {done} of {total}",
"restoreJobPhaseSections": "Writing the other records: {done} of {total} sections",
"restoreJobPhaseRebuilding": "Rebuilding charts and summaries",
"restoreJobSucceededTitle": "Restore of {username} finished",
"restoreJobSucceededDescription": "{count} records restored. Finished {when}.",
"restoreJobFailedTitle": "Restore of {username} did not complete",
"restoreJobFailedKept": "Nothing was changed. The backup is kept, so you can start the restore again from its row without uploading it again.",
"restoreFailureNotFound": "The backup was deleted before the restore could read it.",
"restoreFailureChanged": "The backup was replaced by a newer copy while the restore waited. Check the new copy, then start the restore again.",
"restoreFailureUndecryptable": "The backup could not be decrypted. The key that wrote it is no longer configured, or the stored copy was changed.",
"restoreFailureSchema": "The backup file is not a valid HealthLog backup.",
"restoreFailureVersion": "The backup was written by a newer version of HealthLog. Update this server first.",
"restoreFailureOwnerMismatch": "The file inside the backup belongs to a different account than the backup row.",
"restoreFailureOwnerMissing": "The account this backup belongs to no longer exists.",
"restoreFailureSectionMissing": "The file is incomplete: its manifest names a section it does not carry.",
"restoreFailureDocument": "The file carries documents without their content, as a portable export does. Use a disaster-recovery backup.",
"restoreFailureTimeBudget": "This backup is too large to restore within the background job's time limit.",
"restoreFailureTransaction": "The database refused the restore part-way, so everything was rolled back. The server log names the error.",
"restoreFailureInterrupted": "The restore stopped when the server restarted, and was rolled back. If it stops again, the server is likely running out of memory.",
"restoreFailureNotStarted": "The background worker did not pick the restore up. Check that the worker is running.",
"restoreFailureEnqueue": "The restore could not be handed to the background worker. Check that the worker is running.",
"restoreFailureAfterCommit": "The data was restored, but a step after it failed. Charts may take until the next nightly run to catch up.",
"restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error."
},
"danger-zone": {
"title": "Danger Zone",
@@ -8233,7 +8290,9 @@
"flowSpotting": "spotting",
"flowLight": "light",
"flowMedium": "medium",
"flowHeavy": "heavy"
"flowHeavy": "heavy",
"legendIntercourse": "Intercourse",
"legendOtherEntries": "Other entries"
},
"predictions": {
"title": "What comes next",
@@ -10393,6 +10452,7 @@
"scopeCount": "{count} entries from {groups} areas",
"scopeSensitive": "incl. {names}",
"leafVisits": "Visits",
"leafSurgicalHistory": "Surgical history",
"leafImmunizations": "Immunizations",
"leafEmergency": "Emergency information (blood type, contacts, directives)"
},
@@ -10777,8 +10837,16 @@
"emergency": "Emergency",
"hospital": "Hospital",
"therapy": "Therapy session",
"other": "Appointment"
"other": "Appointment",
"procedure": "Procedure or surgery"
},
"laterality": {
"left": "Left",
"right": "Right",
"both": "Both sides",
"none": "Not stated"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Visits",
"sectionDescription": "Doctor's visits and booked appointments in one place.",
"add": "Visit",
@@ -10820,6 +10888,9 @@
"reason": "Reason",
"outcome": "Outcome",
"outcomeReveal": "Add an outcome",
"bodySite": "Body site",
"bodySiteHint": "For example knee or gallbladder.",
"laterality": "Side",
"linkDocuments": "Documents",
"linkLabResults": "Lab results",
"linkConditions": "Conditions",
@@ -10839,12 +10910,28 @@
},
"dashboard": {
"nextVisit": "Next appointment: {what} on {when}"
},
"procedures": {
"title": "Procedures and surgeries",
"description": "Every procedure and surgery you have had, newest first.",
"add": "Procedure",
"searchLabel": "Filter by body site",
"searchPlaceholder": "For example knee",
"sitesLabel": "Body sites",
"allSites": "All",
"count": "{shown} of {total}",
"emptyTitle": "No procedure recorded yet",
"emptyDescription": "Record a visit of the kind Procedure or surgery, or switch an existing visit to it.",
"noMatchTitle": "Nothing matches this filter",
"clearFilter": "Clear filter",
"loadError": "Procedures could not be loaded."
}
},
"checkups": {
"viewSwitchLabel": "Show checkups or visits",
"viewSwitchLabel": "Show checkups, visits or procedures",
"tabVorsorge": "Checkups",
"tabVisits": "Visits"
"tabVisits": "Visits",
"tabProcedures": "Procedures"
},
"practitioners": {
"title": "Practices and doctors",
@@ -10880,9 +10967,10 @@
"selectAll": "Select all",
"searchPlaceholder": "Search",
"searchNoMatch": "No match",
"remove": "Remove",
"suggested": "Around this date",
"loadError": "The list could not be loaded."
"loadError": "The list could not be loaded.",
"removeNamed": "Remove {name}",
"removed": "Unlinked {name}"
}
}
}
+99 -11
View File
@@ -293,6 +293,11 @@
"visitsColReason": "Motivo",
"visitsColOutcome": "Resultado",
"visitsColConditions": "Enfermedades",
"surgicalHistoryTitle": "Antecedentes quirúrgicos",
"surgicalHistoryColDate": "Fecha",
"surgicalHistoryColProcedure": "Procedimiento",
"surgicalHistoryColBodySite": "Zona del cuerpo",
"surgicalHistoryColOutcome": "Resultado",
"immunizationsTitle": "Vacunas",
"immunizationsColDate": "Fecha",
"immunizationsColVaccine": "Vacuna",
@@ -1201,6 +1206,16 @@
"yesterday": "Ayer",
"tomorrow": "Mañana",
"asNeededMarker": "Según necesidad",
"recordOnlyBadge": "Solo registro",
"trackIntake": {
"title": "Registro de tomas",
"label": "Registrar tomas",
"helperOn": "Las dosis tocan según este horario, con recordatorios y adherencia.",
"helperOff": "Se guarda como registro: nada queda pendiente, sin recordatorios ni adherencia.",
"enabledToast": "Registro de tomas activado",
"disabledToast": "Registro de tomas desactivado",
"toggleFailed": "No se pudo cambiar el registro de tomas."
},
"withoutNotification": "Sin notificación",
"pausedSince": "En pausa desde",
"takeNow": "Tomar ahora",
@@ -1796,7 +1811,7 @@
"unpinnedToast": "Asignación quitada",
"dueContext": "prevista: {time} ({delta})",
"confirmTitle": "¿Asignar al horario de las {time}?",
"confirmBody": "La toma contará como tomada con retraso para este horario; el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.",
"confirmBody": "La toma contará para este horario igual que si se hubiera registrado en él: a tiempo o con retraso según cuándo se tomó, así que el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.",
"confirmCancel": "Cancelar",
"confirmAction": "Asignar"
}
@@ -1927,6 +1942,7 @@
"nextDoseLabel": "Próxima dosis",
"nextDoseNone": "No hay dosis pendiente.",
"nextDoseAsNeeded": "Según necesidad — sin dosis programada.",
"nextDoseNotTracked": "Sin registro de tomas, se guarda como registro.",
"pausedHint": "En pausa — sin recordatorios.",
"reminderLabel": "Recordatorio",
"reminderOn": "Activado",
@@ -2000,7 +2016,7 @@
"packagingEdit": "Editar",
"packagingDescription": "Define cuántas unidades consume una dosis y el tamaño habitual de un envase nuevo, p. ej. tras un cambio de fabricante con otro tamaño de blíster.",
"packagingUnitsPerDoseLabel": "Unidades por dosis",
"packagingUnitsPerDoseHelper": "P. ej. dos comprimidos de 2 mg para una dosis de 4 mg (1–100). Las tomas ya registradas no cambian.",
"packagingUnitsPerDoseHelper": "P. ej. dos comprimidos de 2 mg para una dosis de 4 mg, o 1,5 para uno y medio (más de 0, hasta 100). Las tomas ya registradas no cambian.",
"packagingDefaultPackLabel": "Tamaño de envase predeterminado (unidades)",
"packagingDefaultPackHelper": "Rellena la cantidad al registrar envases nuevos (1–1000). Déjalo vacío si no aplica.",
"packagingSuccess": "Datos del envase guardados",
@@ -2198,6 +2214,13 @@
"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"
},
"unitsPerDoseField": {
"other": "Otro…",
"inputLabel": "Unidades por dosis, otro valor",
"placeholder": "p. ej., 1,5 o 1 1/2",
"invalid": "Introduce un número mayor que 0 y hasta 100, por ejemplo 1,5 o 1 1/2.",
"preview": "Cuenta como {value} por dosis"
}
},
"charts": {
@@ -4760,7 +4783,9 @@
"done": "{name} está añadido.",
"doneUnnamed": "Tu primer medicamento está añadido.",
"nextDose": "Próxima dosis: {when}.",
"reminders": "Los recordatorios siguen el horario que fijaste."
"reminders": "Los recordatorios siguen el horario que fijaste.",
"recordOnly": "Solo registro: sin recordatorios, nada queda pendiente.",
"asNeeded": "Según necesidad: sin recordatorios, registra cada toma cuando la hagas."
},
"log-reading": {
"title": "Registra una medición: {target}",
@@ -7169,7 +7194,7 @@
"uploadTitle": "Subir copia de seguridad",
"uploadDescription": "Valida un archivo de copia de seguridad y guárdalo como instantánea manual. La restauración es un paso aparte.",
"uploadButton": "Subir archivo",
"uploadHelp": "Archivo JSON que coincida con el esquema de copia de seguridad actual. Máx. 10 MB.",
"uploadHelp": "Una copia de seguridad de HealthLog en .json o .json.gz. El archivo se comprime antes de subirlo; comprimido puede ocupar hasta 512 MB.",
"uploadSuccess": "Copia de seguridad subida ({count} registros)",
"uploadFailed": "La subida falló",
"restore": "Restaurar",
@@ -7244,7 +7269,39 @@
"unknownDetail": "Ninguna ejecución nocturna ha registrado esta cuenta todavía.",
"age": "hace {hours} h",
"scheduleNote": "La subida se ejecuta cada {hours} h. Reciente significa dentro de una ejecución, pendiente que una no produjo nada, obsoleta que fueron dos."
}
},
"restoreStarted": "Restauración iniciada. Su progreso aparece encima de la lista.",
"restoreActive": "Ya hay una restauración en curso para esta cuenta. Espera a que termine.",
"restoreJobQueuedTitle": "La restauración de {username} está esperando para empezar",
"restoreJobQueuedNote": "Empieza en cuanto el proceso en segundo plano la recoja.",
"restoreJobRunningTitle": "Restaurando {username}",
"restoreJobRunningNote": "La cuenta sigue mostrando sus datos actuales hasta que termine la restauración. Puedes salir de esta página; la restauración continúa en el servidor.",
"restoreJobResumed": "Iniciada de nuevo tras reiniciarse el servidor.",
"restoreJobPhaseValidating": "Comprobando el archivo de copia: {checked} lecturas comprobadas",
"restoreJobPhaseClearing": "Borrando los datos actuales de la cuenta",
"restoreJobPhaseMeasurements": "Escribiendo lecturas: {done} de {total}",
"restoreJobPhaseSections": "Escribiendo el resto de registros: {done} de {total} secciones",
"restoreJobPhaseRebuilding": "Recalculando gráficos y resúmenes",
"restoreJobSucceededTitle": "Restauración de {username} terminada",
"restoreJobSucceededDescription": "{count} registros restaurados. Terminó {when}.",
"restoreJobFailedTitle": "La restauración de {username} no se completó",
"restoreJobFailedKept": "No se cambió nada. La copia se conserva, así que puedes volver a iniciar la restauración desde su fila sin subirla otra vez.",
"restoreFailureNotFound": "La copia se eliminó antes de que la restauración pudiera leerla.",
"restoreFailureChanged": "La copia se sustituyó por una más reciente mientras la restauración esperaba. Revisa la copia nueva y vuelve a iniciar la restauración.",
"restoreFailureUndecryptable": "No se pudo descifrar la copia. La clave con la que se escribió ya no está configurada, o la copia guardada se modificó.",
"restoreFailureSchema": "El archivo no es una copia de seguridad válida de HealthLog.",
"restoreFailureVersion": "La copia la escribió una versión más reciente de HealthLog. Actualiza primero este servidor.",
"restoreFailureOwnerMismatch": "El archivo de la copia pertenece a otra cuenta que la fila de la copia.",
"restoreFailureOwnerMissing": "La cuenta a la que pertenece esta copia ya no existe.",
"restoreFailureSectionMissing": "El archivo está incompleto: su manifiesto nombra una sección que no contiene.",
"restoreFailureDocument": "El archivo contiene documentos sin su contenido, como una exportación portátil. Usa una copia de recuperación ante desastres.",
"restoreFailureTimeBudget": "Esta copia es demasiado grande para restaurarse dentro del límite de tiempo del proceso en segundo plano.",
"restoreFailureTransaction": "La base de datos rechazó la restauración a mitad, así que se deshizo todo. El registro del servidor indica el error.",
"restoreFailureInterrupted": "La restauración se detuvo al reiniciarse el servidor y se deshizo. Si vuelve a detenerse, probablemente al servidor le falta memoria.",
"restoreFailureNotStarted": "El proceso en segundo plano no recogió la restauración. Comprueba que está en marcha.",
"restoreFailureEnqueue": "No se pudo entregar la restauración al proceso en segundo plano. Comprueba que está en marcha.",
"restoreFailureAfterCommit": "Los datos se restauraron, pero falló un paso posterior. Puede que los gráficos no se pongan al día hasta la próxima ejecución nocturna.",
"restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error."
},
"danger-zone": {
"title": "Zona de peligro",
@@ -8233,7 +8290,9 @@
"flowSpotting": "manchado",
"flowLight": "ligero",
"flowMedium": "medio",
"flowHeavy": "abundante"
"flowHeavy": "abundante",
"legendIntercourse": "Relaciones",
"legendOtherEntries": "Otras entradas"
},
"predictions": {
"title": "Qué viene después",
@@ -10393,6 +10452,7 @@
"scopeCount": "{count} entradas de {groups} áreas",
"scopeSensitive": "incl. {names}",
"leafVisits": "Visitas médicas",
"leafSurgicalHistory": "Antecedentes quirúrgicos",
"leafImmunizations": "Vacunas",
"leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)"
},
@@ -10777,8 +10837,16 @@
"emergency": "Urgencia",
"hospital": "Hospital",
"therapy": "Sesión de terapia",
"other": "Cita médica"
"other": "Cita médica",
"procedure": "Procedimiento o cirugía"
},
"laterality": {
"left": "Izquierda",
"right": "Derecha",
"both": "Ambos lados",
"none": "Sin indicar"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Citas",
"sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.",
"add": "Cita",
@@ -10820,6 +10888,9 @@
"reason": "Motivo",
"outcome": "Resultado",
"outcomeReveal": "Añadir un resultado",
"bodySite": "Zona del cuerpo",
"bodySiteHint": "Por ejemplo, rodilla o vesícula.",
"laterality": "Lado",
"linkDocuments": "Documentos",
"linkLabResults": "Resultados de laboratorio",
"linkConditions": "Enfermedades",
@@ -10839,12 +10910,28 @@
},
"dashboard": {
"nextVisit": "Próxima cita: {what} el {when}"
},
"procedures": {
"title": "Procedimientos y cirugías",
"description": "Todos los procedimientos y cirugías que has tenido, del más reciente al más antiguo.",
"add": "Procedimiento",
"searchLabel": "Filtrar por zona del cuerpo",
"searchPlaceholder": "Por ejemplo, rodilla",
"sitesLabel": "Zonas del cuerpo",
"allSites": "Todas",
"count": "{shown} de {total}",
"emptyTitle": "Aún no hay ningún procedimiento registrado",
"emptyDescription": "Registra una cita del tipo Procedimiento o cirugía, o cambia una cita existente a ese tipo.",
"noMatchTitle": "Nada coincide con este filtro",
"clearFilter": "Quitar filtro",
"loadError": "No se pudieron cargar los procedimientos."
}
},
"checkups": {
"viewSwitchLabel": "Mostrar revisiones o citas",
"viewSwitchLabel": "Mostrar revisiones, citas o procedimientos",
"tabVorsorge": "Revisiones",
"tabVisits": "Citas"
"tabVisits": "Citas",
"tabProcedures": "Procedimientos"
},
"practitioners": {
"title": "Consultas y médicos",
@@ -10880,9 +10967,10 @@
"selectAll": "Seleccionar todo",
"searchPlaceholder": "Buscar",
"searchNoMatch": "Sin coincidencias",
"remove": "Quitar",
"suggested": "En torno a esta fecha",
"loadError": "No se pudo cargar la lista."
"loadError": "No se pudo cargar la lista.",
"removeNamed": "Quitar {name}",
"removed": "Se desvinculó {name}"
}
}
}
+99 -11
View File
@@ -293,6 +293,11 @@
"visitsColReason": "Motif",
"visitsColOutcome": "Résultat",
"visitsColConditions": "Maladies",
"surgicalHistoryTitle": "Antécédents chirurgicaux",
"surgicalHistoryColDate": "Date",
"surgicalHistoryColProcedure": "Intervention",
"surgicalHistoryColBodySite": "Partie du corps",
"surgicalHistoryColOutcome": "Résultat",
"immunizationsTitle": "Vaccinations",
"immunizationsColDate": "Date",
"immunizationsColVaccine": "Vaccin",
@@ -1201,6 +1206,16 @@
"yesterday": "Hier",
"tomorrow": "Demain",
"asNeededMarker": "Au besoin",
"recordOnlyBadge": "Pour mémoire",
"trackIntake": {
"title": "Suivi des prises",
"label": "Suivre les prises",
"helperOn": "Les doses sont dues selon cet horaire, avec rappels et observance.",
"helperOff": "Conservé pour mémoire : rien n’est dû, pas de rappel, pas d’observance.",
"enabledToast": "Suivi des prises activé",
"disabledToast": "Suivi des prises désactivé",
"toggleFailed": "Impossible de modifier le suivi des prises."
},
"withoutNotification": "Sans notification",
"pausedSince": "En pause depuis",
"takeNow": "Prendre maintenant",
@@ -1796,7 +1811,7 @@
"unpinnedToast": "Prise détachée du créneau",
"dueContext": "prévue à {time} ({delta})",
"confirmTitle": "Rattacher au créneau de {time} ?",
"confirmBody": "La prise comptera comme prise en retard pour ce créneau – le taux n'est pas embelli. Le rattachement peut être retiré à tout moment.",
"confirmBody": "La prise comptera pour ce créneau exactement comme si elle y avait été enregistrée : à l'heure ou en retard selon le moment où elle a été prise. Le taux n'est donc pas embelli. Le rattachement peut être retiré à tout moment.",
"confirmCancel": "Annuler",
"confirmAction": "Rattacher"
}
@@ -1927,6 +1942,7 @@
"nextDoseLabel": "Prochaine dose",
"nextDoseNone": "Aucune dose à venir.",
"nextDoseAsNeeded": "Au besoin — aucune dose planifiée.",
"nextDoseNotTracked": "Prises non suivies, conservé pour mémoire.",
"pausedHint": "En pause — aucun rappel.",
"reminderLabel": "Rappel",
"reminderOn": "Activé",
@@ -2000,7 +2016,7 @@
"packagingEdit": "Modifier",
"packagingDescription": "Définis combien d’unités consomme une dose et la taille habituelle d’une nouvelle boîte — p. ex. après un changement de fabricant avec une autre taille de plaquette.",
"packagingUnitsPerDoseLabel": "Unités par dose",
"packagingUnitsPerDoseHelper": "P. ex. deux comprimés de 2 mg pour une dose de 4 mg (1–100). Les prises déjà enregistrées restent inchangées.",
"packagingUnitsPerDoseHelper": "P. ex. deux comprimés de 2 mg pour une dose de 4 mg, ou 1,5 pour un et demi (plus de 0, jusqu’à 100). Les prises déjà enregistrées restent inchangées.",
"packagingDefaultPackLabel": "Taille de boîte par défaut (unités)",
"packagingDefaultPackHelper": "Préremplit la quantité lors de l’enregistrement de nouvelles boîtes (1–1000). Laisse vide si aucune ne s’applique.",
"packagingSuccess": "Conditionnement enregistré",
@@ -2198,6 +2214,13 @@
"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"
},
"unitsPerDoseField": {
"other": "Autre…",
"inputLabel": "Unités par dose, autre valeur",
"placeholder": "p. ex. 1,5 ou 1 1/2",
"invalid": "Saisissez un nombre supérieur à 0 et jusqu’à 100, par exemple 1,5 ou 1 1/2.",
"preview": "Compte comme {value} par dose"
}
},
"charts": {
@@ -4760,7 +4783,9 @@
"done": "{name} est ajouté.",
"doneUnnamed": "Votre premier médicament est ajouté.",
"nextDose": "Prochaine prise : {when}.",
"reminders": "Les rappels suivent l'horaire que vous avez défini."
"reminders": "Les rappels suivent l'horaire que vous avez défini.",
"recordOnly": "Conservé pour mémoire : pas de rappel, rien n’est dû.",
"asNeeded": "Au besoin : pas de rappel, notez une prise quand vous la prenez."
},
"log-reading": {
"title": "Saisir une mesure : {target}",
@@ -7169,7 +7194,7 @@
"uploadTitle": "Téléverser une sauvegarde",
"uploadDescription": "Validez un fichier de sauvegarde et stockez-le comme instantané manuel. La restauration est une étape distincte.",
"uploadButton": "Téléverser un fichier",
"uploadHelp": "Fichier JSON correspondant au schéma de sauvegarde actuel. Max 10 Mo.",
"uploadHelp": "Une sauvegarde HealthLog au format .json ou .json.gz. Le fichier est compressé avant l’envoi ; compressé, il peut atteindre 512 Mo.",
"uploadSuccess": "Sauvegarde téléversée ({count} enregistrements)",
"uploadFailed": "Échec du téléversement",
"restore": "Restaurer",
@@ -7244,7 +7269,39 @@
"unknownDetail": "Aucun cycle nocturne n'a encore enregistré ce compte.",
"age": "il y a {hours} h",
"scheduleNote": "L'envoi tourne toutes les {hours} h. Récente signifie dans un cycle, attendue qu'un cycle n'a rien produit, périmée que deux n'ont rien produit."
}
},
"restoreStarted": "Restauration lancée. Sa progression s'affiche au-dessus de la liste.",
"restoreActive": "Une restauration de ce compte est déjà en cours. Attends qu'elle se termine.",
"restoreJobQueuedTitle": "La restauration de {username} attend de démarrer",
"restoreJobQueuedNote": "Elle démarre dès que le processus en arrière-plan la prend en charge.",
"restoreJobRunningTitle": "Restauration de {username} en cours",
"restoreJobRunningNote": "Le compte affiche ses données actuelles jusqu'à la fin de la restauration. Tu peux quitter cette page ; la restauration continue sur le serveur.",
"restoreJobResumed": "Relancée après le redémarrage du serveur.",
"restoreJobPhaseValidating": "Vérification du fichier de sauvegarde : {checked} mesures vérifiées",
"restoreJobPhaseClearing": "Suppression des données actuelles du compte",
"restoreJobPhaseMeasurements": "Écriture des mesures : {done} sur {total}",
"restoreJobPhaseSections": "Écriture des autres entrées : {done} sections sur {total}",
"restoreJobPhaseRebuilding": "Recalcul des graphiques et des résumés",
"restoreJobSucceededTitle": "Restauration de {username} terminée",
"restoreJobSucceededDescription": "{count} entrées restaurées. Terminée {when}.",
"restoreJobFailedTitle": "La restauration de {username} n'a pas abouti",
"restoreJobFailedKept": "Rien n'a été modifié. La sauvegarde est conservée : tu peux relancer la restauration depuis sa ligne sans la téléverser à nouveau.",
"restoreFailureNotFound": "La sauvegarde a été supprimée avant que la restauration puisse la lire.",
"restoreFailureChanged": "La sauvegarde a été remplacée par une copie plus récente pendant l'attente. Vérifie la nouvelle copie, puis relance la restauration.",
"restoreFailureUndecryptable": "La sauvegarde n'a pas pu être déchiffrée. La clé qui l'a écrite n'est plus configurée, ou la copie stockée a été modifiée.",
"restoreFailureSchema": "Le fichier n'est pas une sauvegarde HealthLog valide.",
"restoreFailureVersion": "La sauvegarde a été écrite par une version plus récente de HealthLog. Mets d'abord ce serveur à jour.",
"restoreFailureOwnerMismatch": "Le fichier de la sauvegarde appartient à un autre compte que la ligne de sauvegarde.",
"restoreFailureOwnerMissing": "Le compte auquel appartient cette sauvegarde n'existe plus.",
"restoreFailureSectionMissing": "Le fichier est incomplet : son manifeste nomme une section qu'il ne contient pas.",
"restoreFailureDocument": "Le fichier contient des documents sans leur contenu, comme un export portable. Utilise une sauvegarde de reprise après sinistre.",
"restoreFailureTimeBudget": "Cette sauvegarde est trop volumineuse pour être restaurée dans le temps imparti au processus en arrière-plan.",
"restoreFailureTransaction": "La base de données a refusé la restauration en cours de route, tout a donc été annulé. Le journal du serveur indique l'erreur.",
"restoreFailureInterrupted": "La restauration s'est arrêtée au redémarrage du serveur et a été annulée. Si elle s'arrête encore, le serveur manque probablement de mémoire.",
"restoreFailureNotStarted": "Le processus en arrière-plan n'a pas pris la restauration en charge. Vérifie qu'il tourne.",
"restoreFailureEnqueue": "La restauration n'a pas pu être transmise au processus en arrière-plan. Vérifie qu'il tourne.",
"restoreFailureAfterCommit": "Les données ont été restaurées, mais une étape suivante a échoué. Les graphiques ne seront peut-être à jour qu'après la prochaine exécution nocturne.",
"restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur."
},
"danger-zone": {
"title": "Zone de danger",
@@ -8233,7 +8290,9 @@
"flowSpotting": "spotting",
"flowLight": "léger",
"flowMedium": "moyen",
"flowHeavy": "abondant"
"flowHeavy": "abondant",
"legendIntercourse": "Rapports",
"legendOtherEntries": "Autres saisies"
},
"predictions": {
"title": "Ce qui arrive ensuite",
@@ -10393,6 +10452,7 @@
"scopeCount": "{count} éléments issus de {groups} domaines",
"scopeSensitive": "dont {names}",
"leafVisits": "Consultations",
"leafSurgicalHistory": "Antécédents chirurgicaux",
"leafImmunizations": "Vaccinations",
"leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)"
},
@@ -10777,8 +10837,16 @@
"emergency": "Urgence",
"hospital": "Hôpital",
"therapy": "Séance de thérapie",
"other": "Rendez-vous médical"
"other": "Rendez-vous médical",
"procedure": "Intervention ou chirurgie"
},
"laterality": {
"left": "Gauche",
"right": "Droite",
"both": "Des deux côtés",
"none": "Non précisé"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Rendez-vous",
"sectionDescription": "Consultations et rendez-vous pris, au même endroit.",
"add": "Rendez-vous",
@@ -10820,6 +10888,9 @@
"reason": "Motif",
"outcome": "Résultat",
"outcomeReveal": "Ajouter un résultat",
"bodySite": "Partie du corps",
"bodySiteHint": "Par exemple genou ou vésicule biliaire.",
"laterality": "Côté",
"linkDocuments": "Documents",
"linkLabResults": "Résultats de laboratoire",
"linkConditions": "Maladies",
@@ -10839,12 +10910,28 @@
},
"dashboard": {
"nextVisit": "Prochain rendez-vous : {what} le {when}"
},
"procedures": {
"title": "Interventions et chirurgies",
"description": "Toutes les interventions et chirurgies que vous avez eues, les plus récentes d’abord.",
"add": "Intervention",
"searchLabel": "Filtrer par partie du corps",
"searchPlaceholder": "Par exemple genou",
"sitesLabel": "Parties du corps",
"allSites": "Toutes",
"count": "{shown} sur {total}",
"emptyTitle": "Aucune intervention enregistrée",
"emptyDescription": "Enregistrez un rendez-vous du type Intervention ou chirurgie, ou passez un rendez-vous existant à ce type.",
"noMatchTitle": "Rien ne correspond à ce filtre",
"clearFilter": "Effacer le filtre",
"loadError": "Les interventions n’ont pas pu être chargées."
}
},
"checkups": {
"viewSwitchLabel": "Afficher les dépistages ou les rendez-vous",
"viewSwitchLabel": "Afficher les dépistages, les rendez-vous ou les interventions",
"tabVorsorge": "Dépistages",
"tabVisits": "Rendez-vous"
"tabVisits": "Rendez-vous",
"tabProcedures": "Interventions"
},
"practitioners": {
"title": "Cabinets et médecins",
@@ -10880,9 +10967,10 @@
"selectAll": "Tout sélectionner",
"searchPlaceholder": "Rechercher",
"searchNoMatch": "Aucun résultat",
"remove": "Retirer",
"suggested": "Autour de cette date",
"loadError": "Impossible de charger la liste."
"loadError": "Impossible de charger la liste.",
"removeNamed": "Retirer {name}",
"removed": "{name} dissocié"
}
}
}
+99 -11
View File
@@ -293,6 +293,11 @@
"visitsColReason": "Motivo",
"visitsColOutcome": "Esito",
"visitsColConditions": "Malattie",
"surgicalHistoryTitle": "Anamnesi chirurgica",
"surgicalHistoryColDate": "Data",
"surgicalHistoryColProcedure": "Intervento",
"surgicalHistoryColBodySite": "Parte del corpo",
"surgicalHistoryColOutcome": "Esito",
"immunizationsTitle": "Vaccinazioni",
"immunizationsColDate": "Data",
"immunizationsColVaccine": "Vaccino",
@@ -1201,6 +1206,16 @@
"yesterday": "Ieri",
"tomorrow": "Domani",
"asNeededMarker": "Al bisogno",
"recordOnlyBadge": "Solo annotato",
"trackIntake": {
"title": "Registrazione assunzioni",
"label": "Registra le assunzioni",
"helperOn": "Le dosi sono dovute secondo questo programma, con promemoria e aderenza.",
"helperOff": "Solo annotato: nulla è dovuto, niente promemoria, niente aderenza.",
"enabledToast": "Registrazione assunzioni attiva",
"disabledToast": "Registrazione assunzioni disattivata",
"toggleFailed": "Impossibile modificare la registrazione delle assunzioni."
},
"withoutNotification": "Senza notifica",
"pausedSince": "In pausa dal",
"takeNow": "Assumi ora",
@@ -1796,7 +1811,7 @@
"unpinnedToast": "Attribuzione rimossa",
"dueContext": "prevista alle {time} ({delta})",
"confirmTitle": "Attribuire allo slot delle {time}?",
"confirmBody": "L'assunzione conterà come in ritardo per questo slot – la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.",
"confirmBody": "L'assunzione conterà per questo slot esattamente come se fosse stata registrata lì: puntuale o in ritardo a seconda di quando è stata presa, quindi la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.",
"confirmCancel": "Annulla",
"confirmAction": "Attribuisci"
}
@@ -1927,6 +1942,7 @@
"nextDoseLabel": "Prossima dose",
"nextDoseNone": "Nessuna dose in programma.",
"nextDoseAsNeeded": "Al bisogno — nessuna dose programmata.",
"nextDoseNotTracked": "Assunzioni non registrate, solo annotato.",
"pausedHint": "In pausa — nessun promemoria.",
"reminderLabel": "Promemoria",
"reminderOn": "Attivo",
@@ -2000,7 +2016,7 @@
"packagingEdit": "Modifica",
"packagingDescription": "Imposta quante unità consuma una dose e la dimensione abituale di una nuova confezione, ad es. dopo un cambio di produttore con blister diverso.",
"packagingUnitsPerDoseLabel": "Unità per dose",
"packagingUnitsPerDoseHelper": "Ad es. due compresse da 2 mg per una dose da 4 mg (1–100). Le assunzioni già registrate restano invariate.",
"packagingUnitsPerDoseHelper": "Ad es. due compresse da 2 mg per una dose da 4 mg, oppure 1,5 per una e mezza (oltre 0, fino a 100). Le assunzioni già registrate restano invariate.",
"packagingDefaultPackLabel": "Dimensione confezione predefinita (unità)",
"packagingDefaultPackHelper": "Precompila la quantità quando registri nuove confezioni (1–1000). Lascia vuoto se non applicabile.",
"packagingSuccess": "Dati confezione salvati",
@@ -2198,6 +2214,13 @@
"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"
},
"unitsPerDoseField": {
"other": "Altro…",
"inputLabel": "Unità per dose, altro valore",
"placeholder": "ad es. 1,5 o 1 1/2",
"invalid": "Inserisci un numero maggiore di 0 e fino a 100, ad esempio 1,5 o 1 1/2.",
"preview": "Conta come {value} per dose"
}
},
"charts": {
@@ -4760,7 +4783,9 @@
"done": "{name} è stato aggiunto.",
"doneUnnamed": "Il tuo primo farmaco è stato aggiunto.",
"nextDose": "Prossima dose: {when}.",
"reminders": "I promemoria seguono l'orario che hai impostato."
"reminders": "I promemoria seguono l'orario che hai impostato.",
"recordOnly": "Solo annotato: niente promemoria, nulla è dovuto.",
"asNeeded": "Al bisogno: niente promemoria, registra una dose quando la prendi."
},
"log-reading": {
"title": "Inserisci una misurazione: {target}",
@@ -7169,7 +7194,7 @@
"uploadTitle": "Carica backup",
"uploadDescription": "Convalida un file di backup e archivialo come istantanea manuale. Il ripristino è un passaggio separato.",
"uploadButton": "Carica file",
"uploadHelp": "File JSON conforme allo schema di backup attuale. Max 10 MB.",
"uploadHelp": "Un backup di HealthLog in formato .json o .json.gz. Il file viene compresso prima del caricamento; compresso può arrivare a 512 MB.",
"uploadSuccess": "Backup caricato ({count} record)",
"uploadFailed": "Caricamento non riuscito",
"restore": "Ripristina",
@@ -7244,7 +7269,39 @@
"unknownDetail": "Nessun giro notturno ha ancora registrato questo account.",
"age": "{hours} h fa",
"scheduleNote": "Il caricamento gira ogni {hours} h. Recente significa entro un giro, attesa che un giro non ha prodotto nulla, obsoleta che non lo hanno fatto due."
}
},
"restoreStarted": "Ripristino avviato. L'avanzamento compare sopra l'elenco.",
"restoreActive": "Per questo account è già in corso un ripristino. Aspetta che finisca.",
"restoreJobQueuedTitle": "Il ripristino di {username} è in attesa di partire",
"restoreJobQueuedNote": "Parte appena il processo in background lo prende in carico.",
"restoreJobRunningTitle": "Ripristino di {username} in corso",
"restoreJobRunningNote": "L'account continua a mostrare i dati attuali finché il ripristino non termina. Puoi lasciare questa pagina: il ripristino prosegue sul server.",
"restoreJobResumed": "Riavviato dopo il riavvio del server.",
"restoreJobPhaseValidating": "Controllo del file di backup: {checked} misurazioni controllate",
"restoreJobPhaseClearing": "Rimozione dei dati attuali dell'account",
"restoreJobPhaseMeasurements": "Scrittura delle misurazioni: {done} di {total}",
"restoreJobPhaseSections": "Scrittura degli altri dati: {done} di {total} sezioni",
"restoreJobPhaseRebuilding": "Ricalcolo di grafici e riepiloghi",
"restoreJobSucceededTitle": "Ripristino di {username} completato",
"restoreJobSucceededDescription": "{count} record ripristinati. Terminato {when}.",
"restoreJobFailedTitle": "Il ripristino di {username} non è stato completato",
"restoreJobFailedKept": "Non è stato modificato nulla. Il backup resta disponibile, quindi puoi riavviare il ripristino dalla sua riga senza caricarlo di nuovo.",
"restoreFailureNotFound": "Il backup è stato eliminato prima che il ripristino potesse leggerlo.",
"restoreFailureChanged": "Il backup è stato sostituito da una copia più recente mentre il ripristino aspettava. Controlla la nuova copia, poi riavvia il ripristino.",
"restoreFailureUndecryptable": "Non è stato possibile decifrare il backup. La chiave con cui è stato scritto non è più configurata, oppure la copia salvata è stata modificata.",
"restoreFailureSchema": "Il file non è un backup HealthLog valido.",
"restoreFailureVersion": "Il backup è stato scritto da una versione più recente di HealthLog. Aggiorna prima questo server.",
"restoreFailureOwnerMismatch": "Il file nel backup appartiene a un account diverso da quello della riga di backup.",
"restoreFailureOwnerMissing": "L'account a cui appartiene questo backup non esiste più.",
"restoreFailureSectionMissing": "Il file è incompleto: il suo manifesto nomina una sezione che non contiene.",
"restoreFailureDocument": "Il file contiene documenti senza il loro contenuto, come un'esportazione portabile. Usa un backup di disaster recovery.",
"restoreFailureTimeBudget": "Questo backup è troppo grande per essere ripristinato entro il tempo concesso al processo in background.",
"restoreFailureTransaction": "Il database ha rifiutato il ripristino a metà, quindi tutto è stato annullato. Il log del server indica l'errore.",
"restoreFailureInterrupted": "Il ripristino si è interrotto al riavvio del server ed è stato annullato. Se si interrompe di nuovo, probabilmente al server manca memoria.",
"restoreFailureNotStarted": "Il processo in background non ha preso in carico il ripristino. Controlla che sia in esecuzione.",
"restoreFailureEnqueue": "Non è stato possibile passare il ripristino al processo in background. Controlla che sia in esecuzione.",
"restoreFailureAfterCommit": "I dati sono stati ripristinati, ma un passaggio successivo non è riuscito. I grafici potrebbero aggiornarsi solo con la prossima esecuzione notturna.",
"restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore."
},
"danger-zone": {
"title": "Zona pericolosa",
@@ -8233,7 +8290,9 @@
"flowSpotting": "spotting",
"flowLight": "leggero",
"flowMedium": "medio",
"flowHeavy": "abbondante"
"flowHeavy": "abbondante",
"legendIntercourse": "Rapporti",
"legendOtherEntries": "Altre voci"
},
"predictions": {
"title": "Cosa arriva dopo",
@@ -10393,6 +10452,7 @@
"scopeCount": "{count} voci da {groups} aree",
"scopeSensitive": "incl. {names}",
"leafVisits": "Visite mediche",
"leafSurgicalHistory": "Anamnesi chirurgica",
"leafImmunizations": "Vaccinazioni",
"leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)"
},
@@ -10777,8 +10837,16 @@
"emergency": "Emergenza",
"hospital": "Ospedale",
"therapy": "Seduta di terapia",
"other": "Appuntamento medico"
"other": "Appuntamento medico",
"procedure": "Procedura o intervento chirurgico"
},
"laterality": {
"left": "Sinistra",
"right": "Destra",
"both": "Entrambi i lati",
"none": "Non indicato"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Appuntamenti",
"sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.",
"add": "Appuntamento",
@@ -10820,6 +10888,9 @@
"reason": "Motivo",
"outcome": "Esito",
"outcomeReveal": "Aggiungi un esito",
"bodySite": "Parte del corpo",
"bodySiteHint": "Per esempio ginocchio o cistifellea.",
"laterality": "Lato",
"linkDocuments": "Documenti",
"linkLabResults": "Esami di laboratorio",
"linkConditions": "Malattie",
@@ -10839,12 +10910,28 @@
},
"dashboard": {
"nextVisit": "Prossimo appuntamento: {what} il {when}"
},
"procedures": {
"title": "Procedure e interventi chirurgici",
"description": "Tutte le procedure e gli interventi che hai avuto, dal più recente.",
"add": "Intervento",
"searchLabel": "Filtra per parte del corpo",
"searchPlaceholder": "Per esempio ginocchio",
"sitesLabel": "Parti del corpo",
"allSites": "Tutte",
"count": "{shown} di {total}",
"emptyTitle": "Nessun intervento registrato",
"emptyDescription": "Registra un appuntamento del tipo Procedura o intervento chirurgico, oppure cambia un appuntamento esistente in questo tipo.",
"noMatchTitle": "Nessun risultato per questo filtro",
"clearFilter": "Rimuovi filtro",
"loadError": "Non è stato possibile caricare gli interventi."
}
},
"checkups": {
"viewSwitchLabel": "Mostra controlli o appuntamenti",
"viewSwitchLabel": "Mostra controlli, appuntamenti o interventi",
"tabVorsorge": "Controlli",
"tabVisits": "Appuntamenti"
"tabVisits": "Appuntamenti",
"tabProcedures": "Interventi"
},
"practitioners": {
"title": "Studi e medici",
@@ -10880,9 +10967,10 @@
"selectAll": "Seleziona tutto",
"searchPlaceholder": "Cerca",
"searchNoMatch": "Nessun risultato",
"remove": "Rimuovi",
"suggested": "Intorno a questa data",
"loadError": "Impossibile caricare l’elenco."
"loadError": "Impossibile caricare l’elenco.",
"removeNamed": "Rimuovi {name}",
"removed": "Collegamento a {name} rimosso"
}
}
}
+99 -11
View File
@@ -329,6 +329,11 @@
"visitsColReason": "사유",
"visitsColOutcome": "결과",
"visitsColConditions": "질환",
"surgicalHistoryTitle": "수술 이력",
"surgicalHistoryColDate": "날짜",
"surgicalHistoryColProcedure": "시술",
"surgicalHistoryColBodySite": "신체 부위",
"surgicalHistoryColOutcome": "결과",
"immunizationsTitle": "예방접종",
"immunizationsColDate": "날짜",
"immunizationsColVaccine": "백신",
@@ -1201,6 +1206,16 @@
"yesterday": "어제",
"tomorrow": "내일",
"asNeededMarker": "필요시",
"recordOnlyBadge": "기록만",
"trackIntake": {
"title": "복용 기록",
"label": "복용 기록하기",
"helperOn": "이 일정에 따라 복용 시각이 되고, 알림과 복약 순응도가 적용돼요.",
"helperOff": "기록으로만 남겨요: 예정된 복용, 알림, 복약 순응도가 없어요.",
"enabledToast": "복용 기록 켜짐",
"disabledToast": "복용 기록 꺼짐",
"toggleFailed": "복용 기록 설정을 바꾸지 못했어요."
},
"withoutNotification": "알림 없음",
"pausedSince": "일시중지 시작",
"takeNow": "지금 복용",
@@ -1796,7 +1811,7 @@
"unpinnedToast": "배정을 해제했어요",
"dueContext": "예정 시각: {time} ({delta})",
"confirmTitle": "{time} 시간대에 배정할까요?",
"confirmBody": "이 복용은 해당 시간대에 늦게 복용한 것으로 계산돼요 – 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.",
"confirmBody": "이 복용은 처음부터 이 시간대에 기록한 것과 똑같이 계산돼요. 복용한 시각에 따라 제시간 또는 늦게 복용한 것으로 계산되어 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.",
"confirmCancel": "취소",
"confirmAction": "배정"
}
@@ -1927,6 +1942,7 @@
"nextDoseLabel": "다음 복용",
"nextDoseNone": "예정된 복용이 없어요.",
"nextDoseAsNeeded": "필요시 복용 — 예정된 복용이 없어요.",
"nextDoseNotTracked": "복용을 기록하지 않아요. 기록으로만 남겨요.",
"pausedHint": "일시중지 — 알림이 오지 않아요.",
"reminderLabel": "알림",
"reminderOn": "켜짐",
@@ -2000,7 +2016,7 @@
"packagingEdit": "편집",
"packagingDescription": "1회분에 몇 개를 쓰는지, 새 포장이 보통 몇 개인지 설정해요 — 예를 들어 제조사가 바뀌어 포장 크기가 달라졌을 때요.",
"packagingUnitsPerDoseLabel": "1회분당 개수",
"packagingUnitsPerDoseHelper": "예: 4mg 복용에 2mg 정제 2개 (1–100). 이미 기록된 복용은 그대로예요.",
"packagingUnitsPerDoseHelper": "예: 4mg 복용에 2mg 정제 2개, 한 알 반이면 1.5 (0 초과, 100 이하). 이미 기록된 복용은 그대로예요.",
"packagingDefaultPackLabel": "기본 포장 크기 (개)",
"packagingDefaultPackHelper": "새 포장을 등록할 때 수량을 미리 채워요 (1–1000). 해당 없으면 비워 두세요.",
"packagingSuccess": "포장 정보를 저장했어요",
@@ -2198,6 +2214,13 @@
"reasonStatusNoDoseInformation": "파일에 그 복용에 대한 기록이 없어요 — 응답도 없었고 기록된 것도 없어요",
"reasonStatusReminderEvent": "행이 미뤄 둔 알림에 관한 내용이라 복용에 대해 알려주는 게 없어요",
"reasonStatusNotificationNotSent": "행은 알림이 발송되지 않았다는 기록이라 복용이 아니라 앱에 관한 내용이에요"
},
"unitsPerDoseField": {
"other": "기타…",
"inputLabel": "1회분 단위 수, 다른 값",
"placeholder": "예: 1.5 또는 1 1/2",
"invalid": "0보다 크고 100 이하인 숫자를 입력하세요. 예: 1.5 또는 1 1/2",
"preview": "1회분당 {value}(으)로 계산돼요"
}
},
"charts": {
@@ -4760,7 +4783,9 @@
"done": "{name}이(가) 추가되었습니다.",
"doneUnnamed": "첫 약이 추가되었습니다.",
"nextDose": "다음 복용: {when}.",
"reminders": "알림은 설정한 일정을 따릅니다."
"reminders": "알림은 설정한 일정을 따릅니다.",
"recordOnly": "기록으로만 남겨요: 알림도, 예정된 복용도 없어요.",
"asNeeded": "필요시: 알림 없이, 복용할 때 기록하세요."
},
"log-reading": {
"title": "측정값 하나 입력: {target}",
@@ -7148,7 +7173,7 @@
"uploadTitle": "백업 업로드",
"uploadDescription": "백업 파일을 검사한 뒤 수동 스냅샷으로 저장해요. 복원은 별도 단계예요.",
"uploadButton": "파일 업로드",
"uploadHelp": "현재 백업 스키마에 맞는 JSON 파일이에요. 최대 10 MB.",
"uploadHelp": "HealthLog 백업 파일(.json 또는 .json.gz)이에요. 업로드 전에 압축되며, 압축된 크기는 최대 512 MB까지 가능해요.",
"uploadSuccess": "백업을 업로드했어요 (기록 {count}개)",
"uploadFailed": "업로드하지 못했어요",
"restore": "복원",
@@ -7223,7 +7248,39 @@
"unknownDetail": "아직 어떤 야간 실행도 이 계정을 기록하지 않았습니다.",
"age": "{hours}시간 전",
"scheduleNote": "업로드는 {hours}시간마다 실행됩니다. 최신은 한 번의 실행 이내, 지연은 한 번이 아무것도 만들지 못한 상태, 오래됨은 두 번이 그런 상태입니다."
}
},
"restoreStarted": "복원을 시작했어요. 진행 상황은 목록 위에 표시돼요.",
"restoreActive": "이 계정의 복원이 이미 진행 중이에요. 끝날 때까지 기다려 주세요.",
"restoreJobQueuedTitle": "{username} 복원이 시작을 기다리고 있어요",
"restoreJobQueuedNote": "백그라운드 작업자가 가져가는 즉시 시작돼요.",
"restoreJobRunningTitle": "{username} 복원 중",
"restoreJobRunningNote": "복원이 끝날 때까지 계정에는 기존 데이터가 그대로 보여요. 이 페이지를 떠나도 복원은 서버에서 계속돼요.",
"restoreJobResumed": "서버가 다시 시작된 뒤 복원을 다시 시작했어요.",
"restoreJobPhaseValidating": "백업 파일 확인 중: 측정값 {checked}개 확인",
"restoreJobPhaseClearing": "계정의 기존 데이터를 지우는 중",
"restoreJobPhaseMeasurements": "측정값 기록 중: {total}개 중 {done}개",
"restoreJobPhaseSections": "나머지 기록 작성 중: {total}개 항목 중 {done}개",
"restoreJobPhaseRebuilding": "차트와 요약을 다시 계산하는 중",
"restoreJobSucceededTitle": "{username} 복원이 끝났어요",
"restoreJobSucceededDescription": "기록 {count}개를 복원했어요. 완료: {when}.",
"restoreJobFailedTitle": "{username} 복원이 완료되지 않았어요",
"restoreJobFailedKept": "아무것도 바뀌지 않았어요. 백업은 그대로 남아 있으니 다시 업로드하지 않고 해당 행에서 복원을 다시 시작할 수 있어요.",
"restoreFailureNotFound": "복원이 읽기 전에 백업이 삭제됐어요.",
"restoreFailureChanged": "복원이 기다리는 동안 백업이 더 최신 사본으로 바뀌었어요. 새 사본을 확인한 뒤 복원을 다시 시작해 주세요.",
"restoreFailureUndecryptable": "백업을 복호화할 수 없어요. 백업을 쓴 키가 더 이상 설정되어 있지 않거나, 저장된 사본이 바뀌었어요.",
"restoreFailureSchema": "올바른 HealthLog 백업 파일이 아니에요.",
"restoreFailureVersion": "더 새로운 HealthLog 버전이 쓴 백업이에요. 먼저 이 서버를 업데이트해 주세요.",
"restoreFailureOwnerMismatch": "백업 안의 파일이 백업 행과 다른 계정의 것이에요.",
"restoreFailureOwnerMissing": "이 백업이 속한 계정이 더 이상 없어요.",
"restoreFailureSectionMissing": "파일이 불완전해요. 매니페스트에 적힌 항목이 파일에 없어요.",
"restoreFailureDocument": "휴대용 내보내기처럼 내용 없이 문서만 담긴 파일이에요. 재해 복구용 백업을 사용해 주세요.",
"restoreFailureTimeBudget": "이 백업은 너무 커서 백그라운드 작업의 시간 제한 안에 복원할 수 없어요.",
"restoreFailureTransaction": "데이터베이스가 복원 도중에 거부해서 모든 변경을 되돌렸어요. 서버 로그에 오류가 나와 있어요.",
"restoreFailureInterrupted": "서버가 다시 시작되면서 복원이 멈췄고 되돌려졌어요. 또 멈춘다면 서버 메모리가 부족할 가능성이 커요.",
"restoreFailureNotStarted": "백그라운드 작업자가 복원을 가져가지 않았어요. 작업자가 실행 중인지 확인해 주세요.",
"restoreFailureEnqueue": "복원을 백그라운드 작업자에게 넘기지 못했어요. 작업자가 실행 중인지 확인해 주세요.",
"restoreFailureAfterCommit": "데이터는 복원됐지만 그다음 단계가 실패했어요. 차트는 다음 야간 실행 때 따라잡을 수 있어요.",
"restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요."
},
"danger-zone": {
"title": "위험 구역",
@@ -8233,7 +8290,9 @@
"flowSpotting": "점상 출혈",
"flowLight": "적음",
"flowMedium": "보통",
"flowHeavy": "많음"
"flowHeavy": "많음",
"legendIntercourse": "성관계",
"legendOtherEntries": "기타 기록"
},
"predictions": {
"title": "다음에 올 일",
@@ -10393,6 +10452,7 @@
"scopeCount": "{groups}개 영역에서 {count}개 항목",
"scopeSensitive": "{names} 포함",
"leafVisits": "진료",
"leafSurgicalHistory": "수술 이력",
"leafImmunizations": "예방접종",
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)"
},
@@ -10777,8 +10837,16 @@
"emergency": "응급",
"hospital": "병원",
"therapy": "치료 세션",
"other": "예약"
"other": "예약",
"procedure": "시술 또는 수술"
},
"laterality": {
"left": "왼쪽",
"right": "오른쪽",
"both": "양쪽",
"none": "지정 안 함"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "진료",
"sectionDescription": "병원 방문과 잡아 둔 예약을 한곳에서 봐요.",
"add": "진료",
@@ -10820,6 +10888,9 @@
"reason": "사유",
"outcome": "결과",
"outcomeReveal": "결과 추가",
"bodySite": "신체 부위",
"bodySiteHint": "예: 무릎, 담낭",
"laterality": "방향",
"linkDocuments": "문서",
"linkLabResults": "검사 결과",
"linkConditions": "질환",
@@ -10839,12 +10910,28 @@
},
"dashboard": {
"nextVisit": "다음 예약: {what} ({when})"
},
"procedures": {
"title": "시술 및 수술",
"description": "지금까지 받은 모든 시술과 수술을 최신순으로 보여 줍니다.",
"add": "시술",
"searchLabel": "신체 부위로 필터",
"searchPlaceholder": "예: 무릎",
"sitesLabel": "신체 부위",
"allSites": "전체",
"count": "{total}개 중 {shown}개",
"emptyTitle": "아직 기록된 시술이 없습니다",
"emptyDescription": "종류를 '시술 또는 수술'로 진료를 기록하거나, 기존 진료의 종류를 바꾸세요.",
"noMatchTitle": "이 필터에 맞는 항목이 없습니다",
"clearFilter": "필터 지우기",
"loadError": "시술 목록을 불러올 수 없습니다."
}
},
"checkups": {
"viewSwitchLabel": "건강검진 또는 진료 보기",
"viewSwitchLabel": "건강검진, 진료 또는 시술 보기",
"tabVorsorge": "건강검진",
"tabVisits": "진료"
"tabVisits": "진료",
"tabProcedures": "시술·수술"
},
"practitioners": {
"title": "병원과 의사",
@@ -10880,9 +10967,10 @@
"selectAll": "전체 선택",
"searchPlaceholder": "검색",
"searchNoMatch": "결과 없음",
"remove": "제거",
"suggested": "이 날짜 전후",
"loadError": "목록을 불러오지 못했습니다."
"loadError": "목록을 불러오지 못했습니다.",
"removeNamed": "{name} 제거",
"removed": "{name} 연결 해제됨"
}
}
}
+99 -11
View File
@@ -293,6 +293,11 @@
"visitsColReason": "Powód",
"visitsColOutcome": "Wynik",
"visitsColConditions": "Choroby",
"surgicalHistoryTitle": "Historia operacji",
"surgicalHistoryColDate": "Data",
"surgicalHistoryColProcedure": "Zabieg",
"surgicalHistoryColBodySite": "Miejsce na ciele",
"surgicalHistoryColOutcome": "Wynik",
"immunizationsTitle": "Szczepienia",
"immunizationsColDate": "Data",
"immunizationsColVaccine": "Szczepionka",
@@ -1201,6 +1206,16 @@
"yesterday": "Wczoraj",
"tomorrow": "Jutro",
"asNeededMarker": "W razie potrzeby",
"recordOnlyBadge": "Tylko zapis",
"trackIntake": {
"title": "Rejestrowanie przyjęć",
"label": "Rejestruj przyjęcia",
"helperOn": "Dawki przypadają według tego harmonogramu, z przypomnieniami i przestrzeganiem.",
"helperOff": "Tylko zapis: nic nie przypada, bez przypomnień i bez przestrzegania.",
"enabledToast": "Rejestrowanie przyjęć włączone",
"disabledToast": "Rejestrowanie przyjęć wyłączone",
"toggleFailed": "Nie udało się zmienić rejestrowania przyjęć."
},
"withoutNotification": "Bez powiadomienia",
"pausedSince": "Wstrzymane od",
"takeNow": "Przyjmij teraz",
@@ -1796,7 +1811,7 @@
"unpinnedToast": "Przypisanie usunięte",
"dueContext": "termin: {time} ({delta})",
"confirmTitle": "Przypisać do slotu {time}?",
"confirmBody": "Dawka zostanie policzona jako przyjęta z opóźnieniem dla tego slotu – wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.",
"confirmBody": "Dawka zostanie policzona dla tego slotu tak, jakby została w nim zapisana: jako przyjęta na czas lub z opóźnieniem, zależnie od tego, kiedy ją przyjęto, więc wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.",
"confirmCancel": "Anuluj",
"confirmAction": "Przypisz"
}
@@ -1927,6 +1942,7 @@
"nextDoseLabel": "Następna dawka",
"nextDoseNone": "Brak nadchodzącej dawki.",
"nextDoseAsNeeded": "W razie potrzeby — brak zaplanowanej dawki.",
"nextDoseNotTracked": "Przyjęcia nie są rejestrowane, tylko zapis.",
"pausedHint": "Wstrzymano — brak przypomnień.",
"reminderLabel": "Przypomnienie",
"reminderOn": "Włączone",
@@ -2000,7 +2016,7 @@
"packagingEdit": "Edytuj",
"packagingDescription": "Określ, ile jednostek zużywa jedna dawka i jaki jest typowy rozmiar nowego opakowania — np. po zmianie producenta z innym blistrem.",
"packagingUnitsPerDoseLabel": "Jednostki na dawkę",
"packagingUnitsPerDoseHelper": "Np. dwie tabletki 2 mg na dawkę 4 mg (1–100). Zarejestrowane dawki pozostają bez zmian.",
"packagingUnitsPerDoseHelper": "Np. dwie tabletki 2 mg na dawkę 4 mg lub 1,5 dla półtorej (powyżej 0, do 100). Zarejestrowane dawki pozostają bez zmian.",
"packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)",
"packagingDefaultPackHelper": "Wstępnie wypełnia ilość przy dodawaniu nowych opakowań (1–1000). Zostaw puste, jeśli nie dotyczy.",
"packagingSuccess": "Zapisano dane opakowania",
@@ -2198,6 +2214,13 @@
"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"
},
"unitsPerDoseField": {
"other": "Inna…",
"inputLabel": "Jednostki na dawkę, inna wartość",
"placeholder": "np. 1,5 lub 1 1/2",
"invalid": "Wpisz liczbę większą od 0 i nie większą niż 100, na przykład 1,5 lub 1 1/2.",
"preview": "Liczy się jako {value} na dawkę"
}
},
"charts": {
@@ -4760,7 +4783,9 @@
"done": "{name} został dodany.",
"doneUnnamed": "Twój pierwszy lek został dodany.",
"nextDose": "Następna dawka: {when}.",
"reminders": "Przypomnienia podążają za ustawionym harmonogramem."
"reminders": "Przypomnienia podążają za ustawionym harmonogramem.",
"recordOnly": "Tylko zapis: bez przypomnień, nic nie przypada.",
"asNeeded": "W razie potrzeby: bez przypomnień, zapisz dawkę, gdy ją przyjmiesz."
},
"log-reading": {
"title": "Wpisz jeden pomiar: {target}",
@@ -7169,7 +7194,7 @@
"uploadTitle": "Prześlij kopię zapasową",
"uploadDescription": "Sprawdź plik kopii zapasowej i zapisz go jako ręczną migawkę. Przywracanie to osobny krok.",
"uploadButton": "Prześlij plik",
"uploadHelp": "Plik JSON zgodny z bieżącym schematem kopii zapasowej. Maks. 10 MB.",
"uploadHelp": "Kopia zapasowa HealthLog w formacie .json lub .json.gz. Plik jest kompresowany przed wysłaniem; po kompresji może mieć do 512 MB.",
"uploadSuccess": "Przesłano kopię zapasową ({count} rekordów)",
"uploadFailed": "Przesyłanie nie powiodło się",
"restore": "Przywróć",
@@ -7244,7 +7269,39 @@
"unknownDetail": "Żaden nocny przebieg nie zarejestrował jeszcze tego konta.",
"age": "{hours} h temu",
"scheduleNote": "Wysyłka działa co {hours} h. Świeża znaczy w obrębie jednego przebiegu, zaległa że jeden nic nie dał, przeterminowana że dwa."
}
},
"restoreStarted": "Przywracanie rozpoczęte. Postęp widać nad listą.",
"restoreActive": "Dla tego konta trwa już przywracanie. Poczekaj, aż się skończy.",
"restoreJobQueuedTitle": "Przywracanie {username} czeka na start",
"restoreJobQueuedNote": "Zacznie się, gdy tylko proces w tle je przejmie.",
"restoreJobRunningTitle": "Przywracanie {username}",
"restoreJobRunningNote": "Konto pokazuje dotychczasowe dane, dopóki przywracanie się nie zakończy. Możesz opuścić tę stronę; przywracanie trwa dalej na serwerze.",
"restoreJobResumed": "Uruchomione ponownie po restarcie serwera.",
"restoreJobPhaseValidating": "Sprawdzanie pliku kopii: sprawdzono {checked} pomiarów",
"restoreJobPhaseClearing": "Usuwanie bieżących danych konta",
"restoreJobPhaseMeasurements": "Zapisywanie pomiarów: {done} z {total}",
"restoreJobPhaseSections": "Zapisywanie pozostałych wpisów: {done} z {total} sekcji",
"restoreJobPhaseRebuilding": "Przeliczanie wykresów i podsumowań",
"restoreJobSucceededTitle": "Przywracanie {username} zakończone",
"restoreJobSucceededDescription": "Przywrócono {count} wpisów. Zakończono {when}.",
"restoreJobFailedTitle": "Przywracanie {username} nie zostało ukończone",
"restoreJobFailedKept": "Nic nie zostało zmienione. Kopia zostaje, więc możesz ponownie uruchomić przywracanie z jej wiersza bez ponownego wgrywania.",
"restoreFailureNotFound": "Kopia została usunięta, zanim przywracanie zdążyło ją odczytać.",
"restoreFailureChanged": "Kopia została zastąpiona nowszą, gdy przywracanie czekało. Sprawdź nową kopię, a potem uruchom przywracanie ponownie.",
"restoreFailureUndecryptable": "Nie udało się odszyfrować kopii. Klucz, którym ją zapisano, nie jest już skonfigurowany albo zapisana kopia została zmieniona.",
"restoreFailureSchema": "Plik nie jest prawidłową kopią HealthLog.",
"restoreFailureVersion": "Kopię zapisała nowsza wersja HealthLog. Najpierw zaktualizuj ten serwer.",
"restoreFailureOwnerMismatch": "Plik w kopii należy do innego konta niż wiersz kopii.",
"restoreFailureOwnerMissing": "Konto, do którego należy ta kopia, już nie istnieje.",
"restoreFailureSectionMissing": "Plik jest niekompletny: jego manifest wymienia sekcję, której nie zawiera.",
"restoreFailureDocument": "Plik zawiera dokumenty bez ich treści, jak eksport przenośny. Użyj kopii do odtwarzania po awarii.",
"restoreFailureTimeBudget": "Ta kopia jest za duża, by przywrócić ją w limicie czasu procesu w tle.",
"restoreFailureTransaction": "Baza danych odrzuciła przywracanie w trakcie, więc wszystko wycofano. Dziennik serwera podaje błąd.",
"restoreFailureInterrupted": "Przywracanie zatrzymało się przy restarcie serwera i zostało wycofane. Jeśli zatrzyma się znowu, serwerowi prawdopodobnie brakuje pamięci.",
"restoreFailureNotStarted": "Proces w tle nie przejął przywracania. Sprawdź, czy działa.",
"restoreFailureEnqueue": "Nie udało się przekazać przywracania do procesu w tle. Sprawdź, czy działa.",
"restoreFailureAfterCommit": "Dane zostały przywrócone, ale krok po nich się nie powiódł. Wykresy mogą nadrobić to dopiero przy następnym nocnym przebiegu.",
"restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd."
},
"danger-zone": {
"title": "Strefa zagrożenia",
@@ -8233,7 +8290,9 @@
"flowSpotting": "plamienie",
"flowLight": "lekkie",
"flowMedium": "średnie",
"flowHeavy": "obfite"
"flowHeavy": "obfite",
"legendIntercourse": "Współżycie",
"legendOtherEntries": "Inne wpisy"
},
"predictions": {
"title": "Co dalej",
@@ -10393,6 +10452,7 @@
"scopeCount": "{count} pozycji z {groups} obszarów",
"scopeSensitive": "w tym {names}",
"leafVisits": "Wizyty lekarskie",
"leafSurgicalHistory": "Historia operacji",
"leafImmunizations": "Szczepienia",
"leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)"
},
@@ -10777,8 +10837,16 @@
"emergency": "Nagły przypadek",
"hospital": "Szpital",
"therapy": "Sesja terapeutyczna",
"other": "Wizyta lekarska"
"other": "Wizyta lekarska",
"procedure": "Zabieg lub operacja"
},
"laterality": {
"left": "Lewa strona",
"right": "Prawa strona",
"both": "Obie strony",
"none": "Nie podano"
},
"bodySiteWithSide": "{site} · {side}",
"sectionTitle": "Wizyty",
"sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.",
"add": "Wizyta",
@@ -10820,6 +10888,9 @@
"reason": "Powód",
"outcome": "Wynik",
"outcomeReveal": "Dodaj wynik",
"bodySite": "Miejsce na ciele",
"bodySiteHint": "Na przykład kolano lub pęcherzyk żółciowy.",
"laterality": "Strona",
"linkDocuments": "Dokumenty",
"linkLabResults": "Wyniki badań",
"linkConditions": "Choroby",
@@ -10839,12 +10910,28 @@
},
"dashboard": {
"nextVisit": "Najbliższa wizyta: {what}, {when}"
},
"procedures": {
"title": "Zabiegi i operacje",
"description": "Wszystkie zabiegi i operacje, które przeszedłeś, od najnowszych.",
"add": "Zabieg",
"searchLabel": "Filtruj według miejsca na ciele",
"searchPlaceholder": "Na przykład kolano",
"sitesLabel": "Miejsca na ciele",
"allSites": "Wszystkie",
"count": "{shown} z {total}",
"emptyTitle": "Nie zapisano jeszcze żadnego zabiegu",
"emptyDescription": "Zapisz wizytę rodzaju Zabieg lub operacja albo zmień rodzaj istniejącej wizyty.",
"noMatchTitle": "Nic nie pasuje do tego filtra",
"clearFilter": "Wyczyść filtr",
"loadError": "Nie udało się wczytać zabiegów."
}
},
"checkups": {
"viewSwitchLabel": "Pokaż badania profilaktyczne lub wizyty",
"viewSwitchLabel": "Pokaż badania profilaktyczne, wizyty lub zabiegi",
"tabVorsorge": "Profilaktyka",
"tabVisits": "Wizyty"
"tabVisits": "Wizyty",
"tabProcedures": "Zabiegi"
},
"practitioners": {
"title": "Gabinety i lekarze",
@@ -10880,9 +10967,10 @@
"selectAll": "Zaznacz wszystko",
"searchPlaceholder": "Szukaj",
"searchNoMatch": "Brak wyników",
"remove": "Usuń",
"suggested": "Wokół tej daty",
"loadError": "Nie udało się wczytać listy."
"loadError": "Nie udało się wczytać listy.",
"removeNamed": "Usuń {name}",
"removed": "Odłączono: {name}"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.39.0",
"version": "1.39.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,48 @@
-- Backup restores run as a background job.
--
-- A restore used to run inside the request that asked for it. On a large
-- account that request outlived a reverse proxy's 60-second limit, so the
-- operator saw an error while the restore went on to finish. The request now
-- writes one of these rows and answers 202; the worker records progress and
-- the outcome here. Additive: nothing existing changes.
-- CreateTable
CREATE TABLE "backup_restore_jobs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"actor_user_id" TEXT NOT NULL,
"backup_id" TEXT NOT NULL,
"backup_digest" TEXT NOT NULL,
"restore_instance_settings" BOOLEAN NOT NULL DEFAULT false,
"pg_boss_job_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'queued',
"phase" TEXT,
"progress" JSONB NOT NULL DEFAULT '{}',
"result" JSONB,
"failure" JSONB,
"attempts" INTEGER NOT NULL DEFAULT 0,
"committed_at" TIMESTAMP(3),
"heartbeat_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "backup_restore_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "backup_restore_jobs_user_id_created_at_idx" ON "backup_restore_jobs"("user_id", "created_at");
-- CreateIndex
CREATE INDEX "backup_restore_jobs_status_heartbeat_at_idx" ON "backup_restore_jobs"("status", "heartbeat_at");
-- One queued or running restore per account. A second request for the same
-- account is refused with 409 by the insert this index rejects, so two
-- concurrent requests cannot both pass a read-then-write check.
CREATE UNIQUE INDEX "backup_restore_jobs_one_active_per_user"
ON "backup_restore_jobs" ("user_id")
WHERE "status" IN ('queued', 'running');
-- AddForeignKey
ALTER TABLE "backup_restore_jobs" ADD CONSTRAINT "backup_restore_jobs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
-- Procedure and surgery history on visits.
--
-- A visit can now be marked as a procedure or surgery (`PROCEDURE` kind) and
-- carry the body site it was done on: free text, encrypted at rest like the
-- visit's reason and outcome, plus an optional side. The reasoning for a kind
-- rather than a flag, and for searching the site by decrypting server-side
-- rather than storing a plaintext copy, is written at the `Encounter` model.
--
-- Purely additive. `ADD VALUE IF NOT EXISTS` makes a rerun safe, and the new
-- value is not used elsewhere in this migration, which is the one thing
-- Postgres forbids inside the transaction that adds it. No backfill: an
-- existing visit keeps its kind until the person switches it, which is an
-- ordinary edit. Both columns are nullable, so no row is rewritten.
--
-- Reversibility: Postgres cannot drop an enum value, so `PROCEDURE` would stay
-- (inert with no rows); the two columns and the `laterality` type can be
-- dropped.
ALTER TYPE "encounter_kind" ADD VALUE IF NOT EXISTS 'PROCEDURE';
CREATE TYPE "laterality" AS ENUM ('LEFT', 'RIGHT', 'BOTH');
ALTER TABLE "encounters"
ADD COLUMN "body_site_encrypted" BYTEA,
ADD COLUMN "laterality" "laterality";
@@ -0,0 +1,27 @@
-- The today projector mints a pending placeholder for every slot of the
-- current day, and the hourly auto-miss pass used to stamp every stale one a
-- miss. For a medication added in the afternoon that turned the placeholders
-- of that day's earlier slots, which were never expected doses, into
-- forgotten doses: the miss-free streak broke and the Coach read a miss on a
-- day before the medication existed. The pass no longer stamps them; this
-- returns the ones it already stamped to pending.
--
-- Only rows that can be nothing but such a placeholder are touched: minted by
-- the server (source REMINDER), never acted on (no take, no skip, live), and
-- anchored before the medication's creation but on its creation day. The
-- projector mints for the current local day only, which begins less than a
-- day before the creation, so the 24-hour bound is exact for placeholders and
-- leaves any older auto-miss (history carried in by a restore) as it was.
-- `sync_version` bumps so sync clients pick the change up.
UPDATE "medication_intake_events" AS e
SET "auto_missed" = false,
"sync_version" = e."sync_version" + 1
FROM "medications" AS m
WHERE e."medication_id" = m."id"
AND e."auto_missed" = true
AND e."taken_at" IS NULL
AND e."skipped" = false
AND e."deleted_at" IS NULL
AND e."source" = 'REMINDER'
AND e."scheduled_for" < m."created_at"
AND e."scheduled_for" >= m."created_at" - INTERVAL '24 hours';
@@ -0,0 +1,6 @@
-- Per-medication intake tracking (#1033). A medication with tracking off
-- keeps its dose, dates and schedule as a record, but nothing is due, no
-- reminder is sent and it is left out of adherence. Every existing row
-- defaults to tracking on, so nothing changes for anyone who does not flip it.
ALTER TABLE "medications"
ADD COLUMN "track_intake" BOOLEAN NOT NULL DEFAULT true;
+200 -42
View File
@@ -750,6 +750,7 @@ model User {
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
dataBackups DataBackup[]
backupRestoreJobs BackupRestoreJob[]
offhostBackupState OffhostBackupState?
achievements UserAchievement[]
recommendationFeedback RecommendationFeedback[]
@@ -983,7 +984,7 @@ model User {
// record. 1:1, and keyed by record rather than by the person at the
// keyboard so a guardian running a managed profile's setup writes the
// child's row, not their own. See `OnboardingRecord`.
onboardingRecord OnboardingRecord?
onboardingRecord OnboardingRecord?
// v1.15.20 — user-authored "about me" context for the AI surfaces
// (1:1, encrypted free text). See `UserHealthProfile`.
@@ -2609,6 +2610,25 @@ model Medication {
/// never reminded" surface follows structurally from the empty
/// schedule list. Mutually exclusive with `oneShot`.
asNeeded Boolean @default(false) @map("as_needed")
/// v1.39.1 (#1033) — per-medication intake tracking. `true` (the default,
/// and the backfill for every existing row) is the behaviour every
/// medication has always had. `false` keeps the medication as a record:
/// dose, dates and the schedule rows stay stored and shown as information,
/// but nothing derives a due dose from them — no reminder on any channel,
/// no projected or auto-missed slot, no place on the doses card or the
/// take-all flow, and no adherence figure anywhere (compliance, heatmap,
/// doctor report, Coach, BP-status gate, achievements, MCP). Unlike
/// `asNeeded` the schedule rows survive, so every schedule reader that
/// derives a due dose must honour this flag explicitly
/// (`src/lib/medications/intake-tracking.ts`); the structural guard
/// `src/__tests__/medication-intake-tracking-guard.test.ts` holds the list.
/// The medication wire serves such a medication with `schedules: []` and
/// the stored rows under `recordedSchedules`, so a client that arms local
/// reminders from `schedules` stays silent without an update. Switching
/// tracking off or back on archives a schedule revision at that instant
/// (the stretch in between archives with an empty payload), so the rate
/// paths never count the untracked stretch as missed.
trackIntake Boolean @default(true) @map("track_intake")
/// v1.6.0 — route of administration. Decoupled from `treatmentClass`
/// so the injection-site picker can surface for any INJECTION dose.
/// A one-time injection is `oneShot = true` + `deliveryForm =
@@ -4711,14 +4731,14 @@ model AppSettings {
// id or an unreachable host all parse perfectly and deliver nothing.
/// Instant the last report left successfully. Consumer: the GlitchTip card's
/// badge, which is only green while this is inside the recent window.
glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at")
glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at")
/// Instant the last attempt failed. Consumer: the GlitchTip card's status
/// line, which names the failure rather than showing a reassuring badge.
glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at")
glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at")
/// Short classification of that failure (`invalid_dsn`, `http_403`,
/// `network`) — never a response body, which is a remote host's text.
/// Consumer: the same status line.
glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason")
glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason")
// Medication reminder thresholds (minutes after windowEnd)
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
@@ -4957,6 +4977,101 @@ model DataBackup {
@@map("data_backups")
}
// ─── Backup restore jobs (v1.39.1) ──────────────────────────
//
// A restore of a stored backup runs on the `backup-restore` pg-boss queue, not
// inside the request that asked for it: rebuilding an account of 1.25 million
// readings takes about a minute and a half, and a reverse proxy that cuts a
// request at 60 s showed the operator an error for a restore that went on to
// finish. The request checks the copy opens, writes one of these rows and
// answers 202; the worker runs the restore and writes its progress and its
// outcome here, and `GET /api/admin/backups/restores/{id}` reads them back.
//
// The staged file is the `DataBackup` row the restore reads. It is already
// encrypted at rest and stays where it is whatever the outcome, so a failed
// restore is retried from the same row without uploading anything again.
//
// At most one queued or running job per account: the partial unique index
// `backup_restore_jobs_one_active_per_user` (migration 0347, raw SQL because
// Prisma cannot express a partial index) is what makes a second request a 409
// rather than a race.
//
// Status state machine:
// queued → running → succeeded
// ↘ failed
// running (worker gone) → queued again at the next worker boot, at most
// twice, then failed.
model BackupRestoreJob {
id String @id @default(cuid())
/// The account the restore replaces: the backup's owner, never the admin.
/// Consumer: the admission check, the worker, and the status routes.
userId String @map("user_id")
/// The admin who asked for the restore. A plain column rather than a
/// relation, so deleting that admin later does not erase the record of who
/// replaced this account's data. Consumer: the worker's audit rows.
actorUserId String @map("actor_user_id")
/// The `DataBackup` row being restored. A plain column: the backup may be
/// deleted while the job waits, which the worker reports as a failure rather
/// than a cascade deleting the job. Consumer: the worker and the status
/// routes (the console marks the row being restored).
backupId String @map("backup_id")
/// SHA-256 of `DataBackup.data` when the restore was requested. The weekly
/// job overwrites its row in place, so the worker checks the copy it reads is
/// the one the operator previewed and confirmed. Consumer: the worker.
backupDigest String @map("backup_digest")
/// The operator's answer to "also restore the instance settings". Consumer:
/// the worker, and the status routes.
restoreInstanceSettings Boolean @default(false) @map("restore_instance_settings")
/// `pg-boss` job id of the latest delivery. Consumer: the boot sweep, which
/// completes a delivery whose worker is gone so it does not later surface as
/// a timed-out job.
pgBossJobId String? @map("pg_boss_job_id")
/// `queued | running | succeeded | failed`. Consumer: every reader.
status String @default("queued")
/// Where a running restore is: `validating | clearing | measurements |
/// sections | rebuilding`. NULL while queued and once finished. Consumer:
/// the status routes.
phase String?
/// Counts the worker writes as it goes (`RestoreProgress` in
/// `src/lib/export/restore-backup.ts`). Consumer: the status routes.
progress Json @default("{}")
/// On success: what the restore wrote, cleared and skipped. Counts and
/// catalogue keys only, the same facts the audit row carries. Consumer: the
/// status routes.
result Json?
/// On failure: a stable `code`, a sentence an operator can act on, and the
/// missing sections when that was the reason. Consumer: the status routes.
failure Json?
/// When the restore transaction committed: from here on the account holds
/// the restored data, whatever happens to the steps after it. Written and
/// awaited before the rollup rebuild starts. Consumer: the boot sweep and
/// the admission check, which never queue a committed job again (a second
/// run would delete what the account gained since) and close it as
/// `failed_after_commit` instead; and the worker's own failure message.
committedAt DateTime? @map("committed_at")
/// How many times a worker has started this job. Consumer: the boot sweep,
/// which stops re-queueing after the second attempt.
attempts Int @default(0)
/// Bumped every few seconds while a worker runs the job. A running row whose
/// heartbeat has gone stale belongs to a worker that is gone. Consumer: the
/// admission check and the boot sweep.
heartbeatAt DateTime? @map("heartbeat_at")
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// `backup_restore_jobs_one_active_per_user` is a partial UNIQUE index over
// `(user_id) WHERE status IN ('queued', 'running')` — see migration 0347.
// Prisma can't express a partial index in the schema language, so it lives
// in raw SQL and is intentionally omitted here.
@@index([userId, createdAt])
@@index([status, heartbeatAt])
@@map("backup_restore_jobs")
}
// ─── Off-host backup ledger ──────────────────────────────────
//
// One row per account, written by the nightly off-host job the moment an
@@ -5338,7 +5453,7 @@ model MeasurementReminder {
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Back-relation for the appointment arm: a PLANNED `Encounter` mints
/// exactly one ENCOUNTER-origin row and points at it. SetNull on the far
/// side, so deleting the reminder leaves the visit intact.
@@ -5622,15 +5737,15 @@ model CustomMetric {
/// snapshot of the metric's unit at write time (historical truth, mirroring the
/// LabResult per-row unit). Charts read these LIVE (no rollup tier).
model CustomMetricEntry {
id String @id @default(cuid())
userId String @map("user_id")
customMetricId String @map("custom_metric_id")
id String @id @default(cuid())
userId String @map("user_id")
customMetricId String @map("custom_metric_id")
value Float
/// Unit snapshot from the metric at write time.
unit String
measuredAt DateTime @map("measured_at")
measuredAt DateTime @map("measured_at")
note String?
createdAt DateTime @default(now()) @map("created_at")
createdAt DateTime @default(now()) @map("created_at")
/// v1.37.20 — soft-delete tombstone, peer parity with Measurement /
/// MoodEntry (every other entry surface deletes recoverably with an undo
/// toast; custom-metric entries were the one hard delete). NULL = live.
@@ -6138,28 +6253,28 @@ model CoachReminder {
// must never land in the table as plaintext. A cleared profile keeps the
// row with a NULL payload so `updatedAt` documents the deletion instant.
model UserHealthProfile {
id String @id @default(cuid())
userId String @unique @map("user_id")
id String @id @default(cuid())
userId String @unique @map("user_id")
// Free text, hard-capped at 4 000 chars by the route's Zod schema
// BEFORE encryption. NULL = cleared / never written.
aboutMeEncrypted Bytes? @map("about_me_encrypted")
aboutMeEncrypted Bytes? @map("about_me_encrypted")
// v1.16.0 — structured self-context companions to the free text.
// Short answers (each capped at 500 chars BEFORE encryption).
// Age/gender deliberately do NOT live here — they come from the User
// profile and are merged into the prompt block at assembly time so
// the data is never duplicated.
conditionsEncrypted Bytes? @map("conditions_encrypted")
allergiesEncrypted Bytes? @map("allergies_encrypted")
coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
conditionsEncrypted Bytes? @map("conditions_encrypted")
allergiesEncrypted Bytes? @map("allergies_encrypted")
coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
// v1.16.0 — up to 3 clarifying questions the server derives after a
// save (AI-generated when a provider + budget allow, deterministic
// hints otherwise). Encrypted JSON string[]; the Coach composer
// renders them as tappable suggestion chips. NULL = none pending.
pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
// Closed inclusion list for self-context sent to Coach and briefing prompts.
// The static default preserves every pre-existing prompt section for legacy
// rows without automatically opting future enum members in.
aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
// Emergency ("Notfalldaten") facts, surfaced on page one of the doctor
// report and edited in Settings -> Anamnese. Three closed-set enums are
// plaintext (read directly by the report, like `User.gender`); three free
@@ -6167,14 +6282,14 @@ model UserHealthProfile {
// `*Encrypted` column. All six are nullable: an unset emergency profile is
// absent, not empty. The report page is withheld unless the EMERGENCY leaf is
// admitted AND at least one of these holds a value.
emergencyBloodType EmergencyBloodType? @map("emergency_blood_type")
organDonorStatus OrganDonorStatus? @map("organ_donor_status")
advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status")
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
emergencyBloodType EmergencyBloodType? @map("emergency_blood_type")
organDonorStatus OrganDonorStatus? @map("organ_donor_status")
advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status")
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -6285,10 +6400,10 @@ model HealthProfileFactRevision {
// secondary signal used by the frontend to render a "≈ N replies left"
// hint without exposing raw token figures.
model CoachUsage {
id String @id @default(cuid())
userId String @map("user_id")
dateKey String @map("date_key")
totalTokens Int @default(0) @map("total_tokens")
id String @id @default(cuid())
userId String @map("user_id")
dateKey String @map("date_key")
totalTokens Int @default(0) @map("total_tokens")
/// v1.38.19 — the share of `totalTokens` served by a provider the
/// OPERATOR funds (`admin-openai` / `admin-codex`). The operator-cost cap is
/// enforced against this counter and the user-plan cap against
@@ -8119,12 +8234,12 @@ model NutrientIntakeDay {
/// relocated to the link pickers, which follow the modules that own the
/// things they point at.
model Encounter {
id String @id @default(cuid())
userId String @map("user_id")
id String @id @default(cuid())
userId String @map("user_id")
/// The visit instant (UTC; wall-clock anchoring is the reader's
/// concern, per the schema's UTC-only convention). Future instants are
/// legal — that is the whole appointment half.
occurredAt DateTime @map("occurred_at")
occurredAt DateTime @map("occurred_at")
/// Where in its lifecycle. PLANNED is a future appointment; DONE is a
/// visit that happened; CANCELLED and NO_SHOW are terminal and keep
/// the row so the history stays honest.
@@ -8138,27 +8253,49 @@ model Encounter {
/// `FHIR_REST_RESOURCE_TYPES` (`src/lib/fhir/rest.ts`) is deliberately
/// untouched — a capability statement that names a type with no route
/// module lies.
status EncounterStatus @default(DONE)
kind EncounterKind @default(OTHER)
practitionerId String? @map("practitioner_id")
status EncounterStatus @default(DONE)
kind EncounterKind @default(OTHER)
practitionerId String? @map("practitioner_id")
/// The person's own framing of why they went or are going. Encrypted
/// at rest (AES-256-GCM, the `*Encrypted` Bytes convention).
reasonEncrypted Bytes? @map("reason_encrypted")
reasonEncrypted Bytes? @map("reason_encrypted")
/// What came out of it — plan, follow-up, what to watch. Same codec.
/// One field rather than a treatment/follow-up pair whose boundary
/// nobody can state: two columns would be two chances to put the text
/// in the wrong one, and the actionable half of a follow-up is not
/// prose at all, it is the next appointment.
outcomeEncrypted Bytes? @map("outcome_encrypted")
outcomeEncrypted Bytes? @map("outcome_encrypted")
/// Where on the body a procedure was done, in the person's own words
/// ("knee", "gallbladder", "L4/L5"). Free text, because no picker covers
/// everything a surgeon operates on, and a coded vocabulary the person
/// cannot read is worse than their own word. Health data, so encrypted
/// like `reasonEncrypted`.
///
/// Searching it does not need plaintext at rest: the procedures read
/// (`src/lib/encounters/procedures.ts`) decrypts the account's own
/// procedures server-side and matches there. That set is a person's
/// surgical history — tens of rows, not thousands — so a decrypt pass
/// per search is cheap, and a plaintext or hashed search column would be
/// a second copy of the most identifying field on the row.
///
/// Allowed on any kind, surfaced for PROCEDURE: switching a visit's kind
/// away and back must not lose what was typed.
bodySiteEncrypted Bytes? @map("body_site_encrypted")
/// Which side, when the site has one. Separate from the text so "left
/// knee" and "right knee" group and filter as the same site on two sides
/// without parsing prose, and plaintext because a side alone says
/// nothing. NULL means not stated or not applicable, deliberately one
/// value rather than a NONE beside NULL that would mean the same.
laterality Laterality?
/// Optional link to the reminder this visit closes. Set when the user
/// files the visit from a due checkup, or minted here when the visit
/// is PLANNED. Satisfying the reminder is the side effect, not this
/// column.
reminderId String? @map("reminder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
reminderId String? @map("reminder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull)
@@ -8189,6 +8326,16 @@ enum EncounterStatus {
/// What kind of contact it was. Defaulted to OTHER so it is never a
/// required pick, and small enough to stay readable at a glance in a
/// list. Maps to FHIR `Encounter.class` at the export boundary.
///
/// PROCEDURE (v1.39.1) marks a surgery or procedure. A kind rather than a
/// boolean beside the kind, because the kind already answers "what was
/// this contact" and a flag would make HOSPITAL-plus-flag and PROCEDURE two
/// spellings of one visit with no rule for which to use. The cost is that a
/// surgery done in an emergency is filed as PROCEDURE and not EMERGENCY; the
/// operation is the fact a surgical history asks for, and the reason text
/// keeps the rest. Additive for every client in the field: the shipped iOS
/// app does not decode visits at all, and the web client ships with the
/// server.
enum EncounterKind {
ROUTINE
ACUTE
@@ -8198,10 +8345,21 @@ enum EncounterKind {
HOSPITAL
THERAPY
OTHER
PROCEDURE
@@map("encounter_kind")
}
/// The side of the body a procedure's site is on. See
/// `Encounter.laterality`.
enum Laterality {
LEFT
RIGHT
BOTH
@@map("laterality")
}
/// The account's own address book of doctors and practices.
///
/// User-scoped, and the scoping is the point: a global registry with an
+1 -1
View File
@@ -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.39.0";
/* @sw-version-fallback */ "v1.39.1";
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/*`
+42 -16
View File
@@ -15,12 +15,23 @@
* BACKUP_S3_REGION=auto \
* BACKUP_ENCRYPTION_KEY=<hex64> \
* pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
*
* An output name ending in `.gz` writes the JSON compressed, which is the
* form to hand to the admin upload for a large account: the JSON of 1.25
* million measurements is 662 MB, the compressed file about a tenth of it.
* The file is written as a stream in either case and never held whole.
*/
import { writeFileSync } from "node:fs";
import { createWriteStream } from "node:fs";
import { rename } from "node:fs/promises";
import { PassThrough, Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
import { scanBackupJson } from "@/lib/export/backup-json-scan";
import {
loadOffhostConfig,
getS3Client,
decryptBackup,
openBackupObject,
OffhostBackupNotConfiguredError,
} from "@/lib/jobs/offhost-backup";
@@ -98,28 +109,43 @@ async function main() {
`Downloading s3://${cfg.bucket}/${key} from ${cfg.endpoint} (region=${cfg.region})`,
);
const ciphertext = await s3.getObject(key);
const plaintext = decryptBackup(ciphertext, cfg.encryptionKey);
const source = openBackupObject(ciphertext, cfg.encryptionKey);
let parsed: unknown;
try {
parsed = JSON.parse(plaintext);
} catch (err) {
throw new Error(
`Decrypted payload is not valid JSON: ${(err as Error).message}`,
);
}
const body = validateBackupShape(parsed);
// Checked first, in one streamed pass that keeps only the small sections,
// so nothing is written for an object that is not a backup.
const { document, streamedCounts } = await scanBackupJson(source(), {
streamKeys: new Set(["measurements", "intakeEvents", "moodEntries"]),
});
const body = validateBackupShape({
...document,
measurements: [],
intakeEvents: [],
moodEntries: [],
});
if (expectedUserId && body.userId !== expectedUserId) {
throw new Error(
`Refusing to write: backup.userId='${body.userId}' does not match --user-id='${expectedUserId}'`,
);
}
// The restored file is decrypted PHI in plaintext. Write it 0o600 so it is
// not world-readable on the operator's disk while it sits there.
writeFileSync(out, plaintext, { encoding: "utf8", mode: 0o600 });
// The restored file is decrypted PHI in plaintext. Written 0o600 so it is
// not world-readable on the operator's disk, to a temporary name first so
// an interrupted run never leaves a truncated file under the real one.
const partial = `${out}.part`;
let bytes = 0;
const counter = new PassThrough();
counter.on("data", (chunk: Buffer) => {
bytes += chunk.length;
});
await pipeline(
Readable.from(source()),
counter,
...(out.endsWith(".gz") ? [createGzip()] : []),
createWriteStream(partial, { mode: 0o600 }),
);
await rename(partial, out);
console.log(
`Restored userId=${body.userId} (${plaintext.length} bytes) -> ${out}`,
`Restored userId=${body.userId} (${bytes} bytes of JSON, ${streamedCounts.measurements ?? 0} measurements) -> ${out}`,
);
}
+66 -26
View File
@@ -108,6 +108,50 @@ function shouldRotate(value: string | null): boolean {
return id !== getActiveKeyId();
}
/** The part of a Prisma delegate the per-column walks below need. */
interface PagedDelegate<V> {
findMany: (args: {
select: Record<string, true>;
orderBy: { id: "asc" };
take: number;
where?: { id: { gt: string } };
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: { id: string };
data: Record<string, V>;
}) => Promise<unknown>;
}
/** Rows per page when walking a column. */
const PAGE_SIZE = 5_000;
/**
* Every row of one column, `id` and value only, a page at a time in id order.
* The walks used to read a column in one `findMany`, which for
* `Measurement.notesEncrypted` is every measurement of the instance: 1.25
* million rows on one large account (#1031).
*/
async function* pagedRows(
delegate: PagedDelegate<unknown>,
field: string,
): AsyncGenerator<Record<string, unknown>> {
let cursor: string | null = null;
for (;;) {
const rows = await delegate.findMany({
select: { id: true, [field]: true },
orderBy: { id: "asc" },
take: PAGE_SIZE,
// `id > last`, not Prisma's `cursor` + `skip`: the cursor form looks
// the last row up again, and a row deleted between pages ends the walk.
...(cursor ? { where: { id: { gt: cursor } } } : {}),
});
if (rows.length === 0) return;
yield* rows;
if (rows.length < PAGE_SIZE) return;
cursor = rows[rows.length - 1]!.id as string;
}
}
/**
* Rotate one `String` ciphertext column on a Prisma model. `delegate` is the
* `prisma.<model>` accessor; `field` is the column. Reads only `id` + the
@@ -116,31 +160,28 @@ function shouldRotate(value: string | null): boolean {
async function rotateStringColumn(
table: string,
field: string,
delegate: {
findMany: (args: {
select: Record<string, true>;
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: { id: string };
data: Record<string, string>;
}) => Promise<unknown>;
},
delegate: PagedDelegate<string>,
): Promise<RotationResult> {
const rows = await delegate.findMany({ select: { id: true, [field]: true } });
const result: RotationResult = {
table,
field,
scanned: rows.length,
scanned: 0,
rotated: 0,
errors: 0,
dropped: 0,
};
for (const row of rows) {
for await (const row of pagedRows(
delegate as PagedDelegate<unknown>,
field,
)) {
const v = row[field] as string | null;
// `scanned` counts rows that hold ciphertext, as the in-app rotation does.
if (!v) continue;
result.scanned++;
if (!shouldRotate(v)) continue;
const id = row.id as string;
try {
const re = encrypt(decrypt(v as string));
const re = encrypt(decrypt(v));
await delegate.update({ where: { id }, data: { [field]: re } });
result.rotated++;
} catch (err) {
@@ -159,28 +200,23 @@ async function rotateStringColumn(
async function rotateBytesColumn(
table: string,
field: string,
delegate: {
findMany: (args: {
select: Record<string, true>;
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: { id: string };
data: Record<string, Uint8Array>;
}) => Promise<unknown>;
},
delegate: PagedDelegate<Uint8Array>,
): Promise<RotationResult> {
const rows = await delegate.findMany({ select: { id: true, [field]: true } });
const result: RotationResult = {
table,
field,
scanned: rows.length,
scanned: 0,
rotated: 0,
errors: 0,
dropped: 0,
};
for (const row of rows) {
for await (const row of pagedRows(
delegate as PagedDelegate<unknown>,
field,
)) {
const buf = row[field] as Uint8Array | null;
if (!buf || buf.byteLength === 0) continue;
result.scanned++;
const asString = Buffer.from(buf).toString("utf8");
if (!shouldRotate(asString)) continue;
const id = row.id as string;
@@ -625,6 +661,10 @@ async function main() {
results.push(
await rotateBytesColumn("Encounter", "outcomeEncrypted", prisma.encounter),
);
// v1.39.1 — a procedure's body site.
results.push(
await rotateBytesColumn("Encounter", "bodySiteEncrypted", prisma.encounter),
);
results.push(
await rotateBytesColumn(
"Practitioner",
@@ -747,7 +787,7 @@ async function main() {
select: { id: true, textEncrypted: true, verbatimTextEncrypted: true },
orderBy: { id: "asc" },
take: 100,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
...(cursor ? { where: { id: { gt: cursor } } } : {}),
});
if (rows.length === 0) break;
for (const row of rows) {
+296
View File
@@ -0,0 +1,296 @@
/**
* Seed one account shaped like a multi-year Apple Health `export.xml` import,
* for reproducing the maintenance jobs at the size a real heavy account
* reaches (issue #1031: about 1.26 million measurement rows).
*
* DATABASE_URL=postgresql://… pnpm dlx tsx scripts/seed-large-account.ts \
* [--user bench-large] [--years 4] [--pulse-seconds 165]
*
* Never point this at a database that holds real data: it inserts rows under
* the given user id and, with `--reset`, deletes that user first.
*
* The shape follows what the import and the iOS sync actually write:
*
* - dense spot rows (heart rate every `--pulse-seconds`, HRV, SpO2) keyed by
* a hashed sample id, the tier the dense-intraday retention folds;
* - high-frequency mean rows (respiratory rate, gait metrics, audio
* exposure), the tier the daily-mean consolidation folds;
* - one `stats:` daily row per cumulative type and day (what the export
* import writes for steps, energy, distance and flights);
* - per-sample cumulative rows with opaque sample ids for the most recent
* 90 days (what the iOS sync writes, and what the cumulative drain folds);
* - sleep-stage segments every night.
*
* Everything is generated inside Postgres with `generate_series`, so a million
* rows take seconds rather than a million round trips. `ANALYZE` runs at the
* end: a planner reading pre-seed statistics picks plans no production table
* of this size would get.
*/
import { prisma } from "@/lib/db";
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
const USER_ID = arg("user", "bench-large");
const YEARS = Number(arg("years", "4"));
const PULSE_SECONDS = Number(arg("pulse-seconds", "165"));
const RESET = process.argv.includes("--reset");
/** A spot series: `perDay` evenly spaced rows per day over the whole span. */
interface SpotSeries {
type: string;
unit: string;
perDay: number;
base: number;
spread: number;
/** Days back from now the series covers. Defaults to the whole span. */
days?: number;
/** externalId prefix; `stats:` rows are generated separately. */
idPrefix: string;
}
async function insertSpot(s: SpotSeries, days: number): Promise<number> {
const step = Math.floor(86_400 / s.perDay);
const count = Math.floor((days * 86_400) / step);
// Jitter stays below the step so every measured_at is distinct per type
// (the (user, type, measured_at, source, sleep_stage) unique index).
const jitter = Math.max(1, Math.min(59, step - 1));
return prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
SELECT
'seed-' || $1 || '-' || $2 || '-' || g,
$1,
$2::measurement_type,
round(($3 + $4 * sin(g / 37.0) + ($4 / 2) * ((g * 7919) % 100) / 100.0)::numeric, 2),
$5,
'APPLE_HEALTH',
date_trunc('minute', now()) - make_interval(secs => $6 * $7::bigint)
+ make_interval(secs => g * $6 + (g * 31) % $8),
$9 || md5($2 || g::text),
now(), now()
FROM generate_series(0, $7::bigint - 1) AS g
`,
USER_ID,
s.type,
s.base,
s.spread,
s.unit,
step,
count,
jitter,
s.idPrefix,
);
}
async function main(): Promise<void> {
const days = Math.round(YEARS * 365);
if (RESET) {
await prisma.$executeRawUnsafe(`DELETE FROM users WHERE id = $1`, USER_ID);
}
await prisma.$executeRawUnsafe(
`INSERT INTO users (id, username, timezone, updated_at)
VALUES ($1, $1, 'Europe/Berlin', now())
ON CONFLICT (id) DO NOTHING`,
USER_ID,
);
const pulsePerDay = Math.floor(86_400 / PULSE_SECONDS);
const spot: SpotSeries[] = [
{
type: "PULSE",
unit: "bpm",
perDay: pulsePerDay,
base: 62,
spread: 25,
idPrefix: "hk-",
},
{
type: "HEART_RATE_VARIABILITY",
unit: "ms",
perDay: 12,
base: 45,
spread: 20,
idPrefix: "hk-",
},
{
type: "OXYGEN_SATURATION",
unit: "%",
perDay: 24,
base: 96,
spread: 2,
idPrefix: "hk-",
},
{
type: "RESPIRATORY_RATE",
unit: "breaths/min",
perDay: 48,
base: 14,
spread: 3,
idPrefix: "hk-",
},
{
type: "WALKING_SPEED",
unit: "km/h",
perDay: 30,
base: 4.8,
spread: 0.8,
idPrefix: "hk-",
},
{
type: "WALKING_STEP_LENGTH",
unit: "cm",
perDay: 30,
base: 72,
spread: 6,
idPrefix: "hk-",
},
{
type: "WALKING_ASYMMETRY",
unit: "%",
perDay: 30,
base: 3,
spread: 2,
idPrefix: "hk-",
},
{
type: "WALKING_DOUBLE_SUPPORT",
unit: "%",
perDay: 30,
base: 28,
spread: 3,
idPrefix: "hk-",
},
{
type: "AUDIO_EXPOSURE_ENV",
unit: "dB",
perDay: 60,
base: 62,
spread: 12,
idPrefix: "hk-",
},
// The iOS sync's per-sample cumulative rows: dense, recent, opaque ids.
{
type: "ACTIVITY_STEPS",
unit: "steps",
perDay: 100,
base: 90,
spread: 60,
days: 90,
idPrefix: "uuid-",
},
{
type: "ACTIVE_ENERGY_BURNED",
unit: "kcal",
perDay: 400,
base: 1.2,
spread: 0.9,
days: 90,
idPrefix: "uuid-",
},
{
type: "WALKING_RUNNING_DISTANCE",
unit: "m",
perDay: 100,
base: 70,
spread: 50,
days: 90,
idPrefix: "uuid-",
},
{
type: "WEIGHT",
unit: "kg",
perDay: 1,
base: 82,
spread: 1.5,
idPrefix: "hk-",
},
];
let total = 0;
for (const s of spot) {
const n = await insertSpot(s, s.days ?? days);
total += n;
console.log(`${s.type.padEnd(28)} ${String(n).padStart(9)}`);
}
// One export-estimate `stats:` row per cumulative type and local day, at
// local noon — what `flushCumulativeBuckets` in the export import writes.
for (const [type, hk, unit, base] of [
["ACTIVITY_STEPS", "HKQuantityTypeIdentifierStepCount", "steps", 8000],
[
"ACTIVE_ENERGY_BURNED",
"HKQuantityTypeIdentifierActiveEnergyBurned",
"kcal",
550,
],
[
"WALKING_RUNNING_DISTANCE",
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"m",
6000,
],
["FLIGHTS_CLIMBED", "HKQuantityTypeIdentifierFlightsClimbed", "count", 9],
] as const) {
const n = await prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id,
aggregation_provenance, created_at, updated_at)
SELECT
'seed-' || $1 || '-' || $2 || '-stats-' || d,
$1, $2::measurement_type, $5 + (d * 7919) % ($5 / 2), $4, 'APPLE_HEALTH',
((current_date - d)::timestamp + interval '12 hours')
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC',
'stats:' || $3 || ':' || to_char(current_date - d, 'YYYY-MM-DD'),
'EXPORT_XML_SOURCE_MAX', now(), now()
FROM generate_series(91, $6::int) AS d
`,
USER_ID,
type,
hk,
unit,
base,
days,
);
total += n;
console.log(`${`${type} (stats:)`.padEnd(28)} ${String(n).padStart(9)}`);
}
// Sleep: 30 segments a night from 23:00 local, 16 minutes apart, rotating
// through the stages. Distinct measured_at per segment.
const sleep = await prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id,
sleep_stage, created_at, updated_at)
SELECT
'seed-' || $1 || '-sleep-' || d || '-' || s,
$1, 'SLEEP_DURATION', 16, 'minutes', 'APPLE_HEALTH',
((current_date - d)::timestamp - interval '1 hour' + s * interval '16 minutes')
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC',
'hk-' || md5('sleep' || d || '-' || s),
(ARRAY['CORE','DEEP','CORE','REM','AWAKE','CORE']::sleep_stage[])[1 + s % 6],
now(), now()
FROM generate_series(1, $2::int) AS d, generate_series(0, 29) AS s
`,
USER_ID,
days,
);
total += sleep;
console.log(`${"SLEEP_DURATION".padEnd(28)} ${String(sleep).padStart(9)}`);
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
console.log(`${"total".padEnd(28)} ${String(total).padStart(9)}`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
@@ -321,7 +321,7 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
it("does not count a nested relation read as a restore write", () => {
expect(
touches(
["src/app/api/admin/backups/[id]/restore/route.ts"],
["src/lib/export/restore-backup.ts"],
"User",
WRITE_OPS,
WRITE_RELATION_OPS,
@@ -0,0 +1,45 @@
/**
* Every reader of a stored or uploaded backup reads it as a stream (#1031).
*
* A backup's JSON grows with the record, and past 536 870 888 characters it
* cannot exist as one JavaScript string at all: the disaster-recovery JSON of
* an account with 1.25 million measurements is 662 MB. Each of the readers
* below used to turn the file into one string first, and so none of them
* could open that account's backup. This pins that none of them goes back to
* it: no whole-document decrypt-to-string, no whole-body text read.
*/
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const READERS = [
"src/lib/export/restore-backup.ts",
"src/app/api/admin/backups/[id]/summary/route.ts",
"src/app/api/admin/backups/[id]/download/route.ts",
"src/app/api/admin/backups/upload/route.ts",
"src/lib/jobs/restore-drill.ts",
"scripts/restore-backup.ts",
] as const;
/** Calls that hand back the whole document as one string or parse it whole. */
const WHOLE_DOCUMENT = [
/\bunpackBackupBlob\s*\(/,
/\bdecryptBackup\s*\(/,
/\bJSON\.parse\s*\(\s*plaintext\b/,
/\bparseBackupPayload\s*\(\s*plaintext\b/,
/\bfile\.text\s*\(/,
/\bawait\s+request\.text\s*\(\s*\)[\s\S]{0,200}parseBackupPayload/,
];
describe("backup readers stream the file", () => {
it.each(READERS)("%s never holds the whole document", (path) => {
const source = readFileSync(path, "utf8");
for (const pattern of WHOLE_DOCUMENT) {
expect(source, `${path} matches ${pattern}`).not.toMatch(pattern);
}
});
it.each(READERS)("%s reads through the streaming reader", (path) => {
const source = readFileSync(path, "utf8");
expect(source).toMatch(/\b(readStreamedBackup|scanBackupJson)\b/);
});
});
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(94);
expect(ALL_LEAF_IDS.length).toBe(95);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(17);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18);
});
it("places every leaf in exactly one group", () => {
@@ -114,7 +114,7 @@ const WRITERS: Record<string, Disposition> = {
},
// ── Restore ───────────────────────────────────────────────────────────
"app/api/admin/backups/[id]/restore/route.ts": {
"lib/export/restore-backup.ts": {
kind: "restore",
why: "replays the record's own export; dropping rows would break the restore contract",
},
@@ -130,6 +130,22 @@ const WRITERS: Record<string, Disposition> = {
const MEASUREMENT_WRITE =
/[A-Za-z_$][\w$]*\s*\.\s*measurement\s*\.\s*(create|createMany|createManyAndReturn|upsert)\s*\(/;
/**
* A call into the column-array bulk insert, which writes around the Prisma
* delegate and so never matches {@link MEASUREMENT_WRITE}. The Apple export
* importer writes its spot rows only this way, and was invisible to this
* guard for as long as nothing looked for it. The definitions themselves are
* excluded by the lookbehind; the module that holds them is pinned below as
* the one place raw SQL may insert a measurement.
*/
const BULK_INSERT_CALL = /(?<!function\s)\binsert(?:New)?MeasurementRows\s*\(/;
/** Raw SQL that inserts into the measurements table. */
const RAW_MEASUREMENT_INSERT = /INSERT\s+INTO\s+"?measurements"?[\s(]/i;
/** The only module allowed to hold {@link RAW_MEASUREMENT_INSERT}. */
const BULK_INSERT_MODULE = "lib/export/measurement-bulk-insert.ts";
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
@@ -143,7 +159,10 @@ function read(rel: string): string {
}
function discoveredWriters(): string[] {
return sourceFiles().filter((rel) => MEASUREMENT_WRITE.test(read(rel)));
return sourceFiles().filter((rel) => {
const text = read(rel);
return MEASUREMENT_WRITE.test(text) || BULK_INSERT_CALL.test(text);
});
}
describe("every measurement writer declares how it meets the plausibility domain", () => {
@@ -158,6 +177,21 @@ describe("every measurement writer declares how it meets the plausibility domain
expect(discoveredWriters()).toEqual(Object.keys(WRITERS).sort());
});
it("sees a writer that only calls the bulk insert", () => {
// The importer has no Prisma measurement write left; if the bulk-insert
// matcher stopped matching, it would drop out of the sweep silently.
const importer = "lib/measurements/import-apple-health-export.ts";
expect(MEASUREMENT_WRITE.test(read(importer))).toBe(false);
expect(discoveredWriters()).toContain(importer);
});
it("raw SQL inserts measurements in the bulk-insert module and nowhere else", () => {
const raw = sourceFiles().filter((rel) =>
RAW_MEASUREMENT_INSERT.test(read(rel)),
);
expect(raw).toEqual([BULK_INSERT_MODULE]);
});
it("every provider-fed writer reaches the gate module", () => {
const gated = Object.entries(WRITERS)
.filter(([, d]) => d.kind === "gate")
@@ -0,0 +1,265 @@
/**
* Structural guard — every reader of medication schedules honours the
* per-medication intake-tracking switch (#1033).
*
* An as-needed medication is "never due, never reminded, never counted"
* structurally: it carries no schedule rows, so every reader that expands
* schedules into slots finds nothing to expand. A medication with intake
* tracking off (`Medication.trackIntake = false`) keeps its schedule rows as a
* record. Nothing about the rows themselves says "do not derive a due dose
* from me", so the promise holds only as long as every reader asks. A new
* reminder path, compliance surface or projection that reads `schedules` and
* forgets the flag would quietly start reminding a person who asked not to be
* reminded.
*
* So the set of schedule readers is frozen here. A file that reads medication
* schedules through Prisma either uses the shared vocabulary in
* `src/lib/medications/intake-tracking.ts` (or `expectsDoses`, whose required
* `trackIntake` field makes the type checker enforce the same thing), or it is
* on the allowlist below with the reason it may see every stored row.
*
* Limits, stated plainly. The matcher is textual: it finds a Prisma
* `schedules: true` / `schedules: { … }` include or select and the
* `medicationSchedule.find*` delegates. A schedule read spelt some other way
* (raw SQL on `medication_schedules`, a helper that returns rows it read
* elsewhere) is invisible to it. It proves a file names the vocabulary, not
* that the named helper is applied to the right query. The behavioural tests
* beside each consumer carry that half.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** A Prisma read (or write payload) that touches medication schedule rows. */
const SCHEDULE_READ =
/\bschedules\s*:\s*(?:true\b|\{)|\bmedicationSchedule\s*\.\s*find(?:Many|First|Unique)\b/;
/** The vocabulary that honours the switch. */
const HONOURS =
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses|isRecordOnly)\b/;
/**
* A bulk read of intake rows. Rate readers that never touch a schedule (a
* nudge that divides taken by resolved rows, a model series that buckets
* rows per day) count a record-only medication's rows unless they filter.
* Whitespace-tolerant: the delegate and the method may sit on two lines.
*/
const INTAKE_READ =
/\bmedicationIntakeEvent\s*\.\s*(?:findMany|groupBy|count|aggregate)\s*\(/;
/**
* Intake readers that may see every medication's rows, and why: nothing in
* them is an adherence rate, an expected-dose count or a due dose.
*/
const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
"app/api/admin/status/route.ts": "instance row counts",
"app/api/dashboard/summary/route.ts":
"engagement streak of days with any logged action, no rate",
"app/api/export/medications/route.ts": "export of the recorded history",
"app/api/export/route.ts": "export of the recorded history",
"lib/export/full-backup-payload.ts": "backup of the recorded history",
"app/api/sync/state/route.ts": "sync watermarks, no rate",
"lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge",
"lib/ai/coach/tools/availability.ts":
"whether any intake history exists, and its date range",
"app/api/medications/[id]/intake/route.ts":
"one medication's recorded history, listed or written",
"app/api/medications/[id]/intake/bulk-delete/route.ts":
"deletes rows the person chose",
"app/api/medications/[id]/route.ts":
"one medication's slots; tombstones on the tracking switch",
"app/api/medications/intake/bulk/route.ts": "writes recorded doses",
"lib/medications/scheduling/slot-upsert.ts":
"binds a recorded dose to its slot",
"lib/medications/scheduling/worker-helpers.ts":
"slot lookups for one medication the caller already selected",
"lib/medications/inventory/consumption.ts":
"units consumed by a recorded dose",
"lib/medications/intake-slot-dedup.ts":
"folds duplicate rows of doses already recorded",
"lib/doctor-report/collect.ts":
"administration ledger lists recorded doses; rates gate via expectsDoses",
};
/**
* Readers that may see every stored schedule row, and why. Each reason says
* why no due dose, reminder or adherence figure can come out of the file.
*/
const MAY_READ_EVERY_ROW: Record<string, string> = {
// Record-keeping: a backup, a restore and an export carry the medication
// as stored, schedule and switch included.
"lib/export/full-backup-payload.ts": "backup carries the stored rows",
"lib/export/restore-backup.ts": "restore writes the stored rows back",
"app/api/export/route.ts": "data export of the stored record",
"app/api/export/medications/route.ts": "medication CSV export of the record",
// Intake WRITE helpers: they run only when a dose is being recorded, bind
// it to the slot it belongs to and consume stock. They derive nothing due.
"lib/medications/scheduling/slot-upsert.ts":
"binds a recorded dose to its slot",
"lib/medications/inventory/consumption.ts":
"units consumed by a recorded dose",
"lib/medications/intake-slot-dedup.ts":
"folds duplicate rows of doses already recorded",
// Readers that hand the rows to a builder whose input TYPE requires
// `trackIntake` and which gates on it: the per-medication compliance
// payload (`buildCompliancePayload`, INTAKE_NOT_TRACKED) and the doctor
// report's ledger compliance (`expectsDoses`). The report's medication
// list shows the stored schedule as information.
"app/api/medications/[id]/compliance/route.ts":
"buildCompliancePayload gates on the type-required trackIntake",
"lib/doctor-report/collect.ts":
"buildLedgerCompliance gates via expectsDoses; list is information",
// The MCP medication resources list the stored schedule as information,
// with `intakeTracked` beside it; they carry no due time or rate.
"lib/mcp/resources.ts":
"schedule as information, intakeTracked flag beside it",
// The Coach's GLP-1 block reads the stored cadence as information and
// gates its next-injection prediction on the flag inline.
"lib/ai/coach/glp1-snapshot.ts":
"cadence as information; prediction gated on trackIntake",
};
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
/** Drop line and block comments so a mention in prose never counts. */
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
function code(rel: string): string {
return stripComments(readFileSync(join(SRC, rel), "utf8"));
}
/**
* The code without its import statements. An import of the vocabulary is
* not a use of it: a reader that still imports `TRACKED_INTAKE_WHERE` after
* its filter was deleted would otherwise pass.
*/
function usage(rel: string): string {
return code(rel).replace(/^import[\s\S]*?from\s+["'][^"']+["'];?$/gm, "");
}
const readers = sourceFiles().filter((rel) => SCHEDULE_READ.test(code(rel)));
describe("medication intake tracking — schedule readers honour the switch", () => {
it("finds the schedule readers it exists to police", () => {
// An empty match set would agree with any allowlist. Pin the floor and
// the readers whose silence would matter most.
expect(readers.length).toBeGreaterThanOrEqual(30);
for (const known of [
"lib/jobs/reminder/medication-reminder-check.ts",
"lib/medications/scheduling/project-today-intakes.ts",
"lib/dashboard/meds-today.ts",
"lib/medications/list-read.ts",
"lib/jobs/intake-auto-skip.ts",
]) {
expect(readers).toContain(known);
}
});
it("every schedule reader uses the vocabulary or is allowlisted", () => {
const offenders = readers.filter(
(rel) => !(rel in MAY_READ_EVERY_ROW) && !HONOURS.test(usage(rel)),
);
expect(
offenders,
"These files read medication schedules without honouring " +
"Medication.trackIntake. Filter with TRACKED_INTAKE_WHERE, derive " +
"from dueSchedules(), shape the wire with scheduleWireFields(), or " +
"add the file to MAY_READ_EVERY_ROW with the reason it derives " +
"nothing due.",
).toEqual([]);
});
it("the allowlist names no file that stopped reading schedules", () => {
const stale = Object.keys(MAY_READ_EVERY_ROW).filter(
(rel) => !readers.includes(rel),
);
expect(stale).toEqual([]);
});
it("the matchers catch the shapes they claim to", () => {
// Defeat check: the guard must be able to fail.
expect(SCHEDULE_READ.test("include: { schedules: true }")).toBe(true);
expect(SCHEDULE_READ.test("schedules: {\n select: X }")).toBe(true);
expect(
SCHEDULE_READ.test("prisma.medicationSchedule\n .findMany({})"),
).toBe(true);
expect(SCHEDULE_READ.test("const schedules = m.schedules;")).toBe(false);
expect(HONOURS.test(stripComments("// dueSchedules(m)"))).toBe(false);
expect(HONOURS.test("where: { ...TRACKED_INTAKE_WHERE }")).toBe(true);
// An import alone is not a use.
const importOnly =
'import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";\n' +
"const rows = await prisma.medication.findMany({ where: { active: true } });";
expect(
HONOURS.test(
importOnly.replace(/^import[\s\S]*?from\s+["'][^"']+["'];?$/gm, ""),
),
).toBe(false);
});
});
const intakeReaders = sourceFiles().filter((rel) =>
INTAKE_READ.test(code(rel)),
);
describe("medication intake tracking — intake-row readers honour the switch", () => {
it("finds the intake readers it exists to police", () => {
expect(intakeReaders.length).toBeGreaterThanOrEqual(30);
for (const known of [
"lib/jobs/coach-nudge.ts",
"lib/insights/general-status.ts",
"lib/analytics/correlations-fast-path.ts",
"lib/medications/outstanding-doses.ts",
]) {
expect(intakeReaders).toContain(known);
}
});
it("every intake reader uses the vocabulary or is allowlisted", () => {
const offenders = intakeReaders.filter(
(rel) => !(rel in INTAKE_MAY_SEE_EVERY_ROW) && !HONOURS.test(usage(rel)),
);
expect(
offenders,
"These files read intake rows without honouring Medication.trackIntake. " +
"Filter with TRACKED_INTAKE_EVENT_WHERE (or read only the ids of " +
"medications filtered with TRACKED_INTAKE_WHERE), or add the file to " +
"INTAKE_MAY_SEE_EVERY_ROW with the reason it computes no rate.",
).toEqual([]);
});
it("the intake allowlist names no file that stopped reading intake rows", () => {
expect(
Object.keys(INTAKE_MAY_SEE_EVERY_ROW).filter(
(rel) => !intakeReaders.includes(rel),
),
).toEqual([]);
});
it("the intake matcher catches the shapes it claims to", () => {
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.findMany({")).toBe(
true,
);
expect(
INTAKE_READ.test("prisma.medicationIntakeEvent\n .groupBy({"),
).toBe(true);
expect(INTAKE_READ.test("tx.medicationIntakeEvent . count (")).toBe(true);
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.create({")).toBe(
false,
);
});
});
@@ -0,0 +1,34 @@
/**
* The key-rotation walks page by `id > last`, never by Prisma's cursor.
*
* `cursor: { id }, skip: 1` looks the last row of a page up again. When that
* row was hard-deleted between pages, Prisma answers the next page with
* nothing, the walk stops early, and the scan reports a corpus smaller than
* the one on disk, which can read as "nothing left on the old key". The
* library walk is proven against Postgres in
* `tests/integration/encryption-corpus-cursor-row-deleted.test.ts`; the
* rotation script calls `process.exit` on import and cannot be driven from a
* test, so its walks are held to the same shape here.
*
* Mutation check: put `cursor: { id: cursor }, skip: 1` back in either file
* and this goes red.
*/
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const WALKS = [
"scripts/rotate-encryption-key.ts",
"src/lib/crypto/encryption-corpus.ts",
] as const;
describe("rotation walks page by key", () => {
it.each(WALKS)("%s pages with `id > last`, not cursor + skip", (path) => {
const code = readFileSync(path, "utf8")
.split("\n")
.filter((line) => !line.trim().startsWith("//"))
.join("\n");
expect(code).not.toMatch(/cursor\s*:\s*\{[^}]*\}\s*,\s*skip\s*:/);
// Non-zero proof: the walk is still there and still pages by key.
expect(code).toMatch(/\{\s*gt\s*:\s*cursor\s*\}/);
});
});
@@ -13,7 +13,7 @@
* is `STRUCTURED_LEAF_GROUP`, the other is HTML rendered by the real
* `<ClinicianView>` over a real fixture. It proves four things per leaf.
*
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so an eighteenth
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so a nineteenth
* structured leaf cannot be added to the catalogue without deciding here
* whether it renders — and saying so — or `pnpm typecheck` fails. That is
* the lock, and it is the compiler's, not this suite's.
@@ -332,6 +332,22 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
},
marker: "Palpitations",
},
SURGICAL_HISTORY: {
kind: "rendered",
data: {
surgicalHistory: [
{
occurredAt: "2011-03-14T09:00:00.000Z",
procedure: "Meniscus repair",
bodySite: "Knee",
laterality: "LEFT",
outcome: null,
practitionerName: null,
},
],
},
marker: "Meniscus repair",
},
IMMUNIZATIONS: {
kind: "rendered",
data: {
@@ -419,7 +435,7 @@ describe("clinician view — every catalogue leaf has a renderer or a reason", (
it("reads a plausible catalogue", () => {
// Sanity floor: a degraded import would satisfy every loop below
// vacuously, which is the failure mode a pair guard dies of.
expect(STRUCTURED_LEAF_IDS).toHaveLength(17);
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
expect(Object.keys(DISPOSITIONS).sort()).toEqual(
[...STRUCTURED_LEAF_IDS].sort(),
);
+8 -1
View File
@@ -943,6 +943,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
},
"app/api/encounters/procedures/route.ts": {
domain: "profile",
why: "The record's procedure and surgery history: the visits of kind PROCEDURE that happened, searched by body site. The same rows the visit list serves, read one way, so it sits under the same domain and level; the search runs over the resolved user's own rows after the decrypt and cannot address anyone else's.",
},
"app/api/encounters/suggest/route.ts": {
domain: "profile",
why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
@@ -1945,8 +1949,11 @@ const ACTOR_ROUTES: Record<string, string> = {
* The screener detail read adds one: the per-administration read that
* decrypts the stored item answers, on the record list at the same level as
* the screener history beside it. 229 -> 230.
*
* v1.39.1 -- the procedure history adds one: a read on the record list at the
* level and domain of the visit list it is a view of. 230 -> 231.
*/
const FROZEN_ENTRY_COUNT = 230;
const FROZEN_ENTRY_COUNT = 231;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -248,6 +248,9 @@ const PINNED_AFFORDANCES: Record<
"src/components/medications/sections/inventory-dialogs.tsx": {
"toast.success": 3,
},
"src/components/medications/sections/intake-tracking-section.tsx": {
"toast.success": 1,
},
"src/components/medications/sections/inventory-section.tsx": {
"toast.success": 1,
},
@@ -0,0 +1,253 @@
/**
* v1.39.1 — every wire change in this release is additive for the iPhone app
* already in people's hands (1.0.3 in the App Store, 1.0.4 on TestFlight).
*
* What changed on shapes those builds decode:
*
* - `AppleHealthBatchEntryResult` gained `convertedValue` and `range`, on a
* `value_out_of_range` skip only;
* - the batch validation 422 gained `meta.errorCode`;
* - `ProfileResponse.timezone` now holds the resolved zone (same type);
* - the nutrient and cycle batch responses gained a `Cache-Control` header
* on one outcome, and the nutrient entry's description changed.
*
* Two directions are pinned, because "additive" is a claim about both:
*
* 1. A payload the new server sends still decodes with a decoder written
* against the old shape. The decoders below are that: the fields the
* shipped client reads non-optionally, with their types, and nothing
* else — unknown keys are ignored, as Swift's `Decodable` ignores them.
* 2. A payload of the old shape still satisfies the new document: no field
* a shipped client might omit or never see became required.
*
* The document is built from the registry rather than read off disk;
* `openapi:check` already pins the YAML to exactly this build.
*
* ## Mutation check
*
* Drop `.optional()` from `convertedValue` or `range` in the registry and the
* `required` assertion goes red. Rename a field the old decoder reads and the
* decode assertion goes red.
*/
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
type Schema = {
type?: string | string[];
properties?: Record<string, Schema>;
required?: string[];
additionalProperties?: boolean | Schema;
$ref?: string;
anyOf?: Schema[];
};
const doc = buildOpenApiDocument() as unknown as {
components: { schemas: Record<string, Schema> };
};
const schemas = doc.components.schemas;
function component(name: string): Schema {
const found = schemas[name];
if (!found) throw new Error(`component ${name} is missing`);
return found;
}
// ── The shapes as 1.0.3 decodes them ────────────────────────────────────
const oldBatchEntry = z.object({
index: z.number().int(),
status: z.string(),
reason: z.string().optional(),
});
const oldBatchResponse = z.object({
data: z.object({
processed: z.number(),
inserted: z.number(),
updated: z.number(),
duplicates: z.number(),
failed: z.number(),
skipped: z.array(z.object({ index: z.number(), reason: z.string() })),
entries: z.array(oldBatchEntry),
}),
});
const oldErrorEnvelope = z.object({
data: z.null(),
error: z.string(),
});
const oldProfile = z.object({
data: z.object({
username: z.string(),
email: z.string().nullable(),
timezone: z.string(),
timeFormat: z.string(),
dateFormat: z.string(),
moodReminderEnabled: z.boolean(),
}),
});
const oldNutrientResponse = z.object({
data: z.object({
processed: z.number(),
inserted: z.number(),
updated: z.number(),
skipped: z.array(z.object({ index: z.number(), reason: z.string() })),
entries: z.array(
z.object({
index: z.number(),
status: z.string(),
reason: z.string().optional(),
}),
),
}),
});
// ── Fixtures ────────────────────────────────────────────────────────────
/** A v1.39.0 batch answer: no range detail anywhere. */
const OLD_BATCH = {
data: {
processed: 2,
inserted: 1,
updated: 0,
duplicates: 0,
failed: 0,
skipped: [{ index: 1, reason: "value_out_of_range" }],
entries: [
{ index: 0, status: "inserted" },
{ index: 1, status: "skipped", reason: "value_out_of_range" },
],
},
error: null,
};
/** The same batch answered by v1.39.1. */
const NEW_BATCH = {
data: {
...OLD_BATCH.data,
entries: [
{ index: 0, status: "inserted" },
{
index: 1,
status: "skipped",
reason: "value_out_of_range",
convertedValue: 90,
range: { min: 1, max: 80, unit: "%" },
},
],
},
error: null,
};
const NEW_BATCH_422 = {
data: null,
error: "Invalid input: expected string, received undefined",
details: {
issues: [{ path: ["entries", 0, "unit"], message: "Required" }],
},
meta: { errorCode: "measurement.batch.invalid" },
};
const NEW_PROFILE = {
data: {
username: "someone",
displayName: null,
email: null,
dateOfBirth: null,
gender: null,
heightCm: null,
locale: null,
timezone: "Europe/Berlin",
timeFormat: "AUTO",
dateFormat: "AUTO",
moodReminderEnabled: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
modules: {},
},
error: null,
};
const NEW_NUTRIENTS = {
data: {
processed: 1,
inserted: 0,
updated: 0,
skipped: [{ index: 0, reason: "upsert_failed" }],
entries: [{ index: 0, status: "skipped", reason: "upsert_failed" }],
},
error: null,
};
describe("v1.39.1 — shipped decoders still decode what the server now sends", () => {
it("the batch answer, with the new range detail", () => {
expect(oldBatchResponse.safeParse(NEW_BATCH).success).toBe(true);
});
it("the batch validation 422, with its new code", () => {
expect(oldErrorEnvelope.safeParse(NEW_BATCH_422).success).toBe(true);
});
it("the profile, whose timezone is still a string", () => {
expect(oldProfile.safeParse(NEW_PROFILE).success).toBe(true);
});
it("the nutrient batch answer carrying upsert_failed", () => {
expect(oldNutrientResponse.safeParse(NEW_NUTRIENTS).success).toBe(true);
});
});
describe("v1.39.1 — the new document still admits the old shapes", () => {
it("adds convertedValue and range to the batch entry as optional fields", () => {
const entry = component("AppleHealthBatchEntryResult");
expect(entry.properties?.convertedValue?.type).toBe("number");
expect(
Object.keys(entry.properties?.range?.properties ?? {}).sort(),
).toEqual(["max", "min", "unit"]);
expect([...(entry.required ?? [])].sort()).toEqual(["index", "status"]);
});
it("an old batch entry carries every field the new document requires", () => {
const required = component("AppleHealthBatchEntryResult").required ?? [];
for (const e of OLD_BATCH.data.entries) {
for (const key of required) expect(e).toHaveProperty(key);
}
});
it("keeps the error envelope's meta optional", () => {
const envelope = component("ErrorEnvelope");
expect(envelope.required ?? []).not.toContain("meta");
expect(envelope.properties?.meta?.required ?? []).not.toContain(
"errorCode",
);
});
it("keeps the profile timezone a required string", () => {
const profile = component("ProfileResponse");
expect(profile.properties?.timezone?.type).toBe("string");
expect(profile.required).toContain("timezone");
});
it("keeps the nutrient entry result's shape", () => {
const entry = component("NutrientEntryResult");
expect(Object.keys(entry.properties ?? {}).sort()).toEqual([
"index",
"reason",
"status",
]);
expect([...(entry.required ?? [])].sort()).toEqual(["index", "status"]);
});
it("puts the named sleep-stage answer beside the old create response, not in place of it", () => {
const named = component("NamedSleepStageMeasurement");
expect(named.required).toContain("status");
const resource = component("MeasurementResource");
expect(resource.properties).not.toHaveProperty("status");
});
});
@@ -177,7 +177,7 @@ describe("T2 — the set of files writing Workout rows is frozen", () => {
"app/api/workouts/batch/route.ts": "client-supplied enum",
// Backup restore replays rows this app previously wrote, provenance and
// all.
"app/api/admin/backups/[id]/restore/route.ts": "restore replay",
"lib/export/restore-backup.ts": "restore replay",
};
it("only the registered ingests and the declared non-mappers create Workout rows", () => {
@@ -14,6 +14,8 @@
* at-rest contract: the ciphertext stays in the DB, the plaintext is only
* materialised inside the request handler and streamed to the admin.
*/
import { Readable } from "node:stream";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
@@ -22,8 +24,13 @@ import { auditLog } from "@/lib/auth/audit";
import {
BACKUP_UNDECRYPTABLE_CODE,
BACKUP_UNDECRYPTABLE_ERROR,
unpackBackupBlob,
openBackupBlob,
} from "@/lib/export/backup-blob";
import {
readStreamedBackup,
type BackupSource,
type StreamedBackup,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import { parseBackupPayload } from "@/lib/validations/backup";
@@ -54,9 +61,11 @@ export const GET = apiHandler(
throw new HttpError(404, "Backup not found");
}
let plaintext: string;
// Opened and streamed, never unpacked into one string: a large record's
// JSON is longer than any string V8 can hold (#1031).
let source: BackupSource;
try {
plaintext = unpackBackupBlob(backup.data);
source = openBackupBlob(backup.data);
} catch (err) {
// A rotated or dropped key, or a stored copy that is no longer the one
// that was written. Both are bad stored input rather than a fault in
@@ -80,8 +89,10 @@ export const GET = apiHandler(
// pathological case where the worker wrote something the upload
// route would later reject — better to fail loudly here than to ship
// a junk file that breaks an admin's restore plan.
let streamed: StreamedBackup;
try {
parseBackupPayload(plaintext);
streamed = await readStreamedBackup(source);
parseBackupPayload(streamed.raw);
} catch (err) {
await auditLog("admin.backups.download.failed", {
userId: admin.id,
@@ -107,22 +118,26 @@ export const GET = apiHandler(
ownerId: backup.userId,
ownerUsername: backup.user.username,
type: backup.type,
sizeBytes: Buffer.byteLength(plaintext, "utf8"),
sizeBytes: streamed.bytes,
},
});
// Returning the plaintext as-is keeps the response a faithful copy of
// Returning the stored bytes as-is keeps the response a faithful copy of
// what the worker wrote (formatting, key order). The file is the
// canonical artefact admins will store / re-upload.
return new NextResponse(plaintext, {
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"content-disposition": `attachment; filename="${filename}"`,
// Backups contain sensitive health data — keep them out of any
// shared cache (CDN, browser disk cache).
"cache-control": "no-store, max-age=0",
return new NextResponse(
Readable.toWeb(Readable.from(source())) as ReadableStream<Uint8Array>,
{
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"content-disposition": `attachment; filename="${filename}"`,
// Backups contain sensitive health data — keep them out of any
// shared cache (CDN, browser disk cache).
"cache-control": "no-store, max-age=0",
"content-length": String(streamed.bytes),
},
},
});
);
},
);
File diff suppressed because it is too large. Load diff
@@ -44,6 +44,11 @@ vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
isCompatibleSchemaVersion: vi.fn(() => true),
// The measurements are read as a stream and counted one by one.
BACKUP_SCHEMA_VERSION: "2",
backupMeasurementSchema: {
safeParse: (value: unknown) => ({ success: true, data: value }),
},
}));
import { GET } from "../route";
@@ -62,9 +67,14 @@ beforeEach(() => {
createdAt: new Date("2026-08-01T00:00:00Z"),
user: { id: "u1", username: "self-hoster" },
});
decryptMock.mockReturnValue("{}");
// Twelve measurements in the file: the preview counts them from the stream.
decryptMock.mockReturnValue(
JSON.stringify({
measurements: Array.from({ length: 12 }, (_, i) => ({ id: `m${i}` })),
}),
);
parseMock.mockReturnValue({ schemaVersion: 3 });
summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 });
summarizeMock.mockReturnValue({ measurements: 0, moodEntries: 3 });
});
describe("GET /api/admin/backups/[id]/summary", () => {
@@ -19,8 +19,12 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import {
BACKUP_UNDECRYPTABLE_CODE,
BACKUP_UNDECRYPTABLE_ERROR,
unpackBackupBlob,
openBackupBlob,
} from "@/lib/export/backup-blob";
import {
readStreamedBackup,
type BackupSource,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import {
isCompatibleSchemaVersion,
@@ -46,9 +50,11 @@ export const GET = apiHandler(
throw new HttpError(404, "Backup not found");
}
let plaintext: string;
// Opened and read as a stream: a large record's JSON is longer than any
// string V8 can hold (#1031).
let source: BackupSource;
try {
plaintext = unpackBackupBlob(backup.data);
source = openBackupBlob(backup.data);
} catch {
// Same refusal the restore and the download give, for the same reason:
// a copy this instance cannot open is bad stored input, and the preview
@@ -59,8 +65,11 @@ export const GET = apiHandler(
}
let payload;
let measurementCount: number;
try {
payload = parseBackupPayload(plaintext);
const streamed = await readStreamedBackup(source);
measurementCount = streamed.measurementCount;
payload = parseBackupPayload(streamed.raw);
} catch {
return apiError("Backup payload failed schema validation", 422);
}
@@ -72,7 +81,10 @@ export const GET = apiHandler(
);
}
const summary = summarizeBackup(payload);
const summary = {
...summarizeBackup(payload),
measurements: measurementCount,
};
annotate({
action: { name: "admin.backups.summary" },
@@ -0,0 +1,34 @@
/**
* GET /api/admin/backups/restores/[jobId] — how one restore is going.
*
* The status, the phase and the counts while it runs; what it wrote, cleared
* and skipped once it succeeded; a stable code and a sentence an operator can
* act on once it failed.
*/
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import {
evictAfterRestore,
readBackupRestoreJob,
} from "@/lib/jobs/backup-restore";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (
_request: Request,
{ params }: { params: Promise<{ jobId: string }> },
) => {
await requireAdmin();
const { jobId } = await params;
annotate({
action: { name: "admin.backups.restores.read" },
meta: { jobId },
});
const job = await readBackupRestoreJob(jobId);
if (!job) throw new HttpError(404, "Restore job not found");
evictAfterRestore(job);
return apiSuccess(job);
},
);
@@ -0,0 +1,25 @@
/**
* GET /api/admin/backups/restores — the restores the backups console shows.
*
* Every queued or running restore, and those that finished in the last day,
* newest first. The console reads it when it opens, so a reload during a
* restore picks the progress up again, and a restore that finished while
* nobody was looking still says how it ended.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import {
evictAfterRestore,
listRecentBackupRestoreJobs,
} from "@/lib/jobs/backup-restore";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.backups.restores.list" } });
const jobs = await listRecentBackupRestoreJobs();
for (const job of jobs) evictAfterRestore(job);
return apiSuccess({ jobs });
});
+291 -133
View File
@@ -1,17 +1,25 @@
/**
* POST /api/admin/backups/upload — admin-only ingest of a backup file.
*
* Accepts a `multipart/form-data` body with a single `file` field that
* carries a JSON backup matching `backupPayloadSchema`. The route:
* Accepts the backup file either as the raw request body (JSON, or JSON
* compressed with gzip) or, for a small file, as a `multipart/form-data`
* body with a single `file` field. The route:
*
* 1. Authenticates the caller as an admin (cookie session only).
* 2. Reads the file (size capped — see `MAX_UPLOAD_BYTES`).
* 3. Parses and validates the JSON against the canonical schema.
* 4. Rejects files written by a future schema version (incompatible).
* 5. Encrypts the plaintext and inserts a new `DataBackup` row of
* type `MANUAL_UPLOAD_<unix-ms>` so multiple uploads coexist with
* 5. Encrypts the file as it arrives and inserts a new `DataBackup` row
* of type `MANUAL_UPLOAD_<unix-ms>` so multiple uploads coexist with
* the rolling `WEEKLY_AUTO` snapshot AND with each other.
*
* The file is read as a stream and never held whole (#1031): an export of an
* account with 1.25 million measurements is a few hundred megabytes of JSON,
* which neither fits a 1 GB container beside the running app nor, in the
* disaster-recovery shape, fits in one JavaScript string at all. It is
* checked as it goes and stored in the same pass; a file that fails any check
* rolls the store back, so nothing of it remains.
*
* Crucially, this route does NOT execute a restore. It only stores the
* file. The separate `POST /api/admin/backups/[id]/restore` endpoint
* (criterion 3) actually replaces user data — keeping the two phases
@@ -20,46 +28,124 @@
*
* Phase B1 / criterion 2 of the v1.4.15 backup-completeness work.
*/
import { Readable } from "node:stream";
import { createGunzip } from "node:zlib";
import { NextRequest } from "next/server";
import { ZodError } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { packBackupBlob } from "@/lib/export/backup-blob";
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
import {
BACKUP_UPLOAD_TOO_LARGE_CODE,
BackupBlobTooLargeError,
} from "@/lib/export/backup-blob";
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
import { StreamedBackupInvalidError } from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
BACKUP_SCHEMA_VERSION,
backupMeasurementSchema,
backupPayloadSchema,
isCompatibleSchemaVersion,
summarizeBackup,
type BackupPayload,
type BackupSummary,
} from "@/lib/validations/backup";
export const dynamic = "force-dynamic";
/**
* Cap on uploaded file size. The largest production backups today are
* ~2 MB; 10 MB leaves headroom while keeping a malicious admin client
* from blowing memory by streaming a 4 GB file. Enforced by reading the
* `Content-Length` header AND the actual buffered size after decoding —
* a missing/wrong content-length doesn't bypass the limit.
* Cap on a file sent as a multipart form. The form parser holds the whole
* file in memory, so this path stays for small files only; anything larger
* is sent as the raw request body, which is read as a stream.
*/
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
/**
* Cap on a file sent as the raw request body, compressed or not. The same
* ceiling the app sets for any request body (`middlewareClientMaxBodySize` in
* `next.config.ts`), beyond which the body would arrive truncated. A
* disaster-recovery file of 1.25 million measurements is 662 MB as plain
* JSON and 64 MB compressed, so large files go up compressed.
*/
const MAX_RAW_UPLOAD_BYTES = 512 * 1024 * 1024;
interface UploadResponse {
id: string;
valid: true;
summary: BackupSummary;
}
/** A file the route refuses: the answer, and what the audit row records. */
class UploadRefused extends Error {
constructor(
readonly status: number,
readonly answer: string,
readonly details: Record<string, unknown>,
readonly meta?: Record<string, unknown>,
) {
super(answer);
this.name = "UploadRefused";
}
}
/** Bytes → bytes, gunzipped when the file starts with the gzip magic. */
async function* decodedBytes(
source: AsyncIterable<Uint8Array>,
limit: number,
): AsyncGenerator<Uint8Array> {
let seen = 0;
async function* counted() {
for await (const chunk of source) {
seen += chunk.byteLength;
if (seen > limit) {
throw new UploadRefused(
413,
`Upload exceeds ${Math.round(limit / 1024 / 1024)} MB limit`,
{ reason: "file_size_exceeded", size: seen },
);
}
yield chunk;
}
}
const iterator = counted()[Symbol.asyncIterator]();
const first = await iterator.next();
if (first.done) return;
const rest = (async function* () {
yield first.value;
for (;;) {
const next = await iterator.next();
if (next.done) return;
yield next.value;
}
})();
const head = first.value;
if (head.byteLength >= 2 && head[0] === 0x1f && head[1] === 0x8b) {
const gunzip = Readable.from(rest).pipe(createGunzip());
try {
for await (const chunk of gunzip) yield chunk as Buffer;
} catch (err) {
if (err instanceof UploadRefused) throw err;
throw new UploadRefused(422, "Uploaded file is not valid gzip", {
reason: "invalid_gzip",
});
}
return;
}
yield* rest;
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user: admin } = await requireAdmin();
annotate({ action: { name: "admin.backups.upload" } });
const ipAddress = getClientIp(request);
// Rate-limit identical to the manual-run endpoint. An admin uploading
// a 10 MB blob 60× per minute would just be wasteful — three is plenty
// for a deliberate restore-prep workflow.
// Rate-limit identical to the manual-run endpoint. Three is plenty for a
// deliberate restore-prep workflow.
const rl = await checkRateLimit(
`admin-backups-upload:${admin.id}`,
3,
@@ -69,120 +155,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
throw new HttpError(429, "Too many backup uploads");
}
// Cheap pre-flight on the declared content length. The actual buffer
// size check below is the hard limit; this just rejects obvious abuse
// before allocating the FormData parser.
const denied = (reason: string, extra: Record<string, unknown> = {}) =>
auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress,
details: { reason, ...extra },
});
const contentType = request.headers.get("content-type") ?? "";
const multipart = contentType.startsWith("multipart/form-data");
const limit = multipart ? MAX_UPLOAD_BYTES : MAX_RAW_UPLOAD_BYTES;
// Cheap pre-flight on the declared content length. The byte count while
// reading is the hard limit; this just rejects obvious abuse early.
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > MAX_UPLOAD_BYTES) {
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "content_length_exceeded", contentLength },
});
return apiError("Upload exceeds 10 MB limit", 413);
}
let formData: FormData;
try {
formData = await request.formData();
} catch (err) {
if (contentLength > limit) {
await denied("content_length_exceeded", { contentLength });
return apiError(
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
400,
`Upload exceeds ${Math.round(limit / 1024 / 1024)} MB limit`,
413,
);
}
const file = formData.get("file");
if (!(file instanceof File)) {
return apiError("Field 'file' must be a file", 422);
}
if (file.size > MAX_UPLOAD_BYTES) {
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "file_size_exceeded", size: file.size },
});
return apiError("Upload exceeds 10 MB limit", 413);
}
let plaintext: string;
try {
plaintext = await file.text();
} catch {
return apiError("Failed to read uploaded file", 400);
}
let parsedJson: unknown;
try {
parsedJson = JSON.parse(plaintext);
} catch (err) {
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
reason: "invalid_json",
message: err instanceof Error ? err.message : "parse_failed",
},
});
return apiError("Uploaded file is not valid JSON", 422);
}
let payload;
try {
payload = backupPayloadSchema.parse(parsedJson);
} catch (err) {
const issues =
err instanceof ZodError
? err.issues.slice(0, 10).map((i) => ({
path: i.path.join("."),
message: i.message,
}))
: [];
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "schema_invalid", issues },
});
return apiError("Backup payload failed schema validation", 422, {
issues,
});
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
reason: "incompatible_schema_version",
schemaVersion: payload.schemaVersion,
},
});
return apiError(
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
422,
let source: AsyncIterable<Uint8Array>;
if (multipart) {
let formData: FormData;
try {
formData = await request.formData();
} catch (err) {
return apiError(
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
400,
);
}
const file = formData.get("file");
if (!(file instanceof File)) {
return apiError("Field 'file' must be a file", 422);
}
if (file.size > MAX_UPLOAD_BYTES) {
await denied("file_size_exceeded", { size: file.size });
return apiError("Upload exceeds 10 MB limit", 413);
}
source = Readable.fromWeb(
file.stream() as Parameters<typeof Readable.fromWeb>[0],
);
}
// Make sure the userId on the file points at a user that exists in
// this DB. Restore would fail later anyway, but failing here gives
// the admin a precise error before any side-effect.
const owner = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, username: true },
});
if (!owner) {
await auditLog("admin.backups.upload.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
reason: "owner_not_found",
ownerId: payload.userId,
},
});
return apiError(
`Backup is for user '${payload.userId}' which does not exist in this DB`,
422,
} else {
if (!request.body) {
return apiError("The backup file is missing from the request body", 422);
}
source = Readable.fromWeb(
request.body as Parameters<typeof Readable.fromWeb>[0],
);
}
@@ -190,25 +212,161 @@ export const POST = apiHandler(async (request: NextRequest) => {
// intact so multiple uploads can coexist for the same user without
// overwriting each other or the rolling WEEKLY_AUTO snapshot.
const uploadType = `MANUAL_UPLOAD_${Date.now()}`;
const encrypted = packBackupBlob(JSON.stringify(payload));
let payload: BackupPayload | undefined;
let measurementCount = 0;
let owner: { id: string; username: string } | null = null;
const created = await prisma.dataBackup.create({
data: {
userId: owner.id,
type: uploadType,
data: encrypted,
},
});
let created: { id: string };
try {
created = await storeBackupBlob(
prisma,
() => ({ userId: owner!.id, type: uploadType }),
async (write) => {
// One pass: every chunk is stored as it is checked, and a check that
// fails throws, which rolls the store back.
async function* storedAsRead() {
for await (const chunk of decodedBytes(source, limit)) {
await write(Buffer.from(chunk));
yield chunk;
}
}
let firstWithoutId: number | null = null;
let scanned;
try {
scanned = await scanBackupJson(storedAsRead(), {
streamKeys: new Set(["measurements"]),
onElement: (_key, element, index) => {
const parsed = backupMeasurementSchema.safeParse(element);
if (!parsed.success) {
const issue = parsed.error.issues[0];
throw new StreamedBackupInvalidError(
["measurements", index, ...(issue?.path ?? [])].join("."),
issue?.message ?? "invalid measurement",
);
}
if (!parsed.data.id && firstWithoutId === null) {
firstWithoutId = index;
}
},
});
} catch (err) {
if (err instanceof BackupJsonError) {
throw new UploadRefused(422, "Uploaded file is not valid JSON", {
reason: "invalid_json",
message: err.message,
});
}
if (err instanceof StreamedBackupInvalidError) {
const issues = [{ path: err.path, message: err.message }];
throw new UploadRefused(
422,
"Backup payload failed schema validation",
{ reason: "schema_invalid", issues },
{ issues },
);
}
throw err;
}
measurementCount = scanned.streamedCounts.measurements ?? 0;
const summary = summarizeBackup(payload);
try {
payload = backupPayloadSchema.parse(scanned.document);
} catch (err) {
const issues =
err instanceof ZodError
? err.issues.slice(0, 10).map((i) => ({
path: i.path.join("."),
message: i.message,
}))
: [];
throw new UploadRefused(
422,
"Backup payload failed schema validation",
{ reason: "schema_invalid", issues },
{ issues },
);
}
if (
payload.schemaVersion === BACKUP_SCHEMA_VERSION &&
firstWithoutId !== null
) {
const issues = [
{
path: `measurements.${firstWithoutId}.id`,
message: "Canonical v2 measurements require a stable id",
},
];
throw new UploadRefused(
422,
"Backup payload failed schema validation",
{ reason: "schema_invalid", issues },
{ issues },
);
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
throw new UploadRefused(
422,
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
{
reason: "incompatible_schema_version",
schemaVersion: payload.schemaVersion,
},
);
}
// Make sure the userId on the file points at a user that exists in
// this DB. Restore would fail later anyway, but failing here gives
// the admin a precise error before any side-effect.
owner = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, username: true },
});
if (!owner) {
throw new UploadRefused(
422,
`Backup is for user '${payload.userId}' which does not exist in this DB`,
{ reason: "owner_not_found", ownerId: payload.userId },
);
}
},
);
} catch (err) {
if (err instanceof UploadRefused) {
await denied(err.details.reason as string, err.details);
return apiError(err.answer, err.status, err.meta);
}
// The file was fine; its encrypted copy is larger than one stored backup
// may be on this host (a fifth of the heap limit). That is the operator's
// to act on, with the numbers in the message, and not a server fault for
// the error reporter. The store rolled back, so nothing was kept.
if (err instanceof BackupBlobTooLargeError) {
await denied("stored_copy_too_large", {
bytes: err.bytes,
limitBytes: err.limitBytes,
});
return apiError(
`This backup is too large to store on this server. ${err.message}`,
413,
{ errorCode: BACKUP_UPLOAD_TOO_LARGE_CODE },
);
}
throw err;
}
const summary: BackupSummary = {
...summarizeBackup(payload!),
measurements: measurementCount,
};
const storedOwner = owner as { id: string; username: string } | null;
await auditLog("admin.backups.upload", {
userId: admin.id,
ipAddress: getClientIp(request),
ipAddress,
details: {
backupId: created.id,
ownerId: owner.id,
ownerUsername: owner.username,
ownerId: storedOwner?.id,
ownerUsername: storedOwner?.username,
type: uploadType,
schemaVersion: summary.schemaVersion,
counts: {
@@ -92,7 +92,7 @@ describe("POST /api/admin/notifications/reminder-check", () => {
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { scoped: boolean } };
expect(json.data.scoped).toBe(false);
expect(whereOfLastFindMany()).toEqual({ active: true });
expect(whereOfLastFindMany()).toEqual({ active: true, trackIntake: true });
});
it("narrows the sweep to the named account", async () => {
@@ -102,6 +102,7 @@ describe("POST /api/admin/notifications/reminder-check", () => {
expect(json.data.scoped).toBe(true);
expect(whereOfLastFindMany()).toEqual({
active: true,
trackIntake: true,
userId: "cku0mock0account0id",
});
});
@@ -8,6 +8,7 @@ import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
export const dynamic = "force-dynamic";
@@ -99,6 +100,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const medications = await prisma.medication.findMany({
where: {
active: true,
// v1.39.1 (#1033) — the manual check reminds exactly what the worker
// reminds: never a medication with intake tracking off.
...TRACKED_INTAKE_WHERE,
// Absent selector ⇒ no `userId` key ⇒ the instance-wide sweep.
...(scopedUserId ? { userId: scopedUserId } : {}),
},
+2 -7
View File
@@ -49,10 +49,7 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import { isCycleEnabled } from "@/lib/cycle/gate";
import {
resolveModuleMap,
@@ -279,9 +276,7 @@ export const GET = apiHandler(async () => {
// The zone the server cuts this account's days in: the stored zone, or
// the instance default when the stored one is unusable. Clients bucket
// by this value, so it must be the resolved one, not the raw column.
timezone: isValidTimezone(user.timezone)
? user.timezone
: await resolveServerDefaultTimezone(),
timezone: await resolveStoredTimezone(user.timezone),
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.39 (C1) — the needs-based setup flow, published additively:
@@ -0,0 +1,259 @@
/**
* `GET /api/cycle/calendar` — what a person logged on a day reaches the grid.
*
* The calendar used to carry flow, symptoms, BBT, the ovulation test and the
* mucus reading, and nothing else from the day log. Intercourse, spotting, the
* pregnancy and progesterone tests, contraception, the cervix signs and the
* presence of a note were all dropped between the row and the wire, so a day
* with only one of those logged looked exactly like a day with nothing logged.
*
* Same harness as the verdict round trip: the real exported `GET`, the real
* builder, the real DTO mapping, the response read back as text. Two ends are
* pinned — what the route asks Prisma for (a field that is not selected never
* reaches the builder) and what comes back on the wire.
*
* The intent fields can sit in the encrypted envelope instead of the plaintext
* columns (`sensitiveCategoryEncryption`), so one case writes them there and
* expects the grid to read them just as the day-log read does.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
vi.mock("@/lib/db", () => ({
prisma: {
menstrualCycle: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
measurement: { findMany: vi.fn() },
cyclePrediction: { findUnique: vi.fn(), upsert: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
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().mockResolvedValue({ allowed: true }),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { encrypt } from "@/lib/crypto";
import { cycleCalendarDayDto } from "@/lib/openapi/routes/cycle";
function session() {
return {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
gender: "FEMALE",
timezone: "UTC",
locale: "en",
},
};
}
function profile() {
return {
userId: "user-1",
goal: "GENERAL_HEALTH",
typicalCycleLength: 28,
typicalPeriodLength: 5,
lutealPhaseLength: 14,
predictionEnabled: true,
rawChartMode: false,
secondarySymptom: "MUCUS",
cycleTrackingEnabled: true,
sensitiveCategoryEncryption: false,
};
}
/** A day-log row as Prisma returns it for the route's select. */
function logRow(date: string, over: Record<string, unknown> = {}) {
return {
date,
flow: null,
intermenstrualBleeding: false,
basalBodyTempC: null,
temperatureExcluded: false,
ovulationTest: null,
cervicalMucus: null,
cervixPosition: null,
cervixFirmness: null,
cervixOpening: null,
sexualActivity: false,
protectedSex: null,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
sensitiveEncrypted: null,
notesEncrypted: null,
_count: { symptomLinks: 0 },
...over,
};
}
async function callCalendar(): Promise<Record<string, unknown>[]> {
const res = await GET(
new NextRequest(
"http://localhost/api/cycle/calendar?from=2026-09-01&to=2026-09-30",
),
);
expect(res.status).toBe(200);
const body = JSON.parse(await res.text());
return body.data.days as Record<string, unknown>[];
}
function day(days: Record<string, unknown>[], date: string) {
const found = days.find((d) => d.date === date);
if (!found) throw new Error(`no grid day ${date}`);
return found;
}
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-25T12:00:00Z"));
vi.mocked(getSession).mockResolvedValue(session() as never);
vi.mocked(requireCycleEnabled).mockResolvedValue({
enabled: true,
profile: profile(),
} as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([
{
id: "cyc-1",
userId: "user-1",
startDate: "2025-12-29",
endDate: null,
periodEndDate: null,
ovulationDate: null,
ovulationConfirmed: false,
isPredicted: false,
},
] as never);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
vi.useRealTimers();
});
describe("GET /api/cycle/calendar carries every per-day entry", () => {
it("asks Prisma for the fields the grid now shows", async () => {
await callCalendar();
const args = vi.mocked(prisma.cycleDayLog.findMany).mock.calls[0][0] as {
select: Record<string, unknown>;
};
expect(args.select).toMatchObject({
intermenstrualBleeding: true,
sexualActivity: true,
pregnancyTest: true,
progesteroneTest: true,
contraceptive: true,
sensitiveEncrypted: true,
notesEncrypted: true,
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
});
});
it("marks a day with intercourse logged, and only that day", async () => {
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
logRow("2026-09-05", { sexualActivity: true }),
] as never);
const days = await callCalendar();
expect(day(days, "2026-09-05").sexualActivity).toBe(true);
expect(day(days, "2026-09-04").sexualActivity).toBe(false);
expect(day(days, "2026-09-06").sexualActivity).toBe(false);
});
it("reads intercourse and the tests from the encrypted envelope", async () => {
// Encryption ON at write: the plaintext columns are NULL/false and the
// envelope carries the values. The grid must read what the day log reads.
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
logRow("2026-09-05", {
sexualActivity: false,
sensitiveEncrypted: encrypt(
JSON.stringify({
sexualActivity: true,
protectedSex: true,
pregnancyTest: "NEGATIVE",
progesteroneTest: "POSITIVE",
contraceptive: "ORAL",
}),
),
}),
] as never);
const d = day(await callCalendar(), "2026-09-05");
expect(d.sexualActivity).toBe(true);
expect(d.pregnancyTest).toBe("NEGATIVE");
expect(d.progesteroneTest).toBe("POSITIVE");
expect(d.contraceptive).toBe("ORAL");
});
it("an undecryptable envelope reads as nothing logged, never a 500", async () => {
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
logRow("2026-09-05", { sensitiveEncrypted: "v1:not-a-real-envelope" }),
] as never);
const d = day(await callCalendar(), "2026-09-05");
expect(d.sexualActivity).toBe(false);
expect(d.pregnancyTest).toBeNull();
});
it("carries spotting, the cervix signs and whether a note exists", async () => {
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
logRow("2026-09-10", {
intermenstrualBleeding: true,
cervixPosition: "HIGH",
cervixFirmness: "SOFT",
cervixOpening: "OPEN",
notesEncrypted: encrypt("a note"),
}),
] as never);
const days = await callCalendar();
const d = day(days, "2026-09-10");
expect(d.intermenstrualBleeding).toBe(true);
expect(d.cervixPosition).toBe("HIGH");
expect(d.cervixFirmness).toBe("SOFT");
expect(d.cervixOpening).toBe("OPEN");
expect(d.hasNote).toBe(true);
// The note's text never rides the grid, only its presence.
expect(JSON.stringify(d)).not.toContain("a note");
expect(day(days, "2026-09-11").hasNote).toBe(false);
});
it("every grid day carries exactly the fields the published contract names", async () => {
// The contract promised the cervix signs while the route never sent them.
// Pin the two against each other so neither can drift alone again.
const days = await callCalendar();
const contractKeys = Object.keys(cycleCalendarDayDto.shape).sort();
expect(Object.keys(days[0]).sort()).toEqual(contractKeys);
});
});
+35 -12
View File
@@ -34,6 +34,7 @@ import {
toCyclePredictionDTO,
goalAllowsFertileWindow,
cycleDisclaimerKey,
resolveSensitiveFields,
} from "@/lib/cycle/dto";
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
@@ -115,6 +116,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
intermenstrualBleeding: true,
// The intent fields and their encrypted envelope, resolved below
// through the same reader the day-log route uses. Same `cycle` grant
// as that route (see sharing-surface-guard), so the grid shows a
// delegate nothing the day log would not.
sexualActivity: true,
protectedSex: true,
pregnancyTest: true,
progesteroneTest: true,
contraceptive: true,
sensitiveEncrypted: true,
// Selected for presence only; the text is never decrypted here.
notesEncrypted: true,
_count: { select: { symptomLinks: true } },
},
}),
@@ -141,18 +155,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
}),
]);
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => ({
date: l.date,
flow: l.flow,
basalBodyTempC: l.basalBodyTempC,
temperatureExcluded: l.temperatureExcluded,
ovulationTest: l.ovulationTest,
cervicalMucus: l.cervicalMucus,
cervixPosition: l.cervixPosition,
cervixFirmness: l.cervixFirmness,
cervixOpening: l.cervixOpening,
hasSymptoms: l._count.symptomLinks > 0,
}));
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => {
const sensitive = resolveSensitiveFields(l);
return {
date: l.date,
flow: l.flow,
basalBodyTempC: l.basalBodyTempC,
temperatureExcluded: l.temperatureExcluded,
ovulationTest: l.ovulationTest,
cervicalMucus: l.cervicalMucus,
cervixPosition: l.cervixPosition,
cervixFirmness: l.cervixFirmness,
cervixOpening: l.cervixOpening,
intermenstrualBleeding: l.intermenstrualBleeding,
sexualActivity: sensitive.sexualActivity,
pregnancyTest: sensitive.pregnancyTest,
progesteroneTest: sensitive.progesteroneTest,
contraceptive: sensitive.contraceptive,
hasSymptoms: l._count.symptomLinks > 0,
hasNote: l.notesEncrypted != null && l.notesEncrypted !== "",
};
});
const nights = nightlyTemps.map((m) => ({
date: moodDateKey(m.measuredAt, tz),
@@ -203,3 +203,82 @@ describe("POST /api/cycle/day-logs/bulk — external-id stability floor", () =>
expect(upsertCycleDayLog).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/cycle/day-logs/bulk — refusal codes", () => {
/**
* The native client files rows under a permanent-refusal register as soon
* as it can tell the refusal is permanent. It can only tell from the code,
* so both whole-batch 422s are pinned here, with the envelope they ride.
*/
async function refusal(body: unknown) {
const res = await POST(postReq(body));
return {
status: res.status,
body: (await res.json()) as {
data: null;
error: string;
details?: { issues: unknown[] };
meta?: { errorCode?: string };
},
};
}
it("names a malformed batch cycle.bulk.invalid, with every issue listed", async () => {
const { status, body } = await refusal({
entries: [{ date: "not-a-day" }, { date: "2026-13-40" }],
});
expect(status).toBe(422);
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("cycle.bulk.invalid");
expect(body.details?.issues.length).toBeGreaterThanOrEqual(2);
expect(upsertCycleDayLog).not.toHaveBeenCalled();
});
it("names a batch with no entries array cycle.bulk.invalid", async () => {
const { status, body } = await refusal({});
expect(status).toBe(422);
expect(body.meta?.errorCode).toBe("cycle.bulk.invalid");
});
it("names an over-cap batch cycle.bulk.too_large", async () => {
const entries = Array.from({ length: 501 }, (_, i) => ({
date: "2026-01-01",
externalId: `e${i}`,
}));
const { status, body } = await refusal({ entries });
expect(status).toBe(422);
expect(body.meta?.errorCode).toBe("cycle.bulk.too_large");
});
});
describe("POST /api/cycle/day-logs/bulk — a failed write is not cached", () => {
it("marks a response carrying upsert_failed no-store and leaves a clean one alone", async () => {
vi.mocked(upsertCycleDayLog).mockRejectedValueOnce(new Error("reset"));
const failed = await POST(
postReq({
entries: [{ date: "2026-01-01", loggedAt: "2026-01-01T08:00:00.000Z" }],
}),
);
expect(failed.status).toBe(200);
const body = (await failed.json()) as {
data: { entries: Array<{ status: string; reason?: string }> };
};
expect(body.data.entries[0]).toMatchObject({
status: "skipped",
reason: "upsert_failed",
});
expect(failed.headers.get("Cache-Control")).toBe("private, no-store");
vi.mocked(upsertCycleDayLog).mockResolvedValueOnce({
id: "d1",
existed: false,
changed: true,
} as never);
const clean = await POST(
postReq({
entries: [{ date: "2026-01-02", loggedAt: "2026-01-02T08:00:00.000Z" }],
}),
);
expect(clean.headers.get("Cache-Control")).not.toBe("private, no-store");
});
});
+9 -1
View File
@@ -249,7 +249,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
},
});
return apiSuccess({
const response = apiSuccess({
processed: entries.length,
inserted,
updated,
@@ -257,4 +257,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
skipped,
entries: results,
});
// `upsert_failed` is a write that did not happen, not a verdict on the
// entry, and the client sends it again. `no-store` keeps the idempotency
// layer from caching this answer, so a retry under the same key writes
// instead of replaying the failure.
if (results.some((r) => r.reason === "upsert_failed")) {
response.headers.set("Cache-Control", "private, no-store");
}
return response;
}
+8
View File
@@ -170,6 +170,12 @@ export const PATCH = apiHandler(
? encryptToBytes(entry.outcome)
: null;
}
if (entry.bodySite !== undefined) {
data.bodySiteEncrypted = entry.bodySite?.trim()
? encryptToBytes(entry.bodySite.trim())
: null;
}
if (entry.laterality !== undefined) data.laterality = entry.laterality;
if (entry.practitionerId !== undefined) {
data.practitioner = practitionerId
? { connect: { id: practitionerId } }
@@ -331,12 +337,14 @@ export const PATCH = apiHandler(
occurredAt: existing.occurredAt,
status: existing.status,
kind: existing.kind,
laterality: existing.laterality,
practitionerId: existing.practitionerId,
},
after: {
occurredAt: outcome.updated.occurredAt,
status: outcome.updated.status,
kind: outcome.updated.kind,
laterality: outcome.updated.laterality,
practitionerId: outcome.updated.practitionerId,
},
}),
@@ -0,0 +1,179 @@
/**
* `GET /api/encounters/procedures` — the procedure history, searched after the
* decrypt.
*
* The body site is ciphertext in the fixture as it is in the database, so a
* route that searched the stored bytes instead of the decrypted text would
* find nothing and go red here. The fake honours nothing about the `where`, so
* the test asserts the `where` it was handed directly: a procedure history
* that forgot the kind or the status filter would otherwise pass against a
* fixture of procedures only.
*
* Mutation checks (each run, each seen red):
* - drop `kind: "PROCEDURE"` from the read → "reads only procedures that
* happened" goes red;
* - compute the facets over the filtered list → "offers every body site
* whatever the filter" goes red with one facet;
* - search `reasonEncrypted` bytes instead of the DTO → the knee search
* returns nothing and goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const mocks = vi.hoisted(() => ({
encounterFindMany: vi.fn(),
userFindUnique: vi.fn(),
requireRecordAuth: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireRecordAuth: mocks.requireRecordAuth,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => undefined,
}));
vi.mock("@/lib/db", () => ({
prisma: {
encounter: { findMany: mocks.encounterFindMany },
user: { findUnique: mocks.userFindUnique },
},
}));
vi.mock("@/lib/sharing/acting-domains", () => ({
actingDomainVisibility: vi.fn(async () => () => true),
}));
vi.mock("@/lib/links", () => ({
listTargets: vi.fn(async () => []),
listTargetsBySource: vi.fn(async () => new Map()),
replaceTargets: vi.fn(),
}));
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { GET } from "../route";
const handler = GET as unknown as (req: NextRequest) => Promise<Response>;
function request(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/encounters/procedures${query ? `?${query}` : ""}`,
);
}
function row(
id: string,
day: number,
bodySite: string | null,
laterality: "LEFT" | "RIGHT" | "BOTH" | null,
reason: string | null,
) {
const at = new Date(Date.UTC(2020, 0, day));
return {
id,
userId: "user-1",
occurredAt: at,
status: "DONE",
kind: "PROCEDURE",
practitionerId: null,
practitioner: null,
reminder: null,
reminderId: null,
reasonEncrypted: reason ? encryptToBytes(reason) : null,
outcomeEncrypted: null,
bodySiteEncrypted: bodySite ? encryptToBytes(bodySite) : null,
laterality,
createdAt: at,
updatedAt: at,
deletedAt: null,
};
}
interface Body {
data: {
procedures: Array<{ id: string; bodySite: string | null }>;
bodySites: Array<{ bodySite: string; laterality: string | null }>;
total: number;
};
}
beforeEach(() => {
vi.clearAllMocks();
mocks.requireRecordAuth.mockResolvedValue({
user: { id: "user-1" },
actor: { id: "user-1" },
grantId: null,
});
mocks.userFindUnique.mockResolvedValue({
timezone: "Europe/Berlin",
locale: "de",
});
mocks.encounterFindMany.mockResolvedValue([
row("p3", 30, "Knie", "RIGHT", "Arthroskopie"),
row("p2", 20, "Gallenblase", null, "Cholezystektomie"),
row("p1", 10, "Knie", "LEFT", "Meniskus"),
]);
});
describe("GET /api/encounters/procedures", () => {
it("reads the health background at READ", async () => {
await handler(request());
expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "profile");
});
it("reads only procedures that happened, for the caller", async () => {
await handler(request());
const args = mocks.encounterFindMany.mock.calls[0][0];
expect(args.where).toMatchObject({
userId: "user-1",
deletedAt: null,
kind: "PROCEDURE",
status: "DONE",
});
expect(args.orderBy).toEqual({ occurredAt: "desc" });
expect(args.take).toBeGreaterThan(0);
});
it("returns the whole history, newest first, with the decrypted site", async () => {
const res = await handler(request());
expect(res.status).toBe(200);
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p3", "p2", "p1"]);
expect(body.data.procedures[0].bodySite).toBe("Knie");
expect(body.data.total).toBe(3);
});
it("searches the decrypted site together with the side, in the owner's language", async () => {
const res = await handler(request("q=knie%20links"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p1"]);
});
it("finds the English side word as well", async () => {
const res = await handler(request("q=left"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p1"]);
});
it("filters the side exactly", async () => {
const res = await handler(request("laterality=RIGHT"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p3"]);
});
it("offers every body site whatever the filter", async () => {
const res = await handler(request("q=galle"));
const body = (await res.json()) as Body;
expect(body.data.procedures.map((p) => p.id)).toEqual(["p2"]);
expect(body.data.bodySites).toHaveLength(3);
expect(body.data.total).toBe(3);
});
it("refuses an unknown side with 422", async () => {
const res = await handler(request("laterality=UP"));
expect(res.status).toBe(422);
expect(mocks.encounterFindMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,60 @@
/**
* `GET /api/encounters/procedures` — the procedure and surgery history.
*
* Every visit filed as PROCEDURE that happened, newest first, with the body
* sites it holds as filter choices. `q` searches the body site, the side and
* the reason; `laterality` filters the side exactly. Both run after the
* decrypt, because the body site is ciphertext at rest; the reasoning is
* written at `Encounter.bodySiteEncrypted` and in
* `src/lib/encounters/procedures.ts`.
*
* Same section and level as the visit list (`("read", "profile")`): a
* procedure is a visit, and a grant that reads the visits reads this.
* `userId` is narrowed from auth and fed to the Prisma `where`.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { procedureListQuerySchema } from "@/lib/validations/encounters";
import { resolveOwnerNotificationContext } from "@/lib/encounters/service";
import { loadProcedureHistory } from "@/lib/encounters/procedures";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
export const GET = apiHandler(async (request: NextRequest) => {
const { user, grantId } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = procedureListQuerySchema.safeParse({
q: params.get("q") ?? undefined,
laterality: params.get("laterality") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
// The side words are the record owner's, like every other label a visit
// resolves: a delegate searching somebody's history types in that person's
// record, and the English word is matched as well either way.
const { locale } = await resolveOwnerNotificationContext(user.id);
const visible = await actingDomainVisibility(prisma, grantId);
const body = await loadProcedureHistory(prisma, user.id, parsed.data, {
locale,
visible,
});
annotate({
action: { name: "encounter.procedure.list", entity_type: "encounter" },
meta: {
total: body.total,
matched: body.procedures.length,
filtered: Boolean(parsed.data.q?.trim() || parsed.data.laterality),
},
});
return apiSuccess(body);
});
+4
View File
@@ -216,6 +216,10 @@ async function postEncounter(request: NextRequest): Promise<Response> {
practitionerId: practitioner?.id ?? null,
reasonEncrypted: entry.reason ? encryptToBytes(entry.reason) : null,
outcomeEncrypted: entry.outcome ? encryptToBytes(entry.outcome) : null,
bodySiteEncrypted: entry.bodySite?.trim()
? encryptToBytes(entry.bodySite.trim())
: null,
laterality: entry.laterality ?? null,
},
select: { id: true },
});
@@ -64,6 +64,29 @@ vi.mock("@/lib/db", () => ({
documentConditionLink: { findMany: vi.fn() },
extractedFact: { findMany: vi.fn() },
ecgRecording: { findMany: vi.fn() },
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
encounter: { findMany: vi.fn().mockResolvedValue([]) },
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
correlationPattern: { findMany: vi.fn().mockResolvedValue([]) },
healthProfileFactRevision: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
cycleSymptom: { findMany: vi.fn().mockResolvedValue([]) },
},
}));
@@ -177,6 +200,7 @@ beforeEach(() => {
);
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
});
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
@@ -192,7 +216,13 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
it("/api/export/full-backup scopes the measurement read to deletedAt: null", async () => {
const { GET } = await import("../full-backup/route");
await GET(mkReq("http://localhost/api/export/full-backup"));
// The file is written into the response as it is produced, so the reads
// happen while the body is consumed. The mocked client does not cover
// every section the builder reads, so the body errors part-way (as the
// whole response did before it streamed); the reads asserted here have
// been issued by then.
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
await res.text();
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ deletedAt: null }),
@@ -202,7 +232,10 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
it("/api/export/full-backup scopes every v1.28 records domain to userId + deletedAt: null", async () => {
const { GET } = await import("../full-backup/route");
await GET(mkReq("http://localhost/api/export/full-backup"));
// The file is written into the response as it is produced, so the reads
// happen while the body is consumed.
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
await res.text();
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
expect.objectContaining({
+77 -27
View File
@@ -42,13 +42,44 @@ import {
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { randomBytes } from "node:crypto";
import { createReadStream } from "node:fs";
import { readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import {
encryptArchive,
encryptArchiveToFile,
MIN_EXPORT_PASSPHRASE_LENGTH,
type SpooledArchive,
} from "@/lib/export/passphrase-archive";
import { streamToResponseBody } from "@/lib/export/response-stream";
import { NextRequest, NextResponse } from "next/server";
const SPOOL_PREFIX = "healthlog-export-";
/**
* Remove spool files an earlier export left behind: a process that died
* mid-export never reached its cleanup. An hour is far longer than any export
* takes, so nothing still being written or sent is touched.
*/
async function removeStaleSpools(): Promise<void> {
try {
const dir = tmpdir();
const cutoff = Date.now() - 60 * 60 * 1000;
for (const name of await readdir(dir)) {
if (!name.startsWith(SPOOL_PREFIX)) continue;
const path = join(dir, name);
const info = await stat(path).catch(() => null);
if (info && info.mtimeMs < cutoff) await rm(path, { force: true });
}
} catch {
// Housekeeping only; an export must not fail over it.
}
}
const encryptedExportSchema = z
.object({
passphrase: z
@@ -89,10 +120,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const { passphrase } = parsed.data;
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
// Encrypt the JSON bytes. The passphrase goes no further than the KDF.
const archive = await encryptArchive(JSON.stringify(payload), passphrase);
// Sealed as it is produced and spooled to a temporary file, then sent: the
// format puts the tag in front of the ciphertext, so the archive cannot go
// out before its last byte is encrypted, and holding it in memory is what
// took the app down on an account of 1.25 million measurements (#1031). Only
// ciphertext touches the disk, and the file is removed once it is sent or
// the client leaves.
let counts: FullBackupCounts | undefined;
await removeStaleSpools();
const bodyPath = join(
tmpdir(),
`${SPOOL_PREFIX}${randomBytes(12).toString("hex")}.hlx.part`,
);
let archive: SpooledArchive;
try {
archive = await encryptArchiveToFile(
async (write) => {
counts = await streamFullBackupJson(prisma, user.id, write);
},
passphrase,
bodyPath,
);
} catch (err) {
await rm(bodyPath, { force: true });
throw err;
}
await auditLog("user.export.encrypted", {
userId: user.id,
@@ -102,38 +154,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({
meta: {
export_measurements_count: counts.measurements,
export_medications_count: counts.medications,
export_intake_count: counts.intakeEvents,
export_medication_side_effect_count: counts.medicationSideEffects,
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
export_lab_result_count: counts.labResults,
export_biomarker_count: counts.biomarkers,
export_illness_episode_count: counts.illnessEpisodes,
export_illness_day_log_count: counts.illnessDayLogs,
export_allergy_count: counts.allergies,
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_nutrient_day_count: counts.nutrientDays,
export_health_profile_count: counts.healthProfile,
export_custom_metric_count: counts.customMetrics,
export_custom_metric_entry_count: counts.customMetricEntries,
export_intraday_profile_count: counts.intradayProfiles,
export_measurements_count: counts?.measurements,
export_medications_count: counts?.medications,
export_intake_count: counts?.intakeEvents,
export_mood_count: counts?.moodEntries,
export_archive_bytes: archive.byteLength,
},
});
const archiveBody = streamToResponseBody(
async (write) => {
await write(archive.prefix);
for await (const chunk of createReadStream(archive.bodyPath)) {
await write(chunk as Buffer);
}
},
{
onComplete: () => rm(archive.bodyPath, { force: true }),
onError: () => rm(archive.bodyPath, { force: true }),
},
);
const stamp = new Date().toISOString().slice(0, 10);
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
// self-contained `.hlx` archive openable with the user's passphrase via
// scripts/decrypt-export.ts.
return new NextResponse(new Uint8Array(archive), {
return new NextResponse(archiveBody, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(archive.byteLength),
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.hlx"`,
"Cache-Control": "no-store",
},
+35 -37
View File
@@ -35,7 +35,9 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { streamToResponseBody } from "@/lib/export/response-stream";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -47,48 +49,44 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 10 exports per hour", 429);
}
// v1.23 — the payload builder is shared with the passphrase-encrypted
// export route so both emit the byte-for-byte same shape (the measurement/
// medication/mood/cycle domains are restore-compatible; the newer records
// domains are export-only for now — see the module doc comment above).
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
await auditLog("user.export.full-backup", {
userId: user.id,
ipAddress: getClientIp(request),
details: { counts },
});
annotate({
meta: {
export_measurements_count: counts.measurements,
export_medications_count: counts.medications,
export_intake_count: counts.intakeEvents,
export_medication_side_effect_count: counts.medicationSideEffects,
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
export_lab_result_count: counts.labResults,
export_biomarker_count: counts.biomarkers,
export_illness_episode_count: counts.illnessEpisodes,
export_illness_day_log_count: counts.illnessDayLogs,
export_allergy_count: counts.allergies,
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_nutrient_day_count: counts.nutrientDays,
export_health_profile_count: counts.healthProfile,
export_custom_metric_count: counts.customMetrics,
export_custom_metric_entry_count: counts.customMetricEntries,
export_intraday_profile_count: counts.intradayProfiles,
// Written into the response as it is produced (`streamFullBackupJson`), not
// built and stringified first: for an account of 1.25 million measurements
// the built payload and its JSON string did not fit a 1 GB container, and
// the export took the whole app down (#1031). The file is byte for byte
// what `JSON.stringify` of the payload builder's object gives; the passphrase
// export beside this route writes the same bytes, encrypted.
//
// The counts are only known once the last row has gone out, so the audit
// row is written then. A file that stops early is audited as a failure.
const ipAddress = getClientIp(request);
let counts: FullBackupCounts | undefined;
const body = streamToResponseBody(
async (write) => {
counts = await streamFullBackupJson(prisma, user.id, write);
},
});
{
onComplete: () =>
auditLog("user.export.full-backup", {
userId: user.id,
ipAddress,
details: { counts },
}),
onError: (err) =>
auditLog("user.export.full-backup.failed", {
userId: user.id,
ipAddress,
details: {
reason: err instanceof Error ? err.message : String(err),
},
}),
},
);
const stamp = new Date().toISOString().slice(0, 10);
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
// the file is a self-contained backup — admin upload + restore expect
// the raw payload, not `{ data: { ... } }`.
return new NextResponse(JSON.stringify(payload), {
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
+1
View File
@@ -66,6 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
name: true,
dose: true,
active: true,
trackIntake: true,
schedules: {
select: {
windowStart: true,
+7 -1
View File
@@ -33,6 +33,7 @@ import {
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import type { MeasurementType } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
type Severity = "alert" | "caution" | "info" | "good";
@@ -101,7 +102,12 @@ export const GET = apiHandler(async () => {
prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId: user.id, active: true, asNeeded: false },
where: {
userId: user.id,
active: true,
asNeeded: false,
...TRACKED_INTAKE_WHERE,
},
include: {
// Schedules through the shared compliance select so the configured
// per-dose windows reach the engine like every other surface.
+2 -1
View File
@@ -42,6 +42,7 @@ import {
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { userDayKey } from "@/lib/tz/format";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
export const dynamic = "force-dynamic";
@@ -381,7 +382,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
const medicationRows = await prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId, active: true, asNeeded: false },
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: {
@@ -0,0 +1,231 @@
/**
* `POST /api/measurements/batch` — what a refusal tells the client.
*
* Two gaps the native client reported against v1.39.0:
*
* - The whole-batch validation 422 carried no `meta.errorCode`, so the
* client could not tell a permanent refusal from a transient one and had
* to hold its anchor for several syncs before giving up on the rows.
* The too-large and source refusals already had codes; this one now has
* `measurement.batch.invalid`.
* - A `value_out_of_range` skip said neither what the value became after
* conversion nor which band it broke, so a unit-mapping slip (a fraction
* sent where a percent was expected) looked the same as a sensor glitch.
* The entry now carries `convertedValue` and `range`.
*
* Both are additive: status codes, the envelope and every existing field
* are unchanged.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
measurement: { findMany: vi.fn() },
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
return (fn as (tx: unknown) => unknown)(prisma);
}
}),
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: vi.fn(
async (_tx: unknown, input: Record<string, unknown>) => ({
status: "inserted",
row: { id: "row-1", ...input },
}),
),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: 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 { checkRateLimit } from "@/lib/rate-limit";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
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 post(body: unknown) {
return POST(
new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
const AT = new Date(Date.now() - 3_600_000).toISOString();
function entry(overrides: Record<string, unknown> = {}) {
return {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 64,
unit: "count/min",
startDate: AT,
endDate: AT,
externalId: "uuid-1",
...overrides,
};
}
type EntryResult = {
index: number;
status: string;
reason?: string;
convertedValue?: number;
range?: { min: number; max: number; unit: string };
};
async function entriesFor(entries: unknown[]): Promise<EntryResult[]> {
const res = await post({ entries });
expect(res.status).toBe(200);
return ((await res.json()) as { data: { entries: EntryResult[] } }).data
.entries;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
});
describe("POST /api/measurements/batch — validation refusal code", () => {
it("names a malformed batch measurement.batch.invalid and keeps the 422 envelope", async () => {
const res = await post({ entries: [entry({ startDate: "yesterday" })] });
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: { issues: unknown[] };
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(typeof body.error).toBe("string");
expect(body.details.issues.length).toBeGreaterThan(0);
expect(body.meta?.errorCode).toBe("measurement.batch.invalid");
});
it("names an empty entries array the same way", async () => {
const res = await post({ entries: [] });
expect(res.status).toBe(422);
expect(
((await res.json()) as { meta?: { errorCode?: string } }).meta?.errorCode,
).toBe("measurement.batch.invalid");
});
it("keeps measurement.batch.too_large for an over-cap batch", async () => {
const entries = Array.from({ length: 501 }, (_, i) =>
entry({ externalId: `uuid-${i}` }),
);
const res = await post({ entries });
expect(res.status).toBe(422);
expect(
((await res.json()) as { meta?: { errorCode?: string } }).meta?.errorCode,
).toBe("measurement.batch.too_large");
});
});
describe("POST /api/measurements/batch — value_out_of_range detail", () => {
it("reports the converted value and the band it broke, in the stored unit", async () => {
// HealthKit reports body fat as a fraction; 0.9 becomes 90 %, above 80.
const [result] = await entriesFor([
entry({
hkIdentifier: "HKQuantityTypeIdentifierBodyFatPercentage",
value: 0.9,
unit: "%",
}),
]);
expect(result).toEqual({
index: 0,
status: "skipped",
reason: "value_out_of_range",
convertedValue: 90,
range: { min: 1, max: 80, unit: "%" },
});
});
it("reports the lower edge too", async () => {
const [result] = await entriesFor([entry({ value: 12 })]);
expect(result).toMatchObject({
reason: "value_out_of_range",
convertedValue: 12,
range: { min: 20, max: 300, unit: "bpm" },
});
});
it("carries the same detail when the write-side gate is the one that refuses", async () => {
vi.mocked(reconcileExternalMeasurement).mockResolvedValueOnce({
status: "rejected_range",
rejection: { type: "PULSE", direction: "above_max" },
});
const [result] = await entriesFor([entry({ value: 64 })]);
expect(result).toMatchObject({
status: "skipped",
reason: "value_out_of_range",
convertedValue: 64,
range: { min: 20, max: 300, unit: "bpm" },
});
});
it("adds nothing to an entry that landed or was skipped for another reason", async () => {
const results = await entriesFor([
entry({ externalId: "uuid-ok" }),
entry({ hkIdentifier: "HKQuantityTypeIdentifierNotAThing" }),
]);
expect(results[0]).toEqual({ index: 0, status: "inserted" });
expect(results[1]).toEqual({
index: 1,
status: "skipped",
reason: "unmappable_identifier",
});
});
});
+44 -11
View File
@@ -72,7 +72,11 @@ import {
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import {
VALUE_RANGES,
getUnitForType,
validateMeasurementRange,
} from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
@@ -217,6 +221,36 @@ interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
/**
* Only on a `value_out_of_range` skip: the value after conversion into the
* stored unit, which is what the band was checked against.
*/
convertedValue?: number;
/** Only on a `value_out_of_range` skip: the band applied, in the stored unit. */
range?: { min: number; max: number; unit: string };
}
/**
* The `value_out_of_range` skip, with what a client needs to say which limit
* applied. The value is the entry's own reading after conversion, handed back
* to the account that sent it; a fraction sent where a percent was expected
* shows up here as a converted value a hundred times too small or too large.
*/
function outOfRangeResult(
index: number,
type: string,
convertedValue: number,
): EntryResult {
const band = VALUE_RANGES[type];
return {
index,
status: "skipped",
reason: "value_out_of_range",
...(Number.isFinite(convertedValue) ? { convertedValue } : {}),
...(band
? { range: { min: band.min, max: band.max, unit: getUnitForType(type) } }
: {}),
};
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
@@ -277,10 +311,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
const parsed = batchPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
// The code is what lets a client tell this refusal from a transient one:
// the batch as sent will never be accepted, so resending it is pointless.
return apiValidationError(
parsed.error.issues[0]?.message ?? "Invalid batch",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "measurement.batch.invalid" },
);
}
@@ -373,11 +410,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
// sensor reading shouldn't poison an otherwise clean ingest.
const rangeError = validateMeasurementRange(mapped.type, mapped.value);
if (rangeError !== null) {
results[index] = {
index,
status: "skipped",
reason: "value_out_of_range",
};
results[index] = outOfRangeResult(index, mapped.type, mapped.value);
continue;
}
@@ -678,11 +711,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
// already applied above, so reaching here means the two disagreed —
// report it exactly as the earlier guard would have, and let the
// wide-event tally record which end refused.
results[p.index] = {
index: p.index,
status: "skipped",
reason: "value_out_of_range",
};
results[p.index] = outOfRangeResult(
p.index,
p.row.type as string,
p.row.value as number,
);
continue;
}
+196
View File
@@ -55,6 +55,21 @@ import {
pickMainNightAndNaps,
} from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
isNamedSleepStageBody,
sleepStageEntrySchema,
toSleepStageSegment,
} from "@/lib/validations/sleep-stage-entry";
import {
MeasurementReconciliationError,
reconcileExternalMeasurement,
} from "@/lib/measurements/reconcile-external-measurement";
import {
MEASURED_AT_TOLERANCE_MS,
isMergeableSource,
isSameReadingAcrossSource,
oppositeMergeSource,
} from "@/lib/measurements/cross-source-merge";
import { NextRequest } from "next/server";
import type {
MeasurementType,
@@ -765,6 +780,13 @@ async function postMeasurement(request: NextRequest) {
);
}
// One sleep-stage segment, the stage named rather than coded. A bridge
// (Tasker, Home Assistant) posts here with its narrow token; the row and
// its dedupe are the batch route's. See `sleep-stage-entry.ts`.
if (isNamedSleepStageBody(body)) {
return postNamedSleepStage(request, user.id, body, scoped);
}
// Batch mode (array of measurements, e.g. combined BP + Pulse)
if (Array.isArray(body)) {
const parsed = createBatchMeasurementSchema.safeParse({
@@ -1084,3 +1106,177 @@ async function postMeasurement(request: NextRequest) {
return apiSuccess(shapeMeasurementNotes(measurement), 201);
}
/**
* `POST /api/measurements` with a named `sleepStage`: one segment, stored as
* the batch route stores a HealthKit sleep sample.
*
* The same three steps as the batch route, in the same order: a same-reading
* row of the opposite source (MANUAL against APPLE_HEALTH) makes this one a
* duplicate; otherwise `reconcileExternalMeasurement` settles both unique
* identities at once, reporting an exact external-id match as a duplicate
* and adopting a row that only shares the natural identity. So a bridge that
* re-posts the same segment gets one row, whichever path it used first.
*
* `201` with the stored row when it was inserted, `200` when it was already
* there (`duplicate`) or was corrected in place (`updated`). The row carries
* a `status` saying which.
*/
async function postNamedSleepStage(
request: NextRequest,
userId: string,
body: unknown,
scoped: boolean,
): Promise<Response> {
const parsed = sleepStageEntrySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "measurements.create.sleep-stage.validation-failed" },
meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "measurement.create.invalid",
});
}
const segment = toSleepStageSegment(parsed.data);
// Under a scoped credential the source is resolved from the credential,
// as on the other arms of this route: the refusal above already returned
// on any body that named one.
const source = (
scoped ? EXTERNAL_SOURCE : parsed.data.source
) as MeasurementSource;
const type: MeasurementType = "SLEEP_DURATION";
// Cross-source same reading, exactly as the batch route decides it.
if (isMergeableSource(source)) {
const candidates = await prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
type,
source: oppositeMergeSource(source),
measuredAt: {
gte: new Date(segment.end.getTime() - MEASURED_AT_TOLERANCE_MS),
lte: new Date(segment.end.getTime() + MEASURED_AT_TOLERANCE_MS),
},
},
select: {
id: true,
type: true,
source: true,
value: true,
measuredAt: true,
},
});
const twin = candidates.find((candidate) =>
isSameReadingAcrossSource(
{ type, source, value: segment.minutes, measuredAt: segment.end },
candidate,
),
);
if (twin) {
return namedSleepStageResponse(twin.id, "duplicate", {
stage: segment.stage,
source,
crossSource: true,
});
}
}
const verdict = await prisma.$transaction((tx) =>
reconcileExternalMeasurement(
tx,
{
userId,
type,
value: segment.minutes,
unit: getUnitForType(type),
source,
measuredAt: segment.end,
externalId: segment.externalId,
externalSourceVersion: null,
sleepStage: segment.stage,
deviceType: parsed.data.deviceType ?? null,
},
{ exactExternalMatch: "duplicate" },
),
);
if (verdict.status === "failed") {
throw new MeasurementReconciliationError(verdict);
}
if (verdict.status === "rejected_range") {
// The schema applies the same band, so this is the two gates disagreeing.
return apiError("Value out of plausible range", 422, {
errorCode: "measurement.create.invalid",
});
}
const status =
verdict.status === "inserted"
? "inserted"
: verdict.status === "duplicate"
? "duplicate"
: "updated";
if (status !== "duplicate") {
await auditLog("measurement.create", {
userId,
ipAddress: getClientIp(request),
details: {
type,
measurementId: verdict.row.id,
sleepStage: segment.stage,
status,
},
});
invalidateUserMeasurements(userId, { evict: true });
fireAndForget(enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
if (status === "inserted") {
void emitInsertedMeasurementArrivals(
userId,
[verdict.row],
"manual",
).catch(() => {});
void maybeEnqueueMorningRefresh(userId, [segment.end]).catch(() => {});
}
await afterMeasurementMutation(userId, [
{ type, measuredAt: segment.end },
...(verdict.dirtyIdentities ?? []),
]);
}
return namedSleepStageResponse(verdict.row.id, status, {
stage: segment.stage,
source,
crossSource: false,
});
}
async function namedSleepStageResponse(
id: string,
status: "inserted" | "updated" | "duplicate",
meta: { stage: string; source: string; crossSource: boolean },
): Promise<Response> {
annotate({
action: {
name: "measurement.create.sleep-stage",
entity_type: "measurement",
entity_id: id,
},
meta: {
status,
stage: meta.stage,
source: meta.source,
cross_source_merge: meta.crossSource,
},
});
const row = await prisma.measurement.findUniqueOrThrow({ where: { id } });
return apiSuccess(
{ ...shapeMeasurementNotes(row), status },
status === "inserted" ? 201 : 200,
);
}
@@ -24,6 +24,10 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
medicationPauseEra: {
create: vi.fn().mockResolvedValue({}),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
apiToken: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
@@ -776,3 +780,336 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
expect(prisma.medication.update).not.toHaveBeenCalled();
});
});
describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
const LIVE_ROW = {
id: "s1",
medicationId: "m1",
windowStart: "08:00",
windowEnd: "08:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
function arrange(trackIntake: boolean) {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
active: true,
asNeeded: false,
trackIntake,
createdAt: new Date("2026-05-01T08:00:00.000Z"),
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
trackIntake: !trackIntake,
unitsPerDose: 1,
schedules: [{ ...LIVE_ROW, unitsPerDose: null }],
} as never);
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
LIVE_ROW,
] as never);
vi.mocked(prisma.medicationScheduleRevision.create).mockClear();
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockClear();
vi.mocked(prisma.medication.update).mockClear();
}
it("switching off archives the schedule in force and tombstones open slots", async () => {
arrange(true);
const res = await PUT(putReq({ trackIntake: false }), ROUTE_CTX);
expect(res.status).toBe(200);
// The tracked stretch keeps its expected doses: the archived era
// carries the live schedule and ends now.
const revisions = vi.mocked(prisma.medicationScheduleRevision.create).mock
.calls;
expect(revisions).toHaveLength(1);
const era = revisions[0][0].data as {
payload: Array<{ timesOfDay: string[] }>;
validFrom: Date;
};
expect(era.payload).toHaveLength(1);
expect(era.payload[0].timesOfDay).toEqual(["08:00"]);
expect(era.validFrom.toISOString()).toBe("2026-05-01T08:00:00.000Z");
// Open placeholders of today and later go; actioned rows never do.
const tombstones = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls;
expect(tombstones).toHaveLength(1);
expect(tombstones[0][0].where).toMatchObject({
medicationId: "m1",
takenAt: null,
skipped: false,
autoMissed: false,
deletedAt: null,
});
// The flag is written field-by-field; the schedule rows are kept.
const update = vi.mocked(prisma.medication.update).mock.calls[0][0];
expect(update.data).toMatchObject({ trackIntake: false });
expect(update.data).not.toHaveProperty("schedules");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
// The wire serves it like an as-needed medication, schedule on record.
const body = (await res.json()) as {
data: { schedules: unknown[]; recordedSchedules?: unknown[] };
};
expect(body.data.schedules).toEqual([]);
expect(body.data.recordedSchedules).toHaveLength(1);
});
it("switching back on archives the untracked stretch as expecting nothing", async () => {
arrange(false);
const res = await PUT(putReq({ trackIntake: true }), ROUTE_CTX);
expect(res.status).toBe(200);
const revisions = vi.mocked(prisma.medicationScheduleRevision.create).mock
.calls;
expect(revisions).toHaveLength(1);
const era = revisions[0][0].data as { payload: unknown[] };
// No retroactive misses: the stretch that was off expected no dose.
expect(era.payload).toEqual([]);
// Nothing is tombstoned on the way back on.
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
const body = (await res.json()) as {
data: { schedules: unknown[]; recordedSchedules?: unknown[] };
};
expect(body.data.schedules).toHaveLength(1);
expect(body.data).not.toHaveProperty("recordedSchedules");
});
it("an unchanged flag archives nothing", async () => {
arrange(true);
const res = await PUT(putReq({ trackIntake: true }), ROUTE_CTX);
expect(res.status).toBe(200);
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
it("a schedule edit while untracked archives the stretch as empty", async () => {
arrange(false);
const res = await PUT(
putReq({
// The web editor names the flag on a record-only schedule save.
trackIntake: false,
schedules: [
{
windowStart: "08:00",
windowEnd: "08:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
},
],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const era = vi.mocked(prisma.medicationScheduleRevision.create).mock
.calls[0][0].data as { payload: unknown[] };
expect(era.payload).toEqual([]);
});
});
/**
* #1033 — the shipped iPhone app (1.0.3 / 1.0.4) cannot see the schedule of a
* record-only medication (it is served with `schedules: []`). These are the
* exact bodies its edit sheet sends (`EditMedicationSheet.save()` →
* `MedicationPatch`, nil keys omitted): it never names `trackIntake`, always
* sends `active` and `notificationsEnabled`, and sends the schedule shape
* only when a schedule field was touched, rebuilt from the "daily at 08:00"
* default its form shows for a medication without schedule rows.
*/
describe("PUT /api/medications/[id] — shipped client on a record-only medication (#1033)", () => {
const STORED_ROW = {
id: "s1",
medicationId: "m1",
windowStart: "21:00",
windowEnd: "21:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["21:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
// `name`/`dose`/`treatmentClass`/`category`/`active`/`notificationsEnabled`/
// `deliveryForm` ride every save; `dosesPerUnit` is nil for an empty field.
const IOS_RENAME = {
name: "Atorvastatin 20",
dose: "20 mg",
treatmentClass: "GENERIC",
category: "OTHER",
active: true,
notificationsEnabled: true,
deliveryForm: "ORAL",
};
// The same save after the dose time was moved from the shown 08:00 default
// to 09:00: `scheduleWrite(.daily)` + `oneShot` + `startsOn` + `asNeeded`.
const IOS_SCHEDULE_TOUCHED = {
...IOS_RENAME,
schedules: [
{
windowStart: "09:00",
windowEnd: "09:00",
timesOfDay: ["09:00"],
rrule: "FREQ=DAILY",
},
],
oneShot: false,
startsOn: "2026-06-01",
asNeeded: false,
};
// The same save after picking the "as needed" cadence.
const IOS_AS_NEEDED = {
...IOS_RENAME,
schedules: [],
oneShot: false,
asNeeded: true,
};
function arrange(trackIntake: boolean) {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
active: true,
asNeeded: false,
trackIntake,
createdAt: new Date("2026-05-01T08:00:00.000Z"),
_count: { schedules: 1 },
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
trackIntake,
unitsPerDose: 1,
schedules: [{ ...STORED_ROW, unitsPerDose: null }],
} as never);
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
STORED_ROW,
] as never);
vi.mocked(prisma.medication.update).mockClear();
vi.mocked(prisma.medicationSchedule.deleteMany).mockClear();
vi.mocked(prisma.medicationScheduleRevision.create).mockClear();
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockClear();
}
function updateData(): Record<string, unknown> {
return vi.mocked(prisma.medication.update).mock.calls[0][0].data as Record<
string,
unknown
>;
}
it("a rename applies and touches no schedule", async () => {
arrange(false);
const res = await PUT(putReq(IOS_RENAME), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).toMatchObject({
name: "Atorvastatin 20",
notificationsEnabled: true,
});
expect(updateData()).not.toHaveProperty("schedules");
expect(updateData()).not.toHaveProperty("trackIntake");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
});
it("a schedule-touched save keeps the recorded schedule and applies the rest", async () => {
arrange(false);
const res = await PUT(putReq(IOS_SCHEDULE_TOUCHED), ROUTE_CTX);
expect(res.status).toBe(200);
const data = updateData();
// The 09:00 default the person never chose is not written.
expect(data).not.toHaveProperty("schedules");
expect(data).not.toHaveProperty("asNeeded");
expect(data).not.toHaveProperty("oneShot");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
// Everything else in the save applies.
expect(data).toMatchObject({ name: "Atorvastatin 20" });
expect(data).toHaveProperty("startsOn");
// And it is still a record: the stored schedule comes back on record.
const body = (await res.json()) as {
data: {
schedules: unknown[];
recordedSchedules?: Array<{ timesOfDay: string[] }>;
};
};
expect(body.data.schedules).toEqual([]);
expect(body.data.recordedSchedules?.[0]?.timesOfDay).toEqual(["21:00"]);
});
it("picking 'as needed' neither 422s nor deletes the recorded schedule", async () => {
arrange(false);
const res = await PUT(putReq(IOS_AS_NEEDED), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).not.toHaveProperty("asNeeded");
expect(updateData()).not.toHaveProperty("schedules");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
});
it("the same schedule write applies once it names trackIntake", async () => {
arrange(false);
const res = await PUT(
putReq({ ...IOS_SCHEDULE_TOUCHED, trackIntake: false }),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(updateData()).toHaveProperty("schedules");
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
});
it("a tracked medication takes the shipped client's schedule write as before", async () => {
arrange(true);
const res = await PUT(putReq(IOS_SCHEDULE_TOUCHED), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).toHaveProperty("schedules");
expect(updateData()).toMatchObject({ asNeeded: false, oneShot: false });
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
});
it.each([
["pause", { active: false }],
["reactivate", { active: true }],
["end course", { endsOn: "2026-06-12" }],
])("the %s write applies unchanged", async (_label, body) => {
arrange(false);
const res = await PUT(putReq(body), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).toMatchObject(
"active" in body ? { active: body.active } : {},
);
expect(updateData()).not.toHaveProperty("schedules");
});
});
@@ -33,6 +33,7 @@ import {
} from "@/lib/analytics/compliance";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { dueSchedules } from "@/lib/medications/intake-tracking";
type RouteParams = { params: Promise<{ id: string }> };
@@ -131,8 +132,10 @@ export const GET = apiHandler(
scheduleRevisions: med.scheduleRevisions,
};
// v1.39.1 (#1033) — intake tracking off: no chip, no next dose.
const liveSchedules = dueSchedules(med);
const timeline = buildCadenceTimeline(
med.schedules,
liveSchedules,
events,
asOf,
windowDays,
@@ -141,7 +144,7 @@ export const GET = apiHandler(
engineCtx,
);
const chips = complianceChips(
med.schedules,
liveSchedules,
events,
asOf,
windowDays,
@@ -150,7 +153,7 @@ export const GET = apiHandler(
engineCtx,
);
const next = computeNextDose(
med.schedules,
liveSchedules,
asOf,
14,
anchor,
@@ -426,3 +426,147 @@ describe("GET /api/medications/[id]/dose-history — early take (#1028 class)",
}
});
});
/**
* #1028 follow-up — a medication added in the afternoon, dosed 09:00 / 14:00
* / 21:00. The 14:00 dose of the creation day was recorded afterwards on its
* own slot, the 21:00 dose came in through the reminder, and the 09:00 slot
* of that day only ever held the pending placeholder the today projector
* mints for every slot of the day, which the auto-miss pass later stamped.
*
* The 14:00 dose must read as its own slot, taken on time, not as an
* off-schedule take "due" at 21:00; the 09:00 slot, which predates the
* medication and holds no recorded dose, must not appear at all.
*/
describe("GET /api/medications/[id]/dose-history — slots before creation (#1028)", () => {
const cases = [
{ tz: "Asia/Kolkata", edited: false },
{ tz: "America/New_York", edited: false },
// The schedule was saved again shortly after the medication was added,
// so the creation day lives in an archived schedule era.
{ tz: "Asia/Kolkata", edited: true },
];
for (const { tz, edited } of cases) {
it(`attributes a recorded dose on a pre-creation slot and hides the unrecorded one (${tz}${edited ? ", edited schedule" : ""})`, async () => {
const day1 = new Date("2026-09-23T12:00:00Z");
const day2 = new Date("2026-09-24T12:00:00Z");
const createdAt = localHmAsUtc(day1, tz, 16, 5);
const now = localHmAsUtc(day2, tz, 22, 0);
const d1 = (h: number, m = 0) => localHmAsUtc(day1, tz, h, m);
const d2 = (h: number, m = 0) => localHmAsUtc(day2, tz, h, m);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(now);
try {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: tz },
} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
startsOn: new Date("2026-09-23T00:00:00Z"),
endsOn: new Date("2026-09-28T00:00:00Z"),
oneShot: false,
createdAt,
scheduleRevisions: edited
? [
{
id: "rev-1",
validFrom: createdAt,
validUntil: localHmAsUtc(day1, tz, 16, 30),
supersededByRevisionId: null,
payload: [
{
timesOfDay: ["09:00", "14:00", "21:00"],
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
label: null,
dose: "1 tablet",
reminderGraceMinutes: null,
},
],
},
]
: [],
schedules: [
{
id: "sched-1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00", "14:00", "21:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
const row = (
id: string,
scheduledFor: Date,
takenAt: Date | null,
extra: Partial<{ autoMissed: boolean; source: string }> = {},
) => ({
id,
scheduledFor,
takenAt,
skipped: false,
autoMissed: extra.autoMissed ?? false,
attributionSource: "AUTO",
doseTaken: null,
source: extra.source ?? "REMINDER",
});
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
row("d2-2100", d2(21), d2(21, 30)),
row("d2-1400", d2(14), d2(14)),
row("d2-0900", d2(9), d2(10)),
row("d1-2100", d1(21), d1(21)),
row("d1-1400", d1(14), d1(14), { source: "WEB" }),
// The projector's placeholder for the pre-creation 09:00 slot,
// stamped by the auto-miss pass a day later.
row("d1-0900-placeholder", d1(9), null, { autoMissed: true }),
] as never);
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
const res = await GET(
getReq(
`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(now.toISOString())}`,
),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const json = await res.json();
const rows = json.data.rows as Array<{
kind: string;
at: string;
timeOfDay: string | null;
status: string;
nearestSlot?: unknown;
intake: { id: string | null } | null;
}>;
expect(
rows.map((r) => [r.at, r.kind, r.status, r.intake?.id ?? null]),
).toEqual([
[d1(14).toISOString(), "slot", "taken_on_time", "d1-1400"],
[d1(21).toISOString(), "slot", "taken_on_time", "d1-2100"],
[d2(9).toISOString(), "slot", "taken_on_time", "d2-0900"],
[d2(14).toISOString(), "slot", "taken_on_time", "d2-1400"],
[d2(21).toISOString(), "slot", "taken_on_time", "d2-2100"],
]);
} finally {
vi.useRealTimers();
}
});
}
});
@@ -40,6 +40,7 @@ import {
type WorkerScheduleRow,
} from "@/lib/medications/scheduling/worker-helpers";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { dueSchedules } from "@/lib/medications/intake-tracking";
type RouteParams = { params: Promise<{ id: string }> };
@@ -134,26 +135,23 @@ export const GET = apiHandler(
return apiError("Medication not found", 404);
}
// Resolve the window. Default to the trailing 90 days; clamp the floor to
// the medication's creation so pre-existence days never mint phantom
// slots, and cap the span so a pathological `from=1970` can't expand a
// year-plus of bands. `to` defaults to `now`.
// Resolve the window. Default to the trailing 90 days and cap the span
// so a pathological `from=1970` can't expand a year-plus of bands. `to`
// defaults to `now`.
const to = parsed.data.to ?? now;
const requestedFrom =
parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS);
const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS);
// The window the caller asked for, capped only by the span limit. This is
// the floor for recorded doses: a dose logged with a time before the
// medication was added (created at 16:20, "taken at 14:00") is a real
// record and belongs in the window, exactly as the unwindowed full
// history shows it (#1028).
const intakeFrom = new Date(
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
);
// The floor for minted slots additionally stops at the medication's
// creation, so pre-existence days never mint phantom missed slots.
// The window the caller asked for, capped only by the span limit. Both
// recorded doses and minted slots use it: a dose logged for a slot
// before the medication was added (created at 16:20, the 14:00 dose
// recorded afterwards) is a real record and must meet its slot, or it
// reads as an off-schedule take due at the next slot (#1028). Slots
// before the creation that no recorded dose claims stay out of the
// ledger: `reconstructDoseHistory` receives the creation instant as the
// floor of expected slots, so pre-existence days never read as missed.
const from = new Date(
Math.max(intakeFrom.getTime(), medication.createdAt.getTime()),
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
);
const events = await prisma.medicationIntakeEvent.findMany({
@@ -220,7 +218,10 @@ export const GET = apiHandler(
// A legacy daily schedule carrying only `windowStart` surfaces it as the
// single time-of-day so the minter mints its daily band (mirrors the
// compliance route + the ledger tally).
const canonicalSchedules = medication.schedules.map((s) => {
// v1.39.1 (#1033) — with intake tracking off the live era expects
// nothing: the ledger shows the doses that were recorded and the slots
// of archived eras, never a missed slot minted from the record.
const canonicalSchedules = dueSchedules(medication).map((s) => {
const canonical = buildCanonicalSchedule(s as WorkerScheduleRow);
if (
canonical.timesOfDay.length === 0 &&
@@ -264,7 +265,7 @@ export const GET = apiHandler(
// partition, so filter on the stored anchor (a take snapped to an
// out-of-window slot is excluded, matching the compliance read).
const historyIntakes: HistoryIntake[] = mapped
.filter((e) => e.scheduledFor >= intakeFrom && e.scheduledFor <= horizon)
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= horizon)
.map((e) => ({
id: e.id,
scheduledFor: e.scheduledFor,
@@ -276,7 +277,12 @@ export const GET = apiHandler(
source: e.source,
}));
const rows = reconstructDoseHistory(bands, historyIntakes, now);
const rows = reconstructDoseHistory(
bands,
historyIntakes,
now,
medication.createdAt,
);
const serialized: SerializedDoseHistoryRow[] = rows.map((row) => ({
kind: row.kind,
@@ -314,11 +320,10 @@ export const GET = apiHandler(
});
// The window the recorded doses were read over (#1028): from the
// requested start (slots additionally start at the medication's
// creation) to the read horizon, which reaches past `to` only for a dose
// recorded ahead of its slot.
// requested start to the read horizon, which reaches past `to` only for
// a dose recorded ahead of its slot.
return apiSuccess({
from: intakeFrom.toISOString(),
from: from.toISOString(),
to: horizon.toISOString(),
family,
hasExpectedSlots: bands.length > 0,
+11 -6
View File
@@ -46,6 +46,7 @@ import {
shapeDoseChangeNote,
} from "@/lib/crypto/note-cipher";
import { NextRequest } from "next/server";
import { dueSchedules } from "@/lib/medications/intake-tracking";
type RouteParams = { params: Promise<{ id: string }> };
@@ -120,12 +121,16 @@ export const GET = apiHandler(
prefsRow?.notificationPrefs ?? null,
medication.reorderLeadDays,
);
const schedules: RunwaySchedule[] = medication.schedules.map((sched) => ({
...sched,
// Decimal → number so the slot-aware runway math reads the wire shape.
unitsPerDose:
sched.unitsPerDose === null ? null : Number(sched.unitsPerDose),
}));
// v1.39.1 (#1033) — intake tracking off: no dose consumes stock, so no
// runway and no low-stock flag derive from the stored schedule.
const schedules: RunwaySchedule[] = dueSchedules(medication).map(
(sched) => ({
...sched,
// Decimal → number so the slot-aware runway math reads the wire shape.
unitsPerDose:
sched.unitsPerDose === null ? null : Number(sched.unitsPerDose),
}),
);
/** runway ≤ effective trigger AND the alert is enabled. */
const isLowStock = (dosesRemaining: number): boolean => {
if (runwayFloor === null) return false;
+278 -124
View File
@@ -41,6 +41,11 @@ import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { NextRequest } from "next/server";
import {
dueSchedules,
isRecordOnly,
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
type RouteParams = { params: Promise<{ id: string }> };
@@ -106,7 +111,7 @@ export const GET = apiHandler(
// cadences re-anchor on the latest non-skipped intake, so fetch it
// once (no-op for calendar cadences but cheap + keeps the value
// correct for rolling injections).
const lastIntake = medication.schedules.some(
const lastIntake = dueSchedules(medication).some(
(s) => s.rollingIntervalDays !== null,
)
? await prisma.medicationIntakeEvent.findFirst({
@@ -157,6 +162,9 @@ export const GET = apiHandler(
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
// v1.39.1 (#1033) — intake tracking off: nothing is due from the
// stored rows, and no runway derives from them.
const liveSchedules = dueSchedules(medication);
const display = computeDisplayDue({
medication: {
id: medication.id,
@@ -165,7 +173,7 @@ export const GET = apiHandler(
oneShot: medication.oneShot,
createdAt: medication.createdAt,
},
schedules: medication.schedules,
schedules: liveSchedules,
now,
userTz: user.timezone || "Europe/Berlin",
lastIntakeAt: lastIntake?.takenAt ?? null,
@@ -220,14 +228,16 @@ export const GET = apiHandler(
? null
: estimateUnitsRunwayDays(
stockUnitsRemaining,
schedulesDto,
isRecordOnly(medication) ? [] : schedulesDto,
Number(medication.unitsPerDose),
);
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
schedules: schedulesDto,
// v1.39.1 (#1033) — `schedules: []` plus `recordedSchedules` when
// intake tracking is off (see `scheduleWireFields`).
...scheduleWireFields(medication.trackIntake, schedulesDto),
category,
nextDueAt: display ? display.at.toISOString() : null,
nextDueOverdue: display?.overdue ?? false,
@@ -265,6 +275,8 @@ export const PUT = apiHandler(
active: true,
createdAt: true,
asNeeded: true,
// v1.39.1 (#1033) — the tracking transition below needs the before.
trackIntake: true,
// v1.37.0 — the pre-image the audit row carries (C4).
name: true,
dose: true,
@@ -287,6 +299,55 @@ export const PUT = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
// ── v1.39.1 (#1033) — a record-only schedule is only replaced knowingly ──
//
// A medication with intake tracking off is served with `schedules: []`
// and its stored schedule in `recordedSchedules`, so a client that
// predates the field arms no reminder from it. The price is that such a
// client cannot SEE the stored schedule. The shipped iPhone app (1.0.3 /
// 1.0.4) opens a medication without schedule rows in its editor as
// "daily at 08:00"; a rename or dose edit sends no `schedules` (its
// editor diffs against that same prefill), but any touch of a schedule
// field sends a full replacement built from the 08:00 default plus
// `asNeeded` and `oneShot`, and never `trackIntake`. Applied, that would
// overwrite the schedule the person kept on record with one they never
// chose (or flip it to as-needed and delete the rows).
//
// Rule: on a medication whose intake tracking is off, a write that does
// not name `trackIntake` leaves the schedule shape alone. `schedules`,
// `asNeeded` and `oneShot` are dropped from it; every other field (name,
// dose, dates, `active`, `notificationsEnabled`, inventory, ...) applies
// as sent. A client that knows about the switch names it on every
// schedule save (the web editor and wizard do), and those writes apply.
const keepRecordedSchedule =
existing.trackIntake === false &&
parsed.data.trackIntake === undefined &&
(parsed.data.schedules !== undefined ||
parsed.data.asNeeded !== undefined ||
parsed.data.oneShot !== undefined);
const input = keepRecordedSchedule
? {
...parsed.data,
schedules: undefined,
asNeeded: undefined,
oneShot: undefined,
}
: parsed.data;
if (keepRecordedSchedule) {
annotate({
action: {
name: "medication.update.record_schedule_kept",
entity_type: "medication",
entity_id: id,
},
meta: {
dropped_schedules: parsed.data.schedules !== undefined,
dropped_as_needed: parsed.data.asNeeded !== undefined,
dropped_one_shot: parsed.data.oneShot !== undefined,
},
});
}
const {
name,
dose,
@@ -309,8 +370,15 @@ export const PUT = apiHandler(
endsOn,
oneShot,
asNeeded,
trackIntake,
reminderGraceMinutes: topLevelGraceMinutes,
} = parsed.data;
} = input;
// v1.39.1 (#1033) — intake tracking transition. Off keeps the schedule
// as a record; the live era from here on expects nothing.
const wasTracked = existing.trackIntake !== false;
const willTrack = trackIntake ?? wasTracked;
const trackingChanged = wasTracked !== willTrack;
// ── v1.16.11 (#316) — as-needed invariants ──────────────────────
//
@@ -516,123 +584,16 @@ export const PUT = apiHandler(
// the manager's grant ends.
let replacedCadence: string | null = null;
let replacedScheduleCount: number | null = null;
let tombstonedSlotCount: number | null = null;
let tombstonedFrom: Date | null = null;
let tombstonedTo: Date | null = null;
let tombstonedSlotCount = null as number | null;
// Assigned inside `tombstoneOpenSlots`; the casts keep TypeScript from
// narrowing them to `null` across the closure.
let tombstonedFrom = null as Date | null;
let tombstonedTo = null as Date | null;
// If schedules provided, replace all
if (schedules && normalisedSchedules) {
// v1.16.3 — effective dating. Before the wholesale replace below wipes
// the old rows, archive them as ONE revision covering
// `[previous validUntil | medication.createdAt, now)` — but only when a
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
// tab echoing the same rows back) must not mint a phantom era.
const previousRows = await prisma.medicationSchedule.findMany({
where: { medicationId: id },
});
const previousEntries = previousRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
);
replacedScheduleCount = previousRows.length;
replacedCadence =
previousRows.length > 0
? previousRows
.map((row) =>
[
row.timesOfDay,
row.rrule ?? "",
row.rollingIntervalDays !== null
? `every ${row.rollingIntervalDays}d`
: "",
row.daysOfWeek ?? "",
]
.filter((part) => part !== "")
.join(" "),
)
.join(" | ")
: null;
if (
previousRows.length > 0 &&
schedulesMateriallyDiffer(
previousEntries,
normalisedSchedules.map((n) => n.snapshot),
)
) {
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
// Chain from the ACTIVE boundary: a superseded row is an
// audit record whose `validUntil` may sit past its correction.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: previousEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: previousEntries.length },
});
} else if (
previousRows.length === 0 &&
existing.asNeeded &&
asNeeded === false &&
normalisedSchedules.length > 0
) {
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
// schedule rows while as-needed, so the wholesale-replace archive
// above never fires — and without a revision the live era would
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
// retro-painting the schedule-less as-needed stretch with the NEW
// schedule's expected slots: every PRN day would read as missed.
// Archive an EMPTY era covering the as-needed stretch instead; an
// empty payload expands to zero schedules, so era-aware compliance
// expects nothing there — exactly the as-needed contract.
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: [] as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: 0 },
});
}
// Tombstone today's and future open pending rows of this medication.
// Runs on a schedule replace (the old anchors are stale) and when intake
// tracking is switched off (nothing is due any more).
const tombstoneOpenSlots = async (): Promise<void> => {
// A schedule replace invalidates the open slot anchors the projector /
// reminder worker minted for the OLD times: a pending 08:00 row for a
// medication that now doses at 20:00 would linger as a phantom slot,
@@ -703,9 +664,195 @@ export const PUT = apiHandler(
});
}
}
};
// If schedules provided, replace all
if (schedules && normalisedSchedules) {
// v1.16.3 — effective dating. Before the wholesale replace below wipes
// the old rows, archive them as ONE revision covering
// `[previous validUntil | medication.createdAt, now)` — but only when a
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
// tab echoing the same rows back) must not mint a phantom era.
const previousRows = await prisma.medicationSchedule.findMany({
where: { medicationId: id },
});
const previousEntries = previousRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
);
replacedScheduleCount = previousRows.length;
replacedCadence =
previousRows.length > 0
? previousRows
.map((row) =>
[
row.timesOfDay,
row.rrule ?? "",
row.rollingIntervalDays !== null
? `every ${row.rollingIntervalDays}d`
: "",
row.daysOfWeek ?? "",
]
.filter((part) => part !== "")
.join(" "),
)
.join(" | ")
: null;
// v1.39.1 (#1033) — a stretch with intake tracking off expected
// nothing, so it archives with an empty payload whatever the rows
// said; and a tracking transition is itself an era boundary even when
// the rows stay the same.
const archivedEntries = wasTracked ? previousEntries : [];
if (
previousRows.length > 0 &&
(trackingChanged ||
!wasTracked ||
schedulesMateriallyDiffer(
previousEntries,
normalisedSchedules.map((n) => n.snapshot),
))
) {
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
// Chain from the ACTIVE boundary: a superseded row is an
// audit record whose `validUntil` may sit past its correction.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: archivedEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: archivedEntries.length },
});
} else if (
previousRows.length === 0 &&
existing.asNeeded &&
asNeeded === false &&
normalisedSchedules.length > 0
) {
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
// schedule rows while as-needed, so the wholesale-replace archive
// above never fires — and without a revision the live era would
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
// retro-painting the schedule-less as-needed stretch with the NEW
// schedule's expected slots: every PRN day would read as missed.
// Archive an EMPTY era covering the as-needed stretch instead; an
// empty payload expands to zero schedules, so era-aware compliance
// expects nothing there — exactly the as-needed contract.
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: [] as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: 0 },
});
}
await tombstoneOpenSlots();
await prisma.medicationSchedule.deleteMany({
where: { medicationId: id },
});
} else if (trackingChanged) {
// v1.39.1 (#1033) — intake tracking switched without a schedule
// replace. Close the current era at this instant: switching OFF
// archives the schedule that was in force (the tracked stretch keeps
// its expected doses); switching back ON archives the untracked
// stretch with an empty payload, so compliance never counts it as
// missed and the live schedule, the projector and the reminder worker
// all resume from now.
const liveRows = await prisma.medicationSchedule.findMany({
where: { medicationId: id },
});
if (liveRows.length > 0) {
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
const archivedEntries = wasTracked
? liveRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
)
: [];
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: archivedEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: {
schedule_revision_rows: archivedEntries.length,
track_intake: willTrack,
},
});
}
}
// v1.39.1 (#1033) — switching intake tracking off leaves nothing due:
// the open placeholders of today and later go the way a schedule
// replace sends them (a replace in the same request already did it).
if (trackingChanged && !willTrack && !schedules) {
await tombstoneOpenSlots();
}
const baseUpdateData = {
@@ -743,6 +890,8 @@ export const PUT = apiHandler(
// v1.16.11 — as-needed flag, field-by-field (the invariants above
// already guaranteed the medication ends schedule-consistent).
...(asNeeded !== undefined && { asNeeded }),
// v1.39.1 (#1033) — intake tracking, field-by-field.
...(trackIntake !== undefined && { trackIntake }),
...(normalisedSchedules && {
schedules: {
create: normalisedSchedules.map((n) => n.createData),
@@ -849,6 +998,7 @@ export const PUT = apiHandler(
dose: existing.dose,
active: existing.active,
asNeeded: existing.asNeeded,
trackIntake: existing.trackIntake,
endsOn: existing.endsOn,
},
after: {
@@ -856,6 +1006,7 @@ export const PUT = apiHandler(
dose: medication.dose,
active: medication.active,
asNeeded: medication.asNeeded,
trackIntake: medication.trackIntake,
endsOn: medication.endsOn,
},
}),
@@ -886,9 +1037,12 @@ export const PUT = apiHandler(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
schedules: serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
),
),
category: normalizedCategory,
});
@@ -494,6 +494,42 @@ describe("POST /api/medications — as-needed (v1.16.11, #316)", () => {
expect(data.unitsPerDose).toBe(2);
});
// #1034 — a whole number plus a fraction reaches the Prisma write
// unchanged, on the medication and on a per-slot override; a value with
// a fifth decimal place is refused before any write.
it("creates a medication with a mixed units-per-dose value", async () => {
const res = await POST(
postReq({
name: "Ibuprofen",
dose: "400 mg",
category: "PAIN_RELIEF",
unitsPerDose: 1.5,
asNeeded: true,
}),
);
expect(res.status).toBe(201);
expect(lastCreateData().unitsPerDose).toBe(1.5);
});
it("422s a units-per-dose value with more than four decimal places", async () => {
const res = await POST(
postReq({
name: "Ibuprofen",
dose: "400 mg",
category: "PAIN_RELIEF",
unitsPerDose: 1.23456,
asNeeded: true,
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: Array<string | number> }> };
};
expect(
body.details.issues.some((i) => i.path.includes("unitsPerDose")),
).toBe(true);
});
it("still 422s a scheduled create without any schedule (legacy contract)", async () => {
const res = await POST(postReq({ name: "Ramipril", dose: "5 mg" }));
expect(res.status).toBe(422);
@@ -250,6 +250,8 @@ describe("GET /api/medications/compliance", () => {
expect(call.where).toEqual({
userId: "user-1",
asNeeded: false,
// v1.39.1 (#1033) — intake tracking off is left out like PRN.
trackIntake: true,
});
});
+2 -1
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/medications/compliance-payload";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
/**
* Batched card-compliance read: one round trip for every medication the
@@ -50,7 +51,7 @@ export const GET = apiHandler(async () => {
// v1.16.11 — as-needed (PRN) medications carry no compliance entry:
// the cards/table render a last-taken presentation for them instead
// of rates, and an empty expected set must not read as 0% or 100%.
where: { userId: user.id, asNeeded: false },
where: { userId: user.id, asNeeded: false, ...TRACKED_INTAKE_WHERE },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
+4
View File
@@ -54,6 +54,7 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
// The query + body schemas moved to `@/lib/validations/medication` when this
// endpoint joined the published contract: the OpenAPI registry generates from
@@ -125,6 +126,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.7.0 sync — exclude tombstoned rows from the today list.
deletedAt: null,
scheduledFor: { gte: todayStart, lt: todayEnd },
// v1.39.1 (#1033) — the today list is what clients offer doses
// from; a medication with intake tracking off is not on it.
...TRACKED_INTAKE_EVENT_WHERE,
},
orderBy: { scheduledFor: "asc" },
include: { medication: { select: { id: true, snoozedUntil: true } } },
+21 -6
View File
@@ -25,6 +25,7 @@ import { readMedicationsListCached } from "@/lib/medications/list-read";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { withIdempotency } from "@/lib/idempotency";
import { NextRequest } from "next/server";
import { scheduleWireFields } from "@/lib/medications/intake-tracking";
// v1.32.25 — blast-radius cap on externally-mirrored medications per user.
// A client that mints a fresh `externalId` for the same source concept on
@@ -72,6 +73,7 @@ async function respondWithExistingMirror(
medication: Record<string, unknown> & {
id: string;
unitsPerDose: unknown;
trackIntake: boolean;
schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>;
},
): Promise<Response> {
@@ -96,9 +98,13 @@ async function respondWithExistingMirror(
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
schedules: serializeScheduleUnitsPerDose(
medication.schedules,
Number(medication.unitsPerDose),
// v1.39.1 (#1033) — record-only medications ship `schedules: []`.
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
Number(medication.unitsPerDose),
),
),
category,
});
@@ -165,6 +171,7 @@ async function postMedication(request: NextRequest): Promise<Response> {
endsOn,
oneShot,
asNeeded,
trackIntake,
} = parsed.data;
// v1.28 — idempotent mirror create. A mirrored medication (Apple
@@ -304,6 +311,10 @@ async function postMedication(request: NextRequest): Promise<Response> {
// carries an empty `scheduleInputs`, so the nested create below
// persists zero schedule rows.
...(asNeeded !== undefined && { asNeeded }),
// v1.39.1 (#1033) — intake tracking, field-by-field. Created off,
// the schedule is stored as a record from the start; the column
// default (true) covers every caller that does not send it.
...(trackIntake !== undefined && { trackIntake }),
schedules: {
create: scheduleInputs.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
@@ -426,9 +437,13 @@ async function postMedication(request: NextRequest): Promise<Response> {
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
schedules: serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
// v1.39.1 (#1033) — record-only medications ship `schedules: []`.
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
),
),
category: normalizedCategory,
},
@@ -69,6 +69,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import { nutrientEntryResultSchema } from "@/lib/validations/nutrients";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -449,6 +450,46 @@ describe("POST /api/nutrients/batch — write semantics", () => {
spy.mockRestore();
});
it("marks a response carrying upsert_failed no-store, so a retry with the same Idempotency-Key writes again", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(prisma.nutrientIntakeDay.createMany).mockRejectedValue(
new Error("connection reset"),
);
const failed = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
],
}),
);
expect(failed.status).toBe(200);
expect(failed.headers.get("Cache-Control")).toBe("private, no-store");
spy.mockRestore();
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
count: 1,
} as never);
const clean = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
],
}),
);
expect(clean.headers.get("Cache-Control")).not.toBe("private, no-store");
});
it("documents upsert_failed as a transient write failure to retry, and the validation skips as terminal", () => {
const description = String(
(nutrientEntryResultSchema.meta() as { description?: string })
.description,
);
expect(description).toMatch(/`upsert_failed` is a write failure/);
expect(description).toMatch(/sent again on a later sync/);
expect(description).not.toMatch(/log and drop a skipped entry/i);
expect(description).toMatch(/unit_mismatch/);
});
it("a race that drops one createMany row still counts consistently and is observable", async () => {
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
count: 1,
+13 -2
View File
@@ -18,7 +18,10 @@
* per-row updates (existing pairs), the measurements-batch shape. A write
* failure marks its WHOLE group `skipped upsert_failed` rather than
* isolating the one entry that would have caused it (unavoidable once the
* write is batched) — the request itself never fails.
* write is batched) — the request itself never fails. That reason is
* transient, unlike the validation skips: the client sends those entries
* again, and the response is marked `no-store` so an idempotent retry is not
* answered from the cache.
*
* Module gate FIRST: the opt-in `nutrients` module (default off)
* refuses ingest with the 403 `module.disabled` envelope, so a phone
@@ -360,11 +363,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
},
});
return apiSuccess({
const response = apiSuccess({
processed: entries.length,
inserted,
updated,
skipped,
entries: results,
});
// `upsert_failed` is transient: nothing was stored for those entries and the
// client sends them again. `no-store` keeps the idempotency layer from
// caching this answer, so a retry under the same key writes instead of
// replaying the failure. The measurement batch does the same for `failed`.
if (skippedByReason.upsert_failed) {
response.headers.set("Cache-Control", "private, no-store");
}
return response;
}
@@ -1181,3 +1181,45 @@ describe("Telegram webhook — GET verification is rate-limited first", () => {
);
});
});
/**
* #1033 — a medication kept as a record is never offered, recorded or snoozed
* from Telegram, the same as on the web.
*/
describe("POST /api/telegram/webhook — record-only medications", () => {
function textUpdate(text: string) {
return {
update_id: 300,
message: { message_id: 30, text, chat: { id: 7777 }, from: { id: 7777 } },
};
}
function whereOf(call: unknown): Record<string, unknown> {
return (call as [{ where: Record<string, unknown> }])[0].where;
}
it("/add lists tracked medications only", async () => {
await POST(tgRequest(textUpdate("/add")));
const where = whereOf(vi.mocked(prisma.medication.findMany).mock.calls[0]);
expect(where).toMatchObject({ active: true, trackIntake: true });
});
it("the 'taken' keyword resolves tracked medications only", async () => {
await POST(tgRequest(textUpdate("taken Ramipril")));
const where = whereOf(vi.mocked(prisma.medication.findFirst).mock.calls[0]);
expect(where).toMatchObject({ active: true, trackIntake: true });
});
it("a reminder button on a record-only medication records nothing", async () => {
await POST(tgRequest(callbackUpdate("taken:med-1")));
// The gate lookup: a record-only medication resolves to nothing here,
// so no dose is written (later lookups only bind an admitted dose).
const calls = vi.mocked(prisma.medication.findFirst).mock.calls;
expect(calls.length).toBeGreaterThan(0);
expect(whereOf(calls[0])).toMatchObject({
id: "med-1",
active: true,
trackIntake: true,
});
});
});
@@ -4,6 +4,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
appSettings: { findUnique: vi.fn() },
},
}));
@@ -60,6 +61,12 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(resolveModuleMap).mockResolvedValue(MODULE_MAP as never);
// The operator's default zone, which the server falls back to when a
// stored one is unusable. Distinct from the built-in default so a test can
// tell the resolver ran.
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
defaultUserTimezone: "America/Chicago",
} as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -438,3 +445,81 @@ describe("PATCH /api/user/profile", () => {
]);
});
});
/**
* `/api/auth/me` has returned the zone the server actually cuts days in —
* the stored one, or the instance default when the stored value is unusable
* — while this alias returned the raw column. A client reading this endpoint
* could bucket a day differently from the server. Both now go through the
* same resolution.
*/
describe("/api/user/profile — timezone is the resolved zone", () => {
function profileRow(timezone: string | null) {
return {
username: "testuser",
displayName: null,
email: null,
dateOfBirth: null,
gender: null,
heightCm: null,
locale: null,
timezone,
moodReminderEnabled: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumberEncrypted: null,
};
}
async function getZone(stored: string | null): Promise<string> {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(
profileRow(stored) as never,
);
const res = await callGet(makeGetReq());
expect(res.status).toBe(200);
return ((await res.json()) as { data: { timezone: string } }).data.timezone;
}
it("returns a usable stored zone as stored", async () => {
expect(await getZone("Asia/Tokyo")).toBe("Asia/Tokyo");
});
it("returns the instance default in place of a bare offset", async () => {
expect(await getZone("+05:30")).toBe("America/Chicago");
});
it("returns the instance default in place of a name no runtime knows", async () => {
expect(await getZone("Mars/Olympus_Mons")).toBe("America/Chicago");
});
it("returns the instance default when the row is missing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await callGet(makeGetReq());
const body = (await res.json()) as { data: { timezone: string } };
expect(body.data.timezone).toBe("America/Chicago");
});
it("answers a PATCH that leaves an unusable zone untouched with the resolved one", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.update).mockResolvedValue({
...profileRow("+05:30"),
id: "user-1",
role: "USER",
displayName: "Alex T.",
} as never);
const res = await PATCH(
new NextRequest("http://localhost/api/user/profile", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ displayName: "Alex T." }),
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { timezone: string } };
expect(body.data.timezone).toBe("America/Chicago");
});
});
+6 -2
View File
@@ -20,6 +20,7 @@ import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveModuleMap } from "@/lib/modules/gate";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
export const dynamic = "force-dynamic";
@@ -77,7 +78,10 @@ export const GET = apiHandler(async () => {
gender: dbUser?.gender ?? null,
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
// The zone the server cuts this account's days in, exactly as
// `/api/auth/me` reports it: the stored zone, or the instance default
// when the stored value is unusable. Never the raw column.
timezone: await resolveStoredTimezone(dbUser?.timezone),
timeFormat: dbUser?.timeFormat ?? "AUTO",
dateFormat: dbUser?.dateFormat ?? "AUTO",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
@@ -118,7 +122,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
gender: result.user.gender,
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: result.user.timezone,
timezone: await resolveStoredTimezone(result.user.timezone),
timeFormat: result.user.timeFormat,
dateFormat: result.user.dateFormat,
moodReminderEnabled: result.user.moodReminderEnabled,
+17 -1
View File
@@ -2,6 +2,7 @@
import { useCallback, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
@@ -11,6 +12,7 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
import { VisitsSection } from "@/components/encounters/visits-section";
import { ProceduresSection } from "@/components/encounters/procedures-section";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -29,6 +31,11 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
* did I actually attend — and the page's own sentence already covers both. A
* visit carries no module gate for the same reason this page never had one.
*
* v1.39.1 — a third tab, the procedure and surgery history. It is the visits
* list read one way (the visits of kind PROCEDURE, searchable by body site),
* so it sits beside the visits rather than behind a nav entry of its own, and
* its read hangs off the same `["encounters"]` root the pull already evicts.
*
* The pull invalidates both reads. Refreshing one and leaving the other stale
* is how a page ends up showing a visit that closed a checkup still listed as
* due.
@@ -38,7 +45,10 @@ export default function CheckupsPageClient() {
const mounted = useMounted();
const queryClient = useQueryClient();
const { t } = useTranslations();
const [view, setView] = useState("vorsorge");
// A link to one visit (`?visit=<id>`, from a document's link chip) lands on
// the visits tab, where the section opens it.
const linkedVisit = useSearchParams()?.get("visit");
const [view, setView] = useState(linkedVisit ? "visits" : "vorsorge");
const refresh = useCallback(
() =>
@@ -65,6 +75,9 @@ export default function CheckupsPageClient() {
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
{t("checkups.tabVisits")}
</TabsTrigger>
<TabsTrigger value="procedures" data-testid="checkups-tab-procedures">
{t("checkups.tabProcedures")}
</TabsTrigger>
</TabsList>
<TabsContent value="vorsorge">
<VorsorgeSection enabled={isAuthenticated} variant="page" />
@@ -74,6 +87,9 @@ export default function CheckupsPageClient() {
for a person who never opens it. */}
<VisitsSection enabled={isAuthenticated} />
</TabsContent>
<TabsContent value="procedures">
<ProceduresSection enabled={isAuthenticated} />
</TabsContent>
</Tabs>
</div>
);
+27 -19
View File
@@ -105,6 +105,12 @@ interface Medication {
oneShot?: boolean;
/** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */
asNeeded?: boolean;
/**
* v1.39.1 (#1033) — false keeps the medication as a record: served with
* `schedules: []` (the stored rows ride `recordedSchedules`), never due,
* and left out of the take-all and log-dose flows.
*/
trackIntake?: boolean;
/** v1.9.0 — optional WHO ATC classification code for the FHIR export. */
atcCode?: string | null;
/** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */
@@ -647,25 +653,27 @@ export default function MedicationsPageClient() {
<LogIntakeDialog
open={logIntakeOpen}
onOpenChange={setLogIntakeOpen}
medications={activeMeds.map((m) => ({
id: m.id,
name: m.name,
dose: m.dose,
active: m.active,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
nextDueAt: m.nextDueAt,
nextDueOverdue: m.nextDueOverdue,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
daysOfWeek: s.daysOfWeek,
label: s.label,
dose: s.dose,
timesOfDay: s.timesOfDay,
doseWindows: s.doseWindows,
})),
}))}
medications={activeMeds
.filter((m) => m.trackIntake !== false)
.map((m) => ({
id: m.id,
name: m.name,
dose: m.dose,
active: m.active,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
nextDueAt: m.nextDueAt,
nextDueOverdue: m.nextDueOverdue,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
daysOfWeek: s.daysOfWeek,
label: s.label,
dose: s.dose,
timesOfDay: s.timesOfDay,
doseWindows: s.doseWindows,
})),
}))}
/>
)}
</div>
+8 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { Suspense, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
@@ -42,5 +42,11 @@ export default function VaccinationsPage() {
return <PageAuthGate />;
}
return <VaccinationsView />;
// The view reads `?dose=` through `useSearchParams`, which needs a
// boundary on a statically rendered page.
return (
<Suspense fallback={<PageAuthGate />}>
<VaccinationsView />
</Suspense>
);
}
@@ -801,6 +801,8 @@ describe("a visit row's edit sheet", () => {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
@@ -0,0 +1,179 @@
/**
* What the backups console shows while a restore runs and after it ends.
*
* The restore runs as a background job; this is the only place an operator
* learns how it is going. Pinned here:
* 1. a running restore names its phase with the counts and draws a bar
* only where the share is known, and says the account still shows its
* current data;
* 2. a failed restore says what went wrong and that nothing changed, except
* for the one failure after the commit, where saying so would be false;
* 3. a succeeded restore reports through the outcome module, so an empty
* file restored to an empty account is not drawn as a tick;
* 4. the panels shown are every active job and the newest finished one not
* dismissed;
* 5. the copy resolves in more than one locale.
*
* Mutation check: always print the "nothing was changed" line and case 2's
* after-commit test goes red; render the bar in every phase and case 1's
* clearing test goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
RestoreJobStatus,
visibleRestoreJobs,
} from "@/components/admin/backup-restore-status";
import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/backups",
}));
function job(overrides: Partial<BackupRestoreJobView>): BackupRestoreJobView {
return {
id: "job-1",
userId: "user-1",
username: "alex",
backupId: "backup-1",
restoreInstanceSettings: false,
status: "running",
phase: "measurements",
progress: {
measurementsChecked: 1_250_000,
measurementsTotal: 1_250_000,
measurementsWritten: 400_000,
sectionsDone: 0,
sectionsTotal: 17,
},
result: null,
failure: null,
attempts: 1,
createdAt: "2026-09-25T08:00:00.000Z",
startedAt: "2026-09-25T08:00:01.000Z",
completedAt: null,
...overrides,
};
}
function render(view: BackupRestoreJobView, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<RestoreJobStatus job={view} onDismiss={() => {}} />
</I18nProvider>,
);
}
describe("RestoreJobStatus while it runs", () => {
it("names the phase with its counts and draws the share done", () => {
const html = render(job({}));
expect(html).toContain("Restoring alex");
expect(html).toContain("Writing readings: 400,000 of 1,250,000");
expect(html).toContain('data-slot="restore-job-progress"');
expect(html).toContain("keeps showing its current data");
});
it("draws no bar where the share is not known", () => {
const html = render(job({ phase: "clearing" }));
expect(html).toContain("Clearing the account");
expect(html).not.toContain('data-slot="restore-job-progress"');
});
it("says a queued restore is waiting", () => {
const html = render(job({ status: "queued", phase: null, progress: null }));
expect(html).toContain("waiting to start");
});
it("says when it was started again after a restart", () => {
expect(render(job({ attempts: 2 }))).toContain("Started again");
});
});
describe("RestoreJobStatus once it failed", () => {
it("says why, and that nothing changed", () => {
const html = render(
job({
status: "failed",
phase: null,
failure: { code: "interrupted", message: "server text" },
}),
);
expect(html).toContain("did not complete");
expect(html).toContain("server restarted");
expect(html).toContain("Nothing was changed");
expect(html).toContain('data-failure-code="interrupted"');
});
it("does not claim nothing changed when the data was restored", () => {
const html = render(
job({
status: "failed",
phase: null,
failure: { code: "failed_after_commit", message: "server text" },
}),
);
expect(html).toContain("The data was restored");
expect(html).not.toContain("Nothing was changed");
});
});
describe("RestoreJobStatus once it succeeded", () => {
const result = {
summary: { measurements: 1_250_000, medications: 3 },
skipped: { links: 0, catalogueKeys: [] },
cleared: { measurements: 10 },
};
it("reports the count through the outcome module", () => {
const html = render(
job({
status: "succeeded",
phase: null,
result,
completedAt: "2026-09-25T08:02:00.000Z",
}),
);
expect(html).toContain("Restore of alex finished");
expect(html).toContain("1,250,003 records restored");
expect(html).toContain('data-outcome="success"');
});
it("does not draw a tick for a file that restored nothing", () => {
const html = render(
job({
status: "succeeded",
phase: null,
result: { ...result, summary: {} },
}),
);
expect(html).toContain('data-outcome="empty"');
});
});
describe("visibleRestoreJobs", () => {
it("shows every active job and the newest finished one not dismissed", () => {
const jobs = [
job({ id: "running", status: "running" }),
job({ id: "newest-done", status: "succeeded" }),
job({ id: "older-done", status: "failed" }),
];
expect(visibleRestoreJobs(jobs, new Set()).map((j) => j.id)).toEqual([
"running",
"newest-done",
]);
expect(
visibleRestoreJobs(jobs, new Set(["newest-done"])).map((j) => j.id),
).toEqual(["running", "older-done"]);
});
});
describe("RestoreJobStatus in another language", () => {
it("speaks German", () => {
const html = render(job({}), "de");
expect(html).toContain("alex wird wiederhergestellt");
expect(html).toContain("Messwerte werden geschrieben");
});
});
@@ -3,7 +3,7 @@
*
* The server refuses a file whose manifest claims a section the file does not
* carry, before it deletes anything. That refusal reaches a person through the
* `meta.errorCode` on the error envelope, and a bare "restore failed" toast
* `code` on the restore job's failure, and a bare "restore failed" toast
* would waste it: the operator on the other end is frequently holding the only
* copy of an account, and the two facts they can act on — which section is
* missing, and that nothing was changed — have to stay on screen.
@@ -14,7 +14,7 @@
* 3. no sections means no notice — a panel that appears after every restore
* trains the operator to ignore it,
* 4. the copy resolves in more than one locale,
* 5. `missingSectionsOf` keys on the errorCode and not on the message text,
* 5. `missingSectionsOf` keys on the failure code and not on the message text,
* so rewording the server's prose cannot silently remove the panel.
*
* Mutation check: render a count instead of the list and case 1 goes red; drop
@@ -30,7 +30,6 @@ import {
RestoreRefusalNotice,
missingSectionsOf,
} from "@/components/admin/backups-section";
import { ApiError } from "@/lib/api/api-fetch";
import type { MissingBackupSection } from "@/lib/export/restore-skips";
vi.mock("next/navigation", () => ({
@@ -79,33 +78,24 @@ describe("RestoreRefusalNotice", () => {
});
describe("missingSectionsOf", () => {
it("reads the sections off the refusal envelope", () => {
const err = new ApiError("Backup is missing documents", 422, {
errorCode: "backup.section.missing",
sections: ["documents"],
});
expect(missingSectionsOf(err)).toEqual(["documents"]);
it("reads the sections off the failed job", () => {
expect(
missingSectionsOf({
code: "backup.section.missing",
sections: ["documents"],
}),
).toEqual(["documents"]);
});
it("ignores an error whose message merely looks like one", () => {
it("ignores a failure whose message merely looks like one", () => {
// The message is prose the server may reword. Only the code counts.
const err = new ApiError("Backup is missing documents", 422, {
errorCode: "something_else",
sections: ["documents"],
});
expect(missingSectionsOf(err)).toEqual([]);
expect(missingSectionsOf(new Error("Backup is missing documents"))).toEqual(
[],
);
expect(
missingSectionsOf({ code: "something_else", sections: ["documents"] }),
).toEqual([]);
expect(missingSectionsOf(null)).toEqual([]);
});
it("survives a refusal with no section list", () => {
const err = new ApiError("Refused", 422, {
errorCode: "backup.section.missing",
});
expect(missingSectionsOf(err)).toEqual([]);
expect(missingSectionsOf({ code: "backup.section.missing" })).toEqual([]);
});
});
@@ -130,10 +130,10 @@ describe("BackupsSection — empty state", () => {
const className = helpElement?.match(/\bclass="([^"]*)"/)?.[1] ?? "";
expect(english).toContain(
"JSON file matching the current backup schema. Max 10 MB.",
"A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.",
);
expect(german).toContain(
"JSON-Datei passend zum aktuellen Backup-Schema. Max. 10 MB.",
"HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
);
expect(helpElement).toBeDefined();
expect(className.split(/\s+/)).toContain("text-muted-foreground");
@@ -0,0 +1,311 @@
"use client";
/**
* `<RestoreJobStatus>` — one restore job as the backups console shows it.
*
* A restore runs on the server as a background job (see
* `src/lib/jobs/backup-restore.ts`). The console polls
* `GET /api/admin/backups/restores` while a job is queued or running and
* renders each one here: what it is doing and how far it has got, then how it
* ended. Because the list comes from the server, a reload during a restore
* picks it up again where the page left it.
*
* Three rules, each for a way the synchronous restore misled an operator:
*
* - A running restore says the account still shows its current data. It
* does: nothing the restore writes is visible until it commits.
* - A failed restore says whether anything changed, and every failure but
* one changed nothing. It also says the backup is kept, because the next
* question is "do I have to upload it again", and the answer is no.
* - Only the job's own row decides the outcome. A poll that fails on the
* network is not a failed restore and is never shown as one.
*/
import { AlertTriangle, Loader2 } from "lucide-react";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type {
BackupRestoreFailureCode,
BackupRestoreJobView,
} from "@/lib/jobs/backup-restore";
/** Whether a job still holds its account's one restore slot. */
export function isActiveRestore(job: BackupRestoreJobView): boolean {
return job.status === "queued" || job.status === "running";
}
/**
* The jobs worth a panel: every active one, and the newest finished one the
* operator has not dismissed.
*/
export function visibleRestoreJobs(
jobs: readonly BackupRestoreJobView[],
dismissed: ReadonlySet<string>,
): BackupRestoreJobView[] {
const active = jobs.filter(isActiveRestore);
const finished = jobs.find(
(job) => !isActiveRestore(job) && !dismissed.has(job.id),
);
return finished ? [...active, finished] : active;
}
/** Total records a succeeded restore wrote, from its summary counts. */
function restoredCount(job: BackupRestoreJobView): number {
let total = 0;
for (const value of Object.values(job.result?.summary ?? {})) {
if (typeof value === "number") total += value;
}
return total;
}
/**
* The failure as a sentence in the operator's language.
*
* Literal `t()` calls, so `i18n-call-site-coverage.test.ts` sees every key.
* The `never` at the foot makes the compiler say the chain is exhaustive: a
* failure code added on the server without a sentence here stops the build.
*/
function failureText(
code: BackupRestoreFailureCode,
t: ReturnType<typeof useTranslations>["t"],
): string {
switch (code) {
case "backup_not_found":
return t("admin.section.backups.restoreFailureNotFound");
case "backup_changed":
return t("admin.section.backups.restoreFailureChanged");
case "backup.payload.undecryptable":
return t("admin.section.backups.restoreFailureUndecryptable");
case "schema_invalid":
return t("admin.section.backups.restoreFailureSchema");
case "incompatible_schema_version":
return t("admin.section.backups.restoreFailureVersion");
case "owner_mismatch":
return t("admin.section.backups.restoreFailureOwnerMismatch");
case "owner_not_found":
return t("admin.section.backups.restoreFailureOwnerMissing");
case "backup.section.missing":
return t("admin.section.backups.restoreFailureSectionMissing");
case "document_ciphertext_missing":
return t("admin.section.backups.restoreFailureDocument");
case "time_budget":
return t("admin.section.backups.restoreFailureTimeBudget");
case "transaction_failed":
return t("admin.section.backups.restoreFailureTransaction");
case "interrupted":
return t("admin.section.backups.restoreFailureInterrupted");
case "not_started":
return t("admin.section.backups.restoreFailureNotStarted");
case "enqueue_failed":
return t("admin.section.backups.restoreFailureEnqueue");
case "failed_after_commit":
return t("admin.section.backups.restoreFailureAfterCommit");
case "unexpected":
return t("admin.section.backups.restoreFailureUnexpected");
default: {
const unhandled: never = code;
return unhandled;
}
}
}
/** What a running job is doing, and the share of it done when that is known. */
function runningLine(
job: BackupRestoreJobView,
t: ReturnType<typeof useTranslations>["t"],
fmt: ReturnType<typeof useFormatters>,
): { text: string; percent: number | null } {
const progress = job.progress;
switch (job.phase) {
case "clearing":
return {
text: t("admin.section.backups.restoreJobPhaseClearing"),
percent: null,
};
case "measurements": {
const total = progress?.measurementsTotal ?? 0;
const done = progress?.measurementsWritten ?? 0;
return {
text: t("admin.section.backups.restoreJobPhaseMeasurements", {
done: fmt.integer(done),
total: fmt.integer(total),
}),
percent: total > 0 ? Math.min(100, (done / total) * 100) : null,
};
}
case "sections": {
const total = progress?.sectionsTotal ?? 0;
const done = progress?.sectionsDone ?? 0;
return {
text: t("admin.section.backups.restoreJobPhaseSections", {
done: fmt.integer(done),
total: fmt.integer(total),
}),
percent: total > 0 ? Math.min(100, (done / total) * 100) : null,
};
}
case "rebuilding":
return {
text: t("admin.section.backups.restoreJobPhaseRebuilding"),
percent: null,
};
case "validating":
default:
return {
text: t("admin.section.backups.restoreJobPhaseValidating", {
checked: fmt.integer(progress?.measurementsChecked ?? 0),
}),
percent: null,
};
}
}
export function RestoreJobStatus({
job,
onDismiss,
}: {
job: BackupRestoreJobView;
onDismiss: () => void;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const username = job.username ?? job.userId;
if (isActiveRestore(job)) {
const line =
job.status === "running"
? runningLine(job, t, fmt)
: {
text: t("admin.section.backups.restoreJobQueuedNote"),
percent: null,
};
return (
<div
role="status"
aria-live="polite"
data-slot="restore-job-status"
data-status={job.status}
className="border-border bg-muted/30 space-y-2 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start gap-2">
<Loader2
className="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium">
{job.status === "queued"
? t("admin.section.backups.restoreJobQueuedTitle", {
username,
})
: t("admin.section.backups.restoreJobRunningTitle", {
username,
})}
</p>
<p>{line.text}</p>
{line.percent !== null ? (
<Progress
value={line.percent}
aria-label={line.text}
data-slot="restore-job-progress"
/>
) : null}
{job.attempts > 1 ? (
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.restoreJobResumed")}
</p>
) : null}
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.restoreJobRunningNote")}
</p>
</div>
</div>
</div>
);
}
if (job.status === "succeeded") {
const count = restoredCount(job);
return (
<div
data-slot="restore-job-status"
data-status={job.status}
className="border-success/40 bg-success/10 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<p className="font-medium">
{t("admin.section.backups.restoreJobSucceededTitle", {
username,
})}
</p>
{/* An empty file restores to an empty account: honest, and not a
tick. The outcome module decides which it looks like. */}
<WrittenOutcomeLine
outcome={count > 0 ? "success" : "empty"}
testId="restore-job-outcome"
message={t(
"admin.section.backups.restoreJobSucceededDescription",
{
count: fmt.integer(count),
when: job.completedAt ? fmt.dateTime(job.completedAt) : "",
},
)}
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={onDismiss}
className="min-h-11"
>
{t("common.dismiss")}
</Button>
</div>
</div>
);
}
const code = job.failure?.code ?? "unexpected";
const changedNothing = code !== "failed_after_commit";
return (
<div
role="alert"
data-slot="restore-job-status"
data-status={job.status}
data-failure-code={code}
className="border-destructive/40 bg-destructive/10 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<div>
<p className="font-medium">
{t("admin.section.backups.restoreJobFailedTitle", { username })}
</p>
<p className="text-xs">{failureText(code, t)}</p>
{changedNothing ? (
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.section.backups.restoreJobFailedKept")}
</p>
) : null}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onDismiss}
className="min-h-11"
>
{t("common.dismiss")}
</Button>
</div>
</div>
);
}
+171 -49
View File
@@ -57,7 +57,13 @@ import type {
RestoreSkipSummary,
SkippedCatalogue,
} from "@/lib/export/restore-skips";
import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore";
import { getApiErrorMessage } from "./_shared";
import {
isActiveRestore,
RestoreJobStatus,
visibleRestoreJobs,
} from "./backup-restore-status";
import {
ApiError,
apiFetch,
@@ -448,16 +454,59 @@ function missingSectionLabel(
* carry, and that nothing was changed — have to survive long enough to be read.
*/
/**
* The refused sections carried on an `ApiError`, or none.
* The refused sections a failed restore job names, or none.
*
* Keyed on `meta.errorCode` and not on the message text: the message is prose
* Keyed on the failure's `code` and not on its message: the message is prose
* the server may reword, and matching on it would turn a copy edit into a
* silent loss of the panel.
*/
export function missingSectionsOf(err: unknown): MissingBackupSection[] {
if (!(err instanceof ApiError)) return [];
if (err.meta?.errorCode !== "backup.section.missing") return [];
const sections = err.meta?.sections;
const DISMISSED_RESTORES_KEY = "healthlog.admin.backups.dismissedRestores";
/** Dismissed restore panels, from this browser. Empty when storage is off. */
function readDismissedRestores(): string[] {
try {
if (typeof window === "undefined") return [];
const raw = window.localStorage.getItem(DISMISSED_RESTORES_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.filter((id): id is string => typeof id === "string")
: [];
} catch {
return [];
}
}
function writeDismissedRestores(ids: string[]): void {
try {
// The list only ever shows a day of jobs; fifty ids is weeks of them.
window.localStorage.setItem(
DISMISSED_RESTORES_KEY,
JSON.stringify(ids.slice(-50)),
);
} catch {
// A private window: the panel comes back on reload, nothing else breaks.
}
}
/**
* The upload body for a backup file: the file itself when it is already
* gzip, otherwise the file compressed in the browser. The server accepts
* both; compressing keeps a large export well inside the request size limit.
*/
async function uploadBody(file: File): Promise<Blob> {
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
if (head[0] === 0x1f && head[1] === 0x8b) return file;
return new Response(
file.stream().pipeThrough(new CompressionStream("gzip")),
).blob();
}
export function missingSectionsOf(
failure: { code: string; sections?: readonly unknown[] } | null | undefined,
): MissingBackupSection[] {
if (failure?.code !== "backup.section.missing") return [];
const sections = failure.sections;
if (!Array.isArray(sections)) return [];
return sections.filter(
(section): section is MissingBackupSection => typeof section === "string",
@@ -697,10 +746,11 @@ export function BackupsSection() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData();
fd.append("file", file);
// apiFetch with a raw init: the multipart body must NOT be JSON
// re-encoded, so the verb helper (which JSON-stringifies) is out.
// Sent as the raw body, compressed on the way unless it already is: the
// server reads it as a stream, and a large export (hundreds of MB of
// JSON) shrinks about tenfold. No request timeout, because a file of
// that size takes longer to check and store than the default allows.
const body = await uploadBody(file);
return apiFetch<{
id: string;
valid: true;
@@ -734,7 +784,12 @@ export function BackupsSection() {
customMetricEntries?: number;
intradayProfiles?: number;
};
}>("/api/admin/backups/upload", { method: "POST", body: fd });
}>("/api/admin/backups/upload", {
method: "POST",
body,
headers: { "Content-Type": "application/gzip" },
signal: null,
});
},
onSuccess: (data) => {
const total =
@@ -800,9 +855,77 @@ export function BackupsSection() {
MissingBackupSection[]
>([]);
// Restore: typed-confirmation dialog. The mutation is keyed by row id
// and used inline by `<RestoreRowDialog>` below — keeping the
// mutation here lets the parent invalidate the list query on success.
// A job this page watched while it ran, and has now seen finish. Only those
// raise a toast and refresh the page's data: a job that had already finished
// when the page opened is shown in its panel and nothing more. Read in the
// poll's own callback, where each answer arrives, rather than in an effect.
const watched = useRef<Map<string, string>>(new Map());
function noticeFinishedRestores(jobs: readonly BackupRestoreJobView[]) {
for (const job of jobs) {
const before = watched.current.get(job.id);
watched.current.set(job.id, job.status);
if (before !== "queued" && before !== "running") continue;
if (job.status === "succeeded") {
setRefusedSections([]);
const report = job.result?.skipped;
if (report && report.links > 0) {
setSkipped(report as RestoreSkipSummary);
toast.warning(
t("admin.section.backups.restoreSkippedToast", {
links: String(report.links),
}),
);
} else {
setSkipped(null);
toast.success(t("admin.section.backups.restoreSuccess"));
}
// Restore touches every personal-data table; nuke the broader cache
// so dashboards / lists rebuild against the new state.
void queryClient.invalidateQueries();
} else if (job.status === "failed") {
setRefusedSections(missingSectionsOf(job.failure));
toast.error(t("admin.section.backups.restoreFailed"));
}
}
}
// The restore jobs, read from the server so a reload during a restore finds
// it again. Polled every two seconds while any is queued or running, and
// not at all otherwise. A poll that fails keeps the last answer on screen:
// a network blip is not a failed restore.
const restores = useQuery({
queryKey: queryKeys.adminBackupRestores(),
queryFn: async () => {
const data = await apiGet<{ jobs: BackupRestoreJobView[] }>(
"/api/admin/backups/restores",
);
noticeFinishedRestores(data.jobs);
return data;
},
refetchInterval: (query) =>
query.state.data?.jobs.some(isActiveRestore) ? 2000 : false,
});
const restoreJobs = restores.data?.jobs ?? [];
const activeOwners = new Set(
restoreJobs.filter(isActiveRestore).map((job) => job.userId),
);
// Finished restores the operator dismissed. Remembered in this browser only,
// for convenience; the list itself forgets a job after a day.
const [dismissedRestores, setDismissedRestores] = useState<Set<string>>(
() => new Set(readDismissedRestores()),
);
function dismissRestore(id: string) {
setDismissedRestores((previous) => {
const next = new Set(previous);
next.add(id);
writeDismissedRestores([...next]);
return next;
});
}
// Restore: typed-confirmation dialog. The request only queues the restore;
// the panel above the list follows it from there.
const restore = useMutation({
mutationFn: async ({
row,
@@ -811,46 +934,35 @@ export function BackupsSection() {
row: BackupRow;
restoreInstanceSettings: boolean;
}) => {
// Idempotency-Key prevents a double-click from re-running the
// destructive transaction. Include the row id so two different
// backups can both be restored independently in the same minute.
// Idempotency-Key prevents a double-click from queuing a second
// restore. Include the row id so two different backups can both be
// restored independently in the same minute.
const idempotencyKey = `restore-${row.id}-${randomId()}`;
return apiPost<{ restored: true; skipped?: RestoreSkipSummary }>(
return apiPost<{ jobId: string; status: "queued" }>(
`/api/admin/backups/${row.id}/restore`,
{ confirm: "RESTORE", restoreInstanceSettings },
{ headers: { "Idempotency-Key": idempotencyKey } },
);
},
onSuccess: (data) => {
// A restore that dropped links is not a plain success and must not read
// as one. The count and the exact keys go on screen; the same report is
// in the audit row for anyone asking later.
setRefusedSections([]);
const report = data.skipped;
if (report && report.links > 0) {
setSkipped(report);
toast.warning(
t("admin.section.backups.restoreSkippedToast", {
links: String(report.links),
}),
);
} else {
setSkipped(null);
toast.success(t("admin.section.backups.restoreSuccess"));
}
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
// Restore touches every personal-data table; nuke the broader
// cache so dashboards / lists rebuild against the new state.
queryClient.invalidateQueries();
setSkipped(null);
watched.current.set(data.jobId, "queued");
// Neutral, not a success: nothing has been restored yet.
toast.info(t("admin.section.backups.restoreStarted"));
void queryClient.invalidateQueries({
queryKey: queryKeys.adminBackupRestores(),
});
},
onError: (err) => {
// The server refuses an incomplete file before it deletes anything, and
// names the sections in `meta`. That is the one restore failure the
// operator can act on, so it gets the panel rather than only a toast.
const sections = missingSectionsOf(err);
if (sections.length > 0) {
setRefusedSections(sections);
toast.error(t("admin.section.backups.restoreMissingTitle"));
if (
err instanceof ApiError &&
err.meta?.errorCode === "backup.restore.active"
) {
toast.error(t("admin.section.backups.restoreActive"));
void queryClient.invalidateQueries({
queryKey: queryKeys.adminBackupRestores(),
});
return;
}
setRefusedSections([]);
@@ -959,7 +1071,7 @@ export function BackupsSection() {
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
accept="application/json,.json,application/gzip,.gz"
className="sr-only"
onChange={handleFileChange}
aria-label={t("admin.section.backups.uploadButton")}
@@ -981,6 +1093,14 @@ export function BackupsSection() {
</div>
</div>
{visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => (
<RestoreJobStatus
key={job.id}
job={job}
onDismiss={() => dismissRestore(job.id)}
/>
))}
<RestoreRefusalNotice
sections={refusedSections}
onDismiss={() => setRefusedSections([])}
@@ -1112,8 +1232,9 @@ export function BackupsSection() {
<RestoreRowDialog
row={row}
pending={
restore.isPending &&
restore.variables?.row.id === row.id
(restore.isPending &&
restore.variables?.row.id === row.id) ||
activeOwners.has(row.userId)
}
onConfirm={(options) =>
restore.mutate({ row, ...options })
@@ -1175,8 +1296,9 @@ export function BackupsSection() {
<RestoreRowDialog
row={row}
pending={
restore.isPending &&
restore.variables?.row.id === row.id
(restore.isPending &&
restore.variables?.row.id === row.id) ||
activeOwners.has(row.userId)
}
onConfirm={(options) =>
restore.mutate({ row, ...options })
@@ -46,6 +46,7 @@ import { EmergencySection, PatientIdentitySection } from "./identity-sections";
import {
IllnessSection,
ImmunizationsSection,
SurgicalHistorySection,
LabResultsSection,
VisitsSection,
} from "./history-sections";
@@ -256,6 +257,12 @@ export function ClinicianView({
scope={scope}
fmtDate={fmtDate}
/>
<SurgicalHistorySection
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
/>
<ImmunizationsSection
t={t}
report={report}
+53 -3
View File
@@ -1,6 +1,6 @@
/**
* The `labs` and `history` groups on the clinician view: lab results, illness
* episodes, visits and the immunization record.
* episodes, visits, the surgical history and the immunization record.
*
* All four were selectable on a share link and none of them reached the page,
* while the PDF download from the same link printed every one. What each
@@ -12,8 +12,11 @@
* A pure server component: no client hooks, no session, no markdown — every
* value renders as escaped React text.
*/
import type { EncounterKind } from "@/generated/prisma/client";
import { encounterKindLabelKey } from "@/lib/encounters/kind-label";
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
import {
encounterKindLabelKey,
lateralityLabelKey,
} from "@/lib/encounters/kind-label";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import {
@@ -221,6 +224,53 @@ export function VisitsSection({
);
}
/**
* The surgical history. Reference data, not window-bounded, like the
* immunization record below: every procedure that happened, oldest first. What
* was done is the row's label; the date, the site with its side and the
* outcome compose onto the value side, the shape the immunization rows use.
*/
export function SurgicalHistorySection({
t,
report,
scope,
fmtDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
}) {
const procedures = report.surgicalHistory ?? [];
const site = (row: (typeof procedures)[number]) => {
if (!row.bodySite) return null;
if (!row.laterality) return row.bodySite;
return t("encounters.bodySiteWithSide", {
site: row.bodySite,
side: t(lateralityLabelKey(row.laterality as Laterality)),
});
};
return (
<LeafSection
t={t}
scope={scope}
leaves={["SURGICAL_HISTORY"]}
title={t("doctorReport.surgicalHistoryTitle")}
empty={procedures.length === 0}
>
{procedures.map((row, index) => (
<StatRow
key={`${row.occurredAt}-${index}`}
label={row.procedure ?? "—"}
value={compose([fmtDate(row.occurredAt), site(row), row.outcome])}
/>
))}
</LeafSection>
);
}
/**
* The immunization record. Reference data, not window-bounded: an Impfpass is
* a lifetime document and the whole of it rides when the leaf and the module
@@ -28,6 +28,15 @@ function day(date: string, overrides: Partial<CalendarDay> = {}): CalendarDay {
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
cervixPosition: null,
cervixFirmness: null,
cervixOpening: null,
intermenstrualBleeding: false,
sexualActivity: false,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
hasNote: false,
...overrides,
};
}
@@ -28,9 +28,27 @@ function dayBase(date: string): CalendarDay {
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
cervixPosition: null,
cervixFirmness: null,
cervixOpening: null,
intermenstrualBleeding: false,
sexualActivity: false,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
hasNote: false,
};
}
/** The opening tag of one day's cell, by its date in the aria-label. */
function cellTag(html: string, date: string): string {
const match = html.match(
new RegExp(`<(?:button|div)[^>]*aria-label="${date}[^"]*"[^>]*>`),
);
if (!match) throw new Error(`no cell for ${date}`);
return match[0];
}
describe("<CycleCalendar>", () => {
const today = "2026-06-15";
@@ -130,4 +148,85 @@ describe("<CycleCalendar>", () => {
);
expect(html).toContain('data-fertile="true"');
});
describe("per-day entries beyond period and symptoms (#1032)", () => {
it("marks an intercourse day with its own marker and names it", () => {
const days = [{ ...dayBase("2026-06-05"), sexualActivity: true }];
const html = render(
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
);
const cell = cellTag(html, "2026-06-05");
expect(cell).toContain('data-intercourse="true"');
expect(cell).toContain("Intercourse");
// Only that day.
expect(cellTag(html, "2026-06-06")).not.toContain("data-intercourse");
expect(html).toContain('data-slot="cycle-calendar-intercourse"');
});
it("the legend names intercourse and the other-entries marker", () => {
const html = render(
<CycleCalendar days={[]} today={today} onSelectDay={() => {}} />,
);
expect(html).toContain('data-legend="cycle.calendar.legendIntercourse"');
expect(html).toContain('data-legend="cycle.calendar.legendOtherEntries"');
expect(html).toContain("Intercourse");
expect(html).toContain("Other entries");
});
it.each([
[
"a pregnancy test",
{ pregnancyTest: "POSITIVE" },
"Pregnancy test (Positive)",
],
[
"a progesterone test",
{ progesteroneTest: "NEGATIVE" },
"Progesterone test (Negative)",
],
["contraception", { contraceptive: "ORAL" }, "Contraceptive (Pill)"],
[
"spotting",
{ intermenstrualBleeding: true },
"Spotting between periods",
],
["a note", { hasNote: true }, "Note"],
["a temperature", { basalBodyTempC: 36.6 }, "Basal body temperature"],
[
"an ovulation test",
{ ovulationTest: "POSITIVE_LH_SURGE" },
"Ovulation test (LH surge)",
],
[
"a mucus reading",
{ cervicalMucus: "CREAMY" },
"Cervical mucus (Creamy)",
],
["a cervix sign", { cervixPosition: "HIGH" }, "Cervix"],
] as const)(
"a day with only %s logged no longer looks empty",
(_label, over, aria) => {
const days = [{ ...dayBase("2026-06-08"), ...over } as CalendarDay];
const html = render(
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
);
const cell = cellTag(html, "2026-06-08");
expect(cell).toContain('data-other-entries="true"');
expect(cell).toContain(aria);
},
);
it("an empty day carries neither marker", () => {
const html = render(
<CycleCalendar
days={[dayBase("2026-06-08")]}
today={today}
onSelectDay={() => {}}
/>,
);
const cell = cellTag(html, "2026-06-08");
expect(cell).not.toContain("data-other-entries");
expect(cell).not.toContain("data-intercourse");
});
});
});
@@ -45,6 +45,15 @@ function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
cervixPosition: null,
cervixFirmness: null,
cervixOpening: null,
intermenstrualBleeding: false,
sexualActivity: false,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
hasNote: false,
...over,
};
}
@@ -281,6 +281,15 @@ function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
cervixPosition: null,
cervixFirmness: null,
cervixOpening: null,
intermenstrualBleeding: false,
sexualActivity: false,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
hasNote: false,
...over,
};
}
+120 -14
View File
@@ -12,6 +12,7 @@ import type { CalendarDay } from "./types";
import {
FERTILE_HUE,
FLOW_HUE,
INTERCOURSE_HUE,
OVULATION_HUE,
flowOpacity,
} from "./phase-tokens";
@@ -25,7 +26,11 @@ import {
* single dated dot); the fertile window (already goal-gated server-side — the
* API nulls it unless TRYING_TO_CONCEIVE) renders as a green ring; the
* predicted-ovulation day gets an amber marker; per-day symptom presence
* shows a small dot. Selecting a day calls `onSelectDay` (the log-day sheet).
* shows a small dot; intercourse shows a diamond; anything else logged that
* day (a test, contraception, spotting, a temperature, a mucus or cervix
* reading, a note) shows a hollow ring, so a day with only one of those
* logged no longer looks like a day with nothing logged. Selecting a day
* calls `onSelectDay` (the log-day sheet).
*
* a11y: each day is a real `<button>` with an aria-label restating the date
* + its markers; the colour markers are paired with the aria text so the
@@ -60,6 +65,64 @@ function mondayIndex(d: Date): number {
return (d.getDay() + 6) % 7;
}
type Translate = ReturnType<typeof useTranslations>["t"];
/** `LABEL (VALUE)`, or the bare label when there is no value to name. */
function named(t: Translate, labelKey: string, valueKey?: string): string {
return valueKey ? `${t(labelKey)} (${t(valueKey)})` : t(labelKey);
}
/**
* The entries a day holds beyond flow, symptoms, intercourse and the
* predictions, each spelled out for the cell's aria-label. Empty means the
* other-entries ring stays off. Every field here is one the day log can hold
* and the grid has no marker of its own for.
*/
export function otherEntryLabels(day: CalendarDay, t: Translate): string[] {
const out: string[] = [];
if (day.intermenstrualBleeding)
out.push(t("cycle.sheet.intermenstrualBleeding"));
if (day.basalBodyTempC != null) out.push(t("cycle.sheet.temperature"));
if (day.ovulationTest)
out.push(
named(
t,
"cycle.sheet.ovulationTest",
`cycle.ovulationTest.${day.ovulationTest}`,
),
);
if (day.cervicalMucus)
out.push(named(t, "cycle.sheet.mucus", `cycle.mucus.${day.cervicalMucus}`));
if (day.cervixPosition || day.cervixFirmness || day.cervixOpening)
out.push(t("cycle.sheet.cervix"));
if (day.pregnancyTest)
out.push(
named(
t,
"cycle.sheet.pregnancyTest",
`cycle.testResult.${day.pregnancyTest}`,
),
);
if (day.progesteroneTest)
out.push(
named(
t,
"cycle.sheet.progesteroneTest",
`cycle.testResult.${day.progesteroneTest}`,
),
);
if (day.contraceptive)
out.push(
named(
t,
"cycle.sheet.contraceptive",
`cycle.contraceptive.${day.contraceptive}`,
),
);
if (day.hasNote) out.push(t("cycle.sheet.note"));
return out;
}
export interface CycleCalendarProps {
days: CalendarDay[];
/** YYYY-MM-DD of today (server tz-anchored) for the "today" ring. */
@@ -222,6 +285,11 @@ export function CycleCalendar({
markers.push(t("cycle.calendar.legendOvulation"));
if (info?.hasSymptoms)
markers.push(t("cycle.calendar.legendSymptoms"));
const hasIntercourse = !!info?.sexualActivity;
if (hasIntercourse)
markers.push(t("cycle.calendar.legendIntercourse"));
const others = info ? otherEntryLabels(info, t) : [];
markers.push(...others);
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
const flowLevel =
@@ -296,7 +364,7 @@ export function CycleCalendar({
oval lives behind the number, so it needs no row dot. */}
<span
aria-hidden="true"
className="relative z-10 mt-0.5 flex h-1.5 items-center gap-0.5"
className="relative z-10 mt-0.5 flex h-2 items-center gap-0.5"
>
{isPredictedOvulationDot ? (
<span
@@ -307,6 +375,19 @@ export function CycleCalendar({
{info?.hasSymptoms ? (
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
) : null}
{hasIntercourse ? (
<span
data-slot="cycle-calendar-intercourse"
className="mx-px size-2 rotate-45"
style={{ backgroundColor: INTERCOURSE_HUE }}
/>
) : null}
{others.length > 0 ? (
<span
data-slot="cycle-calendar-other"
className="border-muted-foreground size-1.5 rounded-full border"
/>
) : null}
</span>
</>
);
@@ -330,6 +411,8 @@ export function CycleCalendar({
: isPredictedOvulationDot
? "predicted"
: undefined,
"data-intercourse": hasIntercourse ? "true" : undefined,
"data-other-entries": others.length > 0 ? "true" : undefined,
};
return onSelectDay ? (
@@ -363,11 +446,20 @@ function CalendarLegend() {
// Each swatch mirrors its grid affordance: period = filled, predicted =
// dashed, fertile = a soft band fill, predicted-ovulation = a filled dot,
// confirmed-ovulation = a ringed light oval (distinct from the dot), symptom
// = the small grey marker dot the grid draws (QA M4).
// = the small grey marker dot the grid draws (QA M4), intercourse = the
// diamond, other entries = the hollow ring.
const items: {
hue: string;
labelKey: string;
variant: "fill" | "dashed" | "band" | "oval" | "dot";
variant:
| "fill"
| "dashed"
| "band"
| "oval"
| "dot"
| "smallDot"
| "diamond"
| "ring";
}[] = [
{ hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod", variant: "fill" },
{
@@ -393,7 +485,17 @@ function CalendarLegend() {
{
hue: "var(--muted-foreground)",
labelKey: "cycle.calendar.legendSymptom",
variant: "dot",
variant: "smallDot",
},
{
hue: INTERCOURSE_HUE,
labelKey: "cycle.calendar.legendIntercourse",
variant: "diamond",
},
{
hue: "var(--muted-foreground)",
labelKey: "cycle.calendar.legendOtherEntries",
variant: "ring",
},
];
return (
@@ -407,14 +509,16 @@ function CalendarLegend() {
<span
aria-hidden="true"
className={cn(
it.variant === "oval"
? "h-2.5 w-3.5 rounded-full ring-[1.5px] ring-inset"
: "rounded-full",
it.variant === "dot" && it.hue === "var(--muted-foreground)"
? "h-1 w-1"
: it.variant === "oval"
? ""
: "h-2.5 w-2.5",
it.variant === "oval" &&
"h-2.5 w-3.5 rounded-full ring-[1.5px] ring-inset",
(it.variant === "fill" ||
it.variant === "dashed" ||
it.variant === "band" ||
it.variant === "dot") &&
"h-2.5 w-2.5 rounded-full",
it.variant === "smallDot" && "h-1 w-1 rounded-full",
it.variant === "diamond" && "mx-0.5 size-2 rotate-45",
it.variant === "ring" && "size-2 rounded-full border",
it.variant === "dashed" && "opacity-60",
it.variant === "band" && "opacity-30",
)}
@@ -425,7 +529,9 @@ function CalendarLegend() {
opacity: 0.3,
"--tw-ring-color": it.hue,
} as React.CSSProperties)
: { backgroundColor: it.hue }
: it.variant === "ring"
? { borderColor: it.hue }
: { backgroundColor: it.hue }
}
/>
{t(it.labelKey)}
+6
View File
@@ -22,6 +22,12 @@ export const FLOW_HUE = "var(--cycle-phase-menstrual)";
export const FERTILE_HUE = "var(--cycle-phase-follicular)";
/** Ovulation marker tint (amber-gold family). */
export const OVULATION_HUE = "var(--cycle-phase-ovulatory)";
/**
* Intercourse marker tint (luteal family). Never colour-only: the marker is a
* diamond, a shape no other calendar marker uses, and the cell's aria-label
* names it.
*/
export const INTERCOURSE_HUE = "var(--cycle-phase-luteal)";
/**
* Flow-intensity shading — a SINGLE-HUE OPACITY LADDER on the menstrual rose
+13
View File
@@ -45,6 +45,19 @@ export interface CalendarDay {
basalBodyTempC: number | null;
ovulationTest: OvulationTest | null;
cervicalMucus: CervicalMucus | null;
/** Logged cervix signs, each or null. */
cervixPosition: string | null;
cervixFirmness: string | null;
cervixOpening: string | null;
/** Spotting outside the period logged on this day. */
intermenstrualBleeding: boolean;
/** Intercourse logged on this day (resolved server-side, envelope included). */
sexualActivity: boolean;
pregnancyTest: string | null;
progesteroneTest: string | null;
contraceptive: string | null;
/** Whether the day carries a note; the text is only on the day-log read. */
hasNote: boolean;
}
export interface CyclePrediction {
@@ -80,6 +80,8 @@ interface Schedule extends ScheduleWindowInput {
export interface MedicationOption extends DefaultMedicationOption {
dose: string;
schedules: Schedule[];
/** v1.39.1 (#1033) — false: kept as a record, not offered for a dose. */
trackIntake?: boolean;
}
interface MedicationIntakeQuickAddProps {
@@ -138,7 +140,9 @@ export function MedicationIntakeQuickAdd({
const medications = useMemo(
() =>
Array.isArray(medicationsRaw)
? medicationsRaw.filter((m) => m.active)
? // v1.39.1 (#1033) — a medication with intake tracking off is
// kept as a record; the dose capture does not offer it.
medicationsRaw.filter((m) => m.active && m.trackIntake !== false)
: [],
[medicationsRaw],
);
@@ -165,4 +165,78 @@ describe("<DocumentRecordLinks>", () => {
expect(html).not.toContain('data-slot="document-vaccination-links-add"');
expect(html).not.toContain('data-slot="document-visit-links-add"');
});
describe("chips open the record they point at (#1024)", () => {
it("a manager's dose chip carries the dose date and opens the dose", () => {
const html = render(
<DocumentRecordLinks
doc={doc({
vaccinationLinks: [
{
vaccinationId: "dose-1",
occurredAt: "1991-04-02T00:00:00.000Z",
catalogSlug: "tetanus",
vaccineName: null,
},
],
})}
canManage
seedFresh
onChange={() => undefined}
/>,
);
const open = html.match(
/<a[^>]*data-slot="document-vaccination-links-chip-open"[^>]*>/,
);
expect(open?.[0]).toContain('href="/vaccinations?dose=dose-1"');
// The date tells two doses of one vaccine apart.
expect(open?.[0]).toBeDefined();
expect(html).toMatch(/>Tetanus<\/span><span[^>]*>04\/02\/1991</);
// The label is not the unlink control; the X is.
expect(html).toContain(
'data-slot="document-vaccination-links-chip-remove"',
);
});
it("a reader's dose chip opens that dose, not the whole list", () => {
const html = render(
<DocumentRecordLinks
doc={doc({
vaccinationLinks: [
{
vaccinationId: "dose-1",
occurredAt: "1991-04-02T00:00:00.000Z",
catalogSlug: null,
vaccineName: "DTP",
},
],
})}
canManage={false}
seedFresh
onChange={() => undefined}
/>,
);
expect(html).toContain('href="/vaccinations?dose=dose-1"');
});
it("a reader's visit chip opens the visit", () => {
const html = render(
<DocumentRecordLinks
doc={doc({
encounterLinks: [
{
encounterId: "enc-1",
kind: "CHECKUP",
occurredAt: "2026-03-01T09:00:00.000Z",
},
],
} as never)}
canManage={false}
seedFresh
onChange={() => undefined}
/>,
);
expect(html).toContain('href="/checkups?visit=enc-1"');
});
});
});
@@ -47,6 +47,7 @@ export function recordOptions(
label: string;
meta?: string | null;
occurredAt: string;
href?: string | null;
}>,
anchor: string | null,
labels: { suggested: string; date: (iso: string) => string },
@@ -59,6 +60,7 @@ export function recordOptions(
label: record.label,
meta: record.meta ?? null,
dateLabel: labels.date(record.occurredAt),
href: record.href ?? null,
};
if (isNearAnchor(record.occurredAt, anchor)) {
near.push({
@@ -73,6 +75,16 @@ export function recordOptions(
return [...near, ...rest];
}
/** Where a visit opens: its sheet on the checkups page. */
function visitHref(encounterId: string): string {
return `/checkups?visit=${encodeURIComponent(encounterId)}`;
}
/** Where a dose opens: its sheet on the vaccinations page. */
function doseHref(vaccinationId: string): string {
return `/vaccinations?dose=${encodeURIComponent(vaccinationId)}`;
}
/** A dose named the way the vaccination page names it. */
function doseName(
t: Translate,
@@ -146,6 +158,7 @@ export function DocumentRecordLinks({
label: encounterKindText(t, visit.kind as EncounterKind),
meta: visit.practitioner?.name ?? null,
occurredAt: visit.occurredAt,
href: visitHref(visit.id),
})),
anchor,
labels,
@@ -155,6 +168,7 @@ export function DocumentRecordLinks({
id: dose.id,
label: doseName(t, dose.catalogEntry?.slug ?? null, dose.vaccineName),
occurredAt: dose.occurredAt,
href: doseHref(dose.id),
})),
anchor,
labels,
@@ -194,7 +208,7 @@ export function DocumentRecordLinks({
{doc.encounterLinks.map((link) => (
<Link
key={link.encounterId}
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
href={visitHref(link.encounterId)}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">
@@ -244,7 +258,7 @@ export function DocumentRecordLinks({
{(doc.vaccinationLinks ?? []).map((link) => (
<Link
key={link.vaccinationId}
href="/vaccinations"
href={doseHref(link.vaccinationId)}
data-slot="document-vaccination-link"
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
@@ -132,6 +132,8 @@ describe("editing never unfiles what the visit already collected", () => {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
links: {
documents: [{ id: "d1", label: "Letter", date: null, redacted: false }],
@@ -216,3 +218,96 @@ describe("the link pickers follow the modules that own their targets", () => {
expect(html).toContain('data-slot="encounter-link-pickers"');
});
});
/**
* v1.39.1 — the body site. Shown for a procedure, where it means something,
* and kept visible on any visit that already carries one, so switching the
* kind away never hides text the person typed. Never required.
*
* Mutation checks (each run, each seen red):
* - show the field only for PROCEDURE → "keeps a typed site visible after
* the kind moves away" goes red;
* - send `bodySite` untrimmed → the whitespace case goes red.
*/
describe("the body site", () => {
function renderDraft(over: Parameters<typeof emptyDraft>[0]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<EncounterForm
draft={emptyDraft({ occurredAt: "2026-08-08T10:00", ...over })}
onChange={() => {}}
isEdit
/>
</I18nProvider>
</QueryClientProvider>,
);
}
it("offers the site and the side for a procedure", () => {
const html = renderDraft({ kind: "PROCEDURE" });
expect(html).toContain('id="encounter-body-site"');
expect(html).toContain('id="encounter-laterality"');
expect(html).toContain("Body site");
expect(html).toContain("Not stated");
});
it("keeps the fields off an ordinary visit", () => {
const html = renderDraft({ kind: "ROUTINE" });
expect(html).not.toContain('id="encounter-body-site"');
});
it("keeps a typed site visible after the kind moves away", () => {
const html = renderDraft({ kind: "HOSPITAL", bodySite: "Knee" });
expect(html).toContain('id="encounter-body-site"');
});
it("offers Procedure or surgery among the kinds", () => {
const html = renderDraft({ kind: "OTHER" });
expect(html).toContain('value="PROCEDURE"');
expect(html).toContain("Procedure or surgery");
});
it("sends the site trimmed and the side, and null for an empty site", () => {
const withSite = draftToBody(
emptyDraft({
occurredAt: "2026-08-08T10:00",
kind: "PROCEDURE",
bodySite: " Knee ",
laterality: "LEFT",
}),
{ isEdit: false },
);
expect(withSite?.bodySite).toBe("Knee");
expect(withSite?.laterality).toBe("LEFT");
const empty = draftToBody(
emptyDraft({ occurredAt: "2026-08-08T10:00", bodySite: " " }),
{ isEdit: false },
);
expect(empty?.bodySite).toBeNull();
expect(empty?.laterality).toBeNull();
});
it("seeds the site and the side from the row being edited", () => {
const draft = draftFromEncounter({
id: "e1",
occurredAt: "2026-08-01T09:00:00.000Z",
status: "DONE",
kind: "PROCEDURE",
practitioner: null,
reason: null,
outcome: null,
bodySite: "Knee",
laterality: "RIGHT",
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
});
expect(draft.bodySite).toBe("Knee");
expect(draft.laterality).toBe("RIGHT");
});
});
@@ -0,0 +1,205 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The procedure history's designed states, pinned by single-pass static
* renders (the repository's component-test convention; typing into the filter
* is driven in the browser check, not here).
*
* - an account with no procedure gets an empty state that says how to make
* one — including switching a visit already on file — and one action;
* - a history lists what the server sent, in the server's order, each row
* naming its body site with the side;
* - the body sites are offered as filter choices, with "All" pressed while
* nothing is filtered;
* - a failed read is an error with a retry, never the empty state.
*
* Mutation checks (each run, each seen red):
* - render `procedures.length === 0` as the account-empty state instead of
* keying it on `total` → "keeps the filter when nothing matches" goes red;
* - drop the side from `bodySiteText` → "names the site with its side" goes
* red.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: {} },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canWrite: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
apiGet: () => new Promise(() => {}),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO, ProcedureListDTO } from "@/lib/encounters/dto";
import { ProceduresSection } from "../procedures-section";
function procedure(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
return {
occurredAt: "2011-03-14T09:00:00.000Z",
status: "DONE",
kind: "PROCEDURE",
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: "2011-03-14T09:00:00.000Z",
updatedAt: "2011-03-14T09:00:00.000Z",
...over,
};
}
function render(data: ProcedureListDTO | "error") {
const queryClient = new QueryClient({
// `retryOnMount: false` so a seeded error stays an error through the
// render instead of being reported as a fresh fetch.
defaultOptions: { queries: { retry: false, retryOnMount: false } },
});
const key = queryKeys.encounterProcedures("", null);
if (data === "error") {
queryClient
.getQueryCache()
.build(queryClient, { queryKey: key })
.setState({
status: "error",
error: new Error("boom"),
fetchStatus: "idle",
});
} else {
queryClient.setQueryData(key, data);
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<ProceduresSection />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<ProceduresSection>", () => {
it("explains how to get a first procedure, including switching a visit", () => {
const html = render({ procedures: [], bodySites: [], total: 0 });
expect(html).toContain("No procedure recorded yet");
expect(html).toContain("switch an existing visit to it");
expect(html).toContain('data-slot="procedures-add"');
expect(html).not.toContain('data-slot="procedures-filter"');
expect(html).not.toContain("Procedures could not be loaded.");
});
it("lists the history in the server's order and names the site with its side", () => {
const html = render({
procedures: [
procedure({
id: "newer",
occurredAt: "2019-07-02T09:00:00.000Z",
reason: "Cholecystectomy",
bodySite: "Gallbladder",
}),
procedure({
id: "older",
reason: "Meniscus repair",
bodySite: "Knee",
laterality: "LEFT",
}),
],
bodySites: [
{ bodySite: "Gallbladder", laterality: null, count: 1 },
{ bodySite: "Knee", laterality: "LEFT", count: 1 },
],
total: 2,
});
expect(html.indexOf('data-encounter-id="newer"')).toBeLessThan(
html.indexOf('data-encounter-id="older"'),
);
expect(html).toContain("Meniscus repair");
expect(html).toContain("Knee · Left");
expect(html).toContain("2 of 2");
});
it("heads a procedure with what was done, and keeps the practice on its own line", () => {
const html = render({
procedures: [
procedure({
id: "p1",
reason: "Meniscus repair",
practitioner: {
id: "pr1",
name: "Sample clinic",
specialty: null,
practice: null,
location: null,
phone: null,
note: null,
createdAt: "2011-01-01T00:00:00.000Z",
updatedAt: "2011-01-01T00:00:00.000Z",
},
}),
],
bodySites: [],
total: 1,
});
expect(html).toMatch(/font-medium">Meniscus repair</);
expect(html).toContain("Sample clinic");
expect(html.match(/Meniscus repair/g)).toHaveLength(1);
});
it("offers every body site as a filter, with All pressed while nothing is filtered", () => {
const html = render({
procedures: [
procedure({ id: "p1", bodySite: "Knee", laterality: "LEFT" }),
],
bodySites: [{ bodySite: "Knee", laterality: "LEFT", count: 1 }],
total: 1,
});
expect(html).toContain('data-slot="procedures-filter"');
expect(html).toMatch(
/data-slot="procedures-site-all"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="procedures-site-all"/,
);
expect(html).toMatch(
/aria-pressed="false"[^>]*>Knee · Left|data-slot="procedures-site"[^>]*aria-pressed="false"/,
);
});
it("keeps the filter when nothing matches, and offers to clear it", () => {
const html = render({
procedures: [],
bodySites: [{ bodySite: "Knee", laterality: null, count: 1 }],
total: 1,
});
expect(html).toContain("Nothing matches this filter");
expect(html).toContain('data-slot="procedures-filter"');
expect(html).not.toContain("No procedure recorded yet");
});
it("paints an error with a retry, never the empty state", () => {
const html = render("error");
expect(html).toContain("Procedures could not be loaded.");
expect(html).not.toContain("No procedure recorded yet");
});
});
@@ -60,6 +60,8 @@ function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
@@ -27,6 +27,7 @@ import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/hooks/use-auth";
@@ -36,8 +37,10 @@ import type { Practitioner } from "@/hooks/use-practitioners";
import {
ENCOUNTER_KINDS,
ENCOUNTER_STATUSES,
LATERALITIES,
encounterKindText,
encounterStatusText,
lateralityText,
} from "./encounter-labels";
import { PractitionerCombobox } from "./practitioner-combobox";
import { EncounterLinkPickers } from "./encounter-link-pickers";
@@ -50,6 +53,9 @@ export interface EncounterDraft {
practitioner: Practitioner | null;
reason: string;
outcome: string;
/** Where on the body; free text, empty when not stated. */
bodySite: string;
laterality: "LEFT" | "RIGHT" | "BOTH" | null;
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
@@ -82,6 +88,8 @@ export function emptyDraft(
practitioner: null,
reason: "",
outcome: "",
bodySite: "",
laterality: null,
documentIds: [],
labResultIds: [],
episodeIds: [],
@@ -97,6 +105,8 @@ export function draftFromEncounter(row: Encounter): EncounterDraft {
practitioner: row.practitioner,
reason: row.reason ?? "",
outcome: row.outcome ?? "",
bodySite: row.bodySite ?? "",
laterality: row.laterality,
documentIds: row.links?.documents.map((link) => link.id) ?? [],
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
@@ -131,6 +141,8 @@ export function draftToBody(
practitionerId: draft.practitioner?.id ?? null,
reason: draft.reason.trim() || null,
outcome: draft.outcome.trim() || null,
bodySite: draft.bodySite.trim() || null,
laterality: draft.laterality,
...(options.reminderId ? { reminderId: options.reminderId } : {}),
documentIds: draft.documentIds,
labResultIds: draft.labResultIds,
@@ -168,6 +180,15 @@ export function EncounterForm({
const [revealed, setRevealed] = useState(false);
const outcomeOpen = revealed || !planned || draft.outcome.length > 0;
// The body site belongs to a procedure, so that is where it is offered. A
// visit that already carries one keeps the fields in view whatever its kind:
// switching the kind away must not hide text the person typed, and the
// server keeps it either way.
const showBodySite =
draft.kind === "PROCEDURE" ||
draft.bodySite.trim().length > 0 ||
draft.laterality !== null;
return (
<div className="space-y-4" data-slot="encounter-form">
<FieldGroup
@@ -234,6 +255,47 @@ export function EncounterForm({
</NativeSelect>
</FieldGroup>
{showBodySite ? (
<div className="grid gap-4 sm:grid-cols-[2fr_1fr]">
<FieldGroup
htmlFor="encounter-body-site"
label={t("encounters.form.bodySite")}
hint={t("encounters.form.bodySiteHint")}
>
<Input
id="encounter-body-site"
value={draft.bodySite}
maxLength={200}
autoComplete="off"
onChange={(event) => patch({ bodySite: event.target.value })}
/>
</FieldGroup>
<FieldGroup
htmlFor="encounter-laterality"
label={t("encounters.form.laterality")}
>
<NativeSelect
id="encounter-laterality"
value={draft.laterality ?? ""}
onChange={(event) =>
patch({
laterality:
(event.target.value as EncounterDraft["laterality"]) ||
null,
})
}
>
<option value="">{t("encounters.laterality.none")}</option>
{LATERALITIES.map((side) => (
<option key={side} value={side}>
{lateralityText(t, side)}
</option>
))}
</NativeSelect>
</FieldGroup>
</div>
) : null}
<FieldGroup
htmlFor="encounter-reason"
label={t("encounters.form.reason")}
Loaded 100 of 344 files, more files were not shown because too many files have changed in this diff. Show more