Compare commits

...
28 Commits
Author SHA1 Message Date
Marc-André Bombeck 384fc9caa1 Merge pull request #285 from MBombeck/fix/multipart-large-upload
fix/v1.15.8.1 — large Apple Health imports
2026-06-07 11:31:57 +02:00
Marc-André Bombeck 059c041586 chore(release): v1.15.8.1 — large Apple Health imports 2026-06-07 11:25:28 +02:00
Marc-André Bombeck caf68aaea7 fix(import): stop the multipart writer from dropping a part body on chunk boundaries
The hand-rolled multipart parser consumed the opening boundary marker and
then inspected the next two bytes inline to classify the suffix ("\r\n" for
another part, "--" for the closing boundary). When a stream chunk ended
exactly on that marker, fewer than two bytes were left in the buffer, so the
branch returned while still in the "preamble" state. The next read re-entered
preamble, searched for the marker again — which was already consumed — and
treated the part headers and the entire file body as preamble to discard,
matching only at the closing boundary. The captured file landed on disk
truncated or empty.

A tiny single-chunk upload never tripped it; a multi-megabyte binary arrives
in hundreds of chunks, so a chunk boundary routinely fell in the dead window
and the saved file came up short. The Apple Health export ingest then failed
its ZIP read with "Could not locate ZIP End-Of-Central-Directory record".

Add a dedicated "after-boundary" parser state that buffers until the two-byte
suffix is present instead of falling back to a marker search, route the
post-body separator through the same state, guard the epilogue state against
the body scanner, and tolerate RFC 2046 transport padding after the marker.
The preamble retain window now keeps boundaryMarker.length - 1 bytes so a
marker split across chunks can still complete. Streaming and the size cap are
unchanged — no part of the upload is buffered whole.

Cover the regression with byte-exact round-trip tests over adversarial
chunkings (1/3/7-byte feeds, chunks ending on the marker and inside the
separator, every single and adjacent split offset) plus an end-to-end check
that a streamed export.zip stays a parseable archive.
2026-06-07 11:23:34 +02:00
Marc-André Bombeck 6965993825 Merge pull request #284 from MBombeck/release/v1.15.8
release/v1.15.8 — medication cards
2026-06-07 01:13:30 +02:00
Marc-André Bombeck 60901ff4fa chore(release): v1.15.8 — medication cards 2026-06-07 01:07:19 +02:00
Marc-André Bombeck 61c35740fc Show the dose-count denominator on the compliance bars
Two identical compliance percentages with no context read as a stuck
display, even when the math is correct (a rolling weekly med on cadence is
~100% on every window by construction; a daily med with full adherence
rounds to ~100% on both 7d and 30d). Render the taken-of-expected count
after each rate (e.g. 100% with 4 / 4 and 100% with 52 / 52) so the two rows
stay distinguishable and trustworthy. Falls back to the bare expected count
when only the denominator is on the wire, and to nothing when neither count
is available.

Thread a per-window taken count onto ComplianceDisplay (short.taken /
long.taken) alongside the existing expectedShort / expectedLong, wire both
through to the shared bars component, and regen the OpenAPI schema.

Add unit tests asserting the windows do not structurally collapse to one
value: a daily med with misses outside the 7-day window scores 7d above 30d,
and a rolling weekly med with a partial-adherence history yields a short rate
above the long rate. Add card-part tests for the count / fallback / no-count
render branches.
2026-06-07 01:06:17 +02:00
Marc-André Bombeck a3410efb5c Unify medication card next/last labels across both variants
The GLP-1 card overrode the shared next/last slot with appointment-phrased
labels (Next / Last, "Nächster Termin" / "Letzter Termin") while the generic
card used Next intake / Last intake. Drop the override so both cards name the
concept identically; the GLP-1 relative-day value phrasing (in N days / today)
is unchanged. Remove the now-dead label override props from the shared slot
and the orphaned glp1NextLabel / glp1LastLabel strings from all locales.
2026-06-07 01:06:05 +02:00
Marc-André Bombeck 781995e382 Merge pull request #283 from MBombeck/release/v1.15.7
release/v1.15.7 — data import in the browser
2026-06-07 00:46:03 +02:00
Marc-André Bombeck 8683190580 test(integration): pin the clock for the slot-collapse cases
The v1.8.2 slot-collapse cases mint a 07:00 pending slot and replay a
taken-write onto it, but a taken-write must never snap onto a future
slot — so the cases failed whenever the suite ran before 07:00 local.
Pin Date (only) to today's local noon for those cases and give the
shared test session a long expiry so it survives the time jump.
2026-06-07 00:40:44 +02:00
Marc-André Bombeck ff18203f0c chore(release): v1.15.7 — data import in the browser 2026-06-07 00:09:16 +02:00
Marc-André Bombeck 4d4dcac505 docs(import): document the data-import paths, point Apple Health import at the new settings area
Add docs/integrations/data-import.md covering the Apple Health
export.zip path, the generic JSON import schema with the full
MeasurementType enum and canonical units, a worked example, and a
CSV-to-JSON conversion how-to. Fix the Apple Health doc to point at
Settings -> Export & Import instead of a non-existent import page. Extend
the export e2e spec with the import surfaces and the example-download
happy path.
2026-06-07 00:07:58 +02:00
Marc-André Bombeck c7bf62e4d9 feat(settings): relabel Export to Export & Import, mount import area, add gated cycle export, slim the health-record card
Relabel the Export section to Export & Import (heading + description,
all six locales; slug unchanged so existing links keep working) and
mount the import area in it. Add a cycle export tile that reuses the
health-record FHIR export with the reproductive section opted in, gated
on cycle tracking being enabled so a non-cycle account never sees it.
Compress the health-record export hero — format and date range share a
row, the practice-name field tightens, and the FHIR note rides the
footer with the generate action — so the card opens denser without
losing any controls. Add the import + cycle i18n keys across all
locales.
2026-06-07 00:07:53 +02:00
Marc-André Bombeck a4c3dd73bc feat(settings): add the import UI for Apple Health and JSON
The Apple Health export.zip and generic JSON import routes shipped
without a web surface (issue #281). Add an import area with two
controls: an Apple Health export.zip drop / picker that streams the
upload and polls the job status to completion, and a generic JSON
control that takes a pasted body or an uploaded .json file, validates it
client-side, and reports the imported / skipped counts. A Download
example button mints a valid example payload as a Blob. Both controls
surface rate-limit and error states and announce progress via aria-live.

Adds the importJobStatus query key to the factory.
2026-06-07 00:07:39 +02:00
Marc-André Bombeck 99dbd70b81 Merge pull request #282 from MBombeck/release/v1.15.6
release/v1.15.6 — settings, insights, and medication-card polish
2026-06-06 23:45:58 +02:00
Marc-André Bombeck ab1eea7f23 test(e2e): align export and integrations specs with removed surfaces
Drop the obsolete doctor-report dialog spec (the interactive export-page
dialog was removed; the report now lives under the health-record export),
remove the doctor-report testid from the export smoke, and update the
integrations layout spec to three cards after the moodLog removal.
2026-06-06 23:40:10 +02:00
Marc-André Bombeck 7176afa9ef chore(cleanup): drop orphaned doctor-report PDF helper after export-page removal 2026-06-06 23:30:39 +02:00
Marc-André Bombeck 29cb6a8f0b chore(release): v1.15.6 — settings, insights, and medication-card polish 2026-06-06 23:28:38 +02:00
Marc-André Bombeck 646ef164ae fix(ui): align the select chevron symmetrically with the value inset
Both select primitives parked the trailing chevron visibly farther from
the right edge than the value text sits from the left. The shadcn trigger
gets a -2 px optical nudge to offset the chevron glyph's internal
padding. The native select drops the OS-drawn arrow (`appearance-none`)
and paints a chevron background at a 12 px right inset mirroring the left
`px-3` padding, kept on the single `<select>` element so call-site width
and margin classes still compose unchanged.
2026-06-06 23:25:52 +02:00
Marc-André Bombeck 0e184e7f1b fix(medications): drop the duplicate next-dose line and the injection-site recommendation from the card
The open-cycle status line repeated the upcoming-dose timing already
shown in the card's next-intake slot; the calm on-track "next dose in N
days" phrasing is now suppressed, leaving only the actionable due /
overdue tiers.

Remove the "recommended next site" nudge from the GLP-1 card — it is
noise on the at-a-glance surface. Injection-site tracking stays: the last
site still reads on the last-injection line and the post-dose picker
still captures the next site.
2026-06-06 23:25:47 +02:00
Marc-André Bombeck 19af59ebfe fix(insights): title the period-in-review card and hide the day-signal card when there is no signal
Make "Period in review" the period-narrative card heading (a proper
title, not a faint uppercase label) and drop the possessive across all
locales.

Render the today-signal card only when there is an actual signal: an
all-clear day (every vital inside its personal range) or an account still
building its baselines now produces no card, rather than an empty header.
2026-06-06 23:25:41 +02:00
Marc-André Bombeck 6d5145bdd9 test(settings): update integrations contract for the dropped moodLog card
The single-status-display contract now expects three integration cards
(Withings, WHOOP, Google Health) instead of four after the moodLog card
removal.
2026-06-06 23:15:09 +02:00
Marc-André Bombeck 1b283a9c01 feat(settings): land the account page from the avatar, tidy the token row
Clicking the avatar (and the name beside it) in the sidebar user menu
now routes straight to the account settings. On the per-medication API
token row, drop the separate "Copy URL" button in favour of an inline
read-only endpoint field with a copy icon, and switch the generate-token
button to the standard outline variant so it no longer renders as the
lone primary-coloured control on the row.
2026-06-06 23:15:05 +02:00
Marc-André Bombeck eda79794d9 feat(settings): trim the export surface and remove the sharing page
Right-align the health-record export generate button. Remove the
duplicate doctor-report card from the export page (the doctor report is
reached through the health-record export now) and drop the now-unused
ArztberichtHeroCard. Remove the clinician share-link ("sharing") page,
its nav entry, slug, and section mapping; the backing model, routes, and
public clinician view stay in place for a later dedicated removal.
2026-06-06 23:14:50 +02:00
Marc-André Bombeck a56e6fe610 feat(settings): drop the moodLog integration from the integrations page
Remove the moodLog card, its render, and the moodLog-only UI plumbing
from the integrations settings — the integration is no longer offered.
The backing columns, routes, and status aggregator entry stay in place
for a later dedicated removal. Also shorten the integration save button
from "Save credentials" to "Save" across all locales, and add the
nav account-settings label.
2026-06-06 23:14:34 +02:00
Marc-André Bombeck 79ce836335 fix(settings): guard notification panel against non-array status payload
The notifications settings panel white-screened with "x.map is not a
function" when a status payload reached the card as the whole
{ channels, events } object instead of the channels array (events is an
object map, not an array). Guard the channel map with an Array.isArray
check, default the mood reminder-hour read to optional chaining, and
add a regression test that renders the empty state instead of crashing
on a malformed payload.
2026-06-06 23:14:04 +02:00
Marc-André Bombeck 0e3e0b0135 Merge pull request #280 from MBombeck/release/v1.15.5
v1.15.5 — insights layout, navigation, password-reset CLI
2026-06-06 21:38:22 +02:00
Marc-André Bombeck db391aa4ce chore(release): v1.15.5 — insights layout, navigation, password-reset CLI 2026-06-06 21:32:16 +02:00
Marc-André Bombeck fcf1366fea feat(insights): cycle ring replaces strain tile, tidy overview spacing, nav order, password-reset CLI
When cycle tracking is on, the cycle ring takes the Strain slot in the
health-scores strip rather than adding a sixth tile: the wellness strip
gains a hideStrain flag the insights page sets from the cycle gate, so
Strain is hidden for cycle accounts and shown unchanged for everyone else.

Fix the oversized vertical gap on the insights overview. The cycle summary
card set data-revealed on the same node that carried wellness-tile-rise,
but the entrance keyframe selector is a descendant combinator, so the
animation never matched and the card stayed at the rise rule's opacity: 0 —
invisible yet still occupying its box. Wrap the card so data-revealed sits
on an ancestor, restoring the entrance and removing the phantom gap.

Move the gated cycle entry directly after Medications (before Insights) in
the sidebar nav instead of appending it at the end, spliced by the
Medications index so the position survives a list reorder.

Add an operator-only password-reset CLI. It runs under plain node in the
production standalone image (which strips tsx) via the in-image pg and
@node-rs/argon2, reusing the app's Argon2id parameters through a shared
.mjs module so a reset hash matches one the app would mint. It matches a
single user by username or email, reads the new password without echoing,
never prints it back, and exits non-zero on zero or multiple matches.
Documented in docs/ops/password-reset.md.
2026-06-06 21:31:12 +02:00
70 changed files with 3453 additions and 2570 deletions

No files matched your search

+67 -1
View File
@@ -2,7 +2,73 @@
## [Unreleased]
## [1.15.4] — 2026-06-06 — cycle and insights polish
## [1.15.8.1] — 2026-06-07 — large Apple Health imports
### Fixed
- **Large Apple Health `export.zip` uploads no longer fail with "Could not locate ZIP End-Of-Central-Directory record".** The multipart upload reader could drop part of a file body when a network chunk ended exactly on the multipart boundary — which only showed up on larger, multi-chunk uploads (a small export imported fine). The reader now handles the boundary across chunks correctly, so the uploaded archive arrives byte-for-byte intact. (#281)
## [1.15.8] — 2026-06-07 — medication cards
### Changed
- **Consistent medication cards.** Both medication card styles now use the same structure and the same labels for the same thing — "Next intake" and "Last intake" everywhere — so the two cards read the same way. The GLP-1 card keeps its relative-day hint ("Saturday 13 Jul (in 7 days)").
- **Compliance bars show the counts.** Each compliance bar now shows the doses behind the percentage (for example "100% · 12 / 12"), so two windows that genuinely land at the same percentage are legible at a glance instead of looking like a glitch.
## [1.15.7] — 2026-06-07 — data import in the browser
### Added
- **Import your data from the browser.** Settings → Export is now **Export & Import** and carries a proper import area with two paths:
- **Apple Health** — upload your `export.zip` directly. It streams server-side (handles multi-GB archives) and is idempotent, so re-uploading the same archive merges rather than duplicating; progress and the imported/skipped totals are shown as it runs.
- **Generic JSON** — upload or paste a JSON document of measurements and mood entries (with a downloadable example and a documented schema) for manually-prepared or converted data.
- The data-import schema, the full measurement-type/unit reference, and a "convert a CSV into this" how-to are documented at `docs/integrations/data-import.md`.
- **Cycle export.** When cycle tracking is on, the Export area offers an explicit cycle export.
### Changed
- **A more compact health-record export card.** The health-record export keeps its prominence but takes far less vertical space.
### Fixed
- **The Apple Health import is reachable again.** The importer existed on the backend, but the web page the docs pointed to was never present; there is now a real import UI in Settings → Export & Import. (#281)
## [1.15.6] — 2026-06-06 — settings, insights, and medication-card polish
### Fixed
- **The notifications settings panel no longer crashes.** Opening Settings → Notifications could throw a client-side error on some accounts; the panel now renders safely whatever shape the data arrives in.
- **Even chevron in dropdowns.** The arrow on the right of selects (timezone, language, units, and everywhere else) now sits with the same inset from the right edge as the label has from the left, instead of pushing against the edge.
### Changed
- **Clearer Insights overview.** The period-in-review card now carries a proper heading, and the "signals of the day" section only appears when there's an actual anomaly to flag — when every vital is within your personal range it stays hidden rather than showing an empty header.
- **Tidier medication cards.** The next-dose line no longer appears twice on a card, and the injection-site suggestion text has been dropped from the card surface (site tracking and the post-dose picker are unchanged).
- **The avatar menu lands in Account.** Clicking your avatar now opens the Account settings directly.
- **Shorter integration save button.** The integrations credential form's button now simply reads "Save".
- **Tidier API-tokens panel.** The token endpoint URL is shown inline with a copy button, and the "generate another token" button matches the rest of the page rather than standing out in a different colour.
- **The export panel's create button is right-aligned**, consistent with other primary actions.
### Removed
- **The moodLog integration** has been removed from the Integrations page; it is no longer used.
- **The standalone sharing (create-link) feature** has been removed from the Export area.
- **The doctor report is no longer offered twice** — it has been removed from the main Export page, where it duplicated the health-record export's own doctor report.
## [1.15.5] — 2026-06-06 — insights layout, navigation, password-reset CLI
### Added
- **Reset a password from the command line.** Operators can reset a user's password from inside the container — useful when someone is locked out — via `docker compose exec app node scripts/reset-password.mjs <username-or-email>`. See `docs/ops/password-reset.md`.
### Changed
- **A tidier health-scores row.** With cycle tracking on, your cycle ring takes the place of the strain ring in the Insights health-scores row, so the row stays compact rather than growing an extra tile.
- **Cycle sits with your other trackers.** The Cycle entry in the sidebar now appears between Medications and Insights, rather than at the very bottom.
### Fixed
- **No more empty gap on the Insights overview.** The cycle summary card on the Insights page was held invisible by a style rule yet still took up space, leaving an odd gap above and below it; it now appears as intended and the spacing is even.
### Added
+11 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.15.4
version: 1.15.8.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -8456,12 +8456,17 @@ components:
type: integer
minimum: 0
maximum: 100
taken:
type: integer
minimum: 0
maximum: 9007199254740991
streak:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- rate
- taken
- streak
additionalProperties: false
long:
@@ -8471,8 +8476,13 @@ components:
type: integer
minimum: 0
maximum: 100
taken:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- rate
- taken
additionalProperties: false
currentCycle:
type: object
+5 -4
View File
@@ -35,10 +35,11 @@ clinical records, electrocardiograms). The importer reads
## 2. Upload to HealthLog
Open the import page from the in-app settings tree. Drop the
`export.zip` onto the upload control. The browser streams the body
directly to the server; nothing is buffered into memory on either
side.
Open **Settings → Export & Import** in the app and drop the
`export.zip` onto the Apple Health upload control (or click to pick the
file). The browser streams the body directly to the server; nothing is
buffered into memory on either side. A progress indicator polls the
job until it reports the imported / skipped counts.
The endpoint is `POST /api/import/apple-health-export`. The handler:
+224
View File
@@ -0,0 +1,224 @@
# Data import
HealthLog has two ways to bring data in, both reachable from
**Settings → Export & Import**:
1. An **Apple Health `export.zip`** upload — the full archive from the
iPhone Health app, parsed server-side. See
[`apple-health.md`](./apple-health.md) for the end-to-end walkthrough,
the HealthKit type mapping, and the streaming / idempotency model.
2. A **generic JSON import** — a small, explicit payload of measurements
and mood entries, useful for restoring a partial export or migrating
from another tracker. This page documents that format.
Both paths skip rows that already exist, so re-running an import is
safe — it merges rather than duplicating.
## 1. Apple Health `export.zip`
On the iPhone: **Health app → profile picture → Export All Health
Data**, then move the resulting `export.zip` somewhere you can reach
from a browser. In HealthLog open **Settings → Export & Import** and drop
the archive onto the Apple Health control (or click to pick it). The
upload streams to the server — a multi-gigabyte archive never buffers in
the browser — and a background job parses it while a progress indicator
polls for the imported / skipped counts.
Re-uploading the same archive resolves to the same job by content hash,
so it merges instead of creating duplicates. Clinical records
(electrocardiograms, lab documents) are intentionally skipped. The full
type mapping lives in [`apple-health.md`](./apple-health.md).
## 2. Generic JSON import
`POST /api/import` accepts a single JSON object with two optional
arrays:
```json
{
"measurements": [ /* … */ ],
"moodEntries": [ /* … */ ]
}
```
Each array holds at most **10 000** entries. The endpoint is rate-limited
to **5 imports per hour per account**. Existing rows (matched on the
type + timestamp uniqueness, or on `externalId` for mood entries) are
skipped and counted under `skipped` in the response.
### Measurement entry
| Field | Required | Type | Notes |
|---|---|---|---|
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
### Mood entry
| Field | Required | Type | Notes |
|---|---|---|---|
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
### `MeasurementType` values and canonical units
Storage is always in the canonical unit listed here; the UI converts for
display per the user's preference. Event-class types record a single
fired event (`value` is `1`) and are normally produced by a device, not
hand-authored.
| Type | Canonical unit |
|---|---|
| `WEIGHT` | kg |
| `BLOOD_PRESSURE_SYS` | mmHg |
| `BLOOD_PRESSURE_DIA` | mmHg |
| `PULSE` | bpm |
| `RESTING_HEART_RATE` | bpm |
| `WALKING_HEART_RATE_AVERAGE` | bpm |
| `AVERAGE_HEART_RATE` | bpm |
| `MAX_HEART_RATE` | bpm |
| `CARDIO_RECOVERY` | bpm |
| `HEART_RATE_VARIABILITY` | ms (SDNN) |
| `HRV_RMSSD` | ms (RMSSD) |
| `RESPIRATORY_RATE` | count/min |
| `OXYGEN_SATURATION` | percent (0–100) |
| `BLOOD_GLUCOSE` | mg/dL |
| `BODY_FAT` | percent (0–100) |
| `FAT_MASS` | kg |
| `FAT_FREE_MASS` | kg |
| `LEAN_BODY_MASS` | kg |
| `MUSCLE_MASS` | kg |
| `BONE_MASS` | kg |
| `TOTAL_BODY_WATER` | kg |
| `VISCERAL_FAT` | rating |
| `BODY_MASS_INDEX` | kg/m² |
| `BODY_TEMPERATURE` | °C (core) |
| `SKIN_TEMPERATURE` | °C (dermal spot) |
| `WRIST_TEMPERATURE` | °C (overnight wrist) |
| `SLEEP_DURATION` | minutes |
| `SLEEP_NEED` | minutes |
| `SLEEP_PERFORMANCE` | percent (0–100) |
| `SLEEP_EFFICIENCY` | percent (0–100) |
| `SLEEP_CONSISTENCY` | percent (0–100) |
| `SLEEP_DISTURBANCE_COUNT` | count |
| `BREATHING_DISTURBANCES` | count |
| `ACTIVITY_STEPS` | count |
| `FLIGHTS_CLIMBED` | count |
| `WALKING_RUNNING_DISTANCE` | metres |
| `SIX_MINUTE_WALK_DISTANCE` | metres |
| `WALKING_STEP_LENGTH` | metres |
| `WALKING_SPEED` | m/s |
| `STAIR_ASCENT_SPEED` | m/s |
| `STAIR_DESCENT_SPEED` | m/s |
| `WALKING_STEADINESS` | percent (0–100) |
| `WALKING_ASYMMETRY` | percent (0–100) |
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
| `ACTIVE_ENERGY_BURNED` | kcal |
| `ENERGY_EXPENDITURE_KJ` | kJ |
| `VO2_MAX` | mL/(kg·min) |
| `PULSE_WAVE_VELOCITY` | m/s |
| `VASCULAR_AGE` | years |
| `FALL_COUNT` | count |
| `AUDIO_EXPOSURE_ENV` | dBA |
| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
| `TIME_IN_DAYLIGHT` | minutes |
| `RECOVERY_SCORE` | score (0–100) |
| `STRESS_SCORE` | score (0–100) |
| `STRAIN_SCORE` | score (0–100) |
| `DAY_STRAIN` | score (0–21) |
| `WORKOUT_STRAIN` | score (0–21) |
| `AUDIO_EXPOSURE_EVENT` | count (event) |
| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
| `HIGH_HEART_RATE_EVENT` | event |
| `LOW_HEART_RATE_EVENT` | event |
| `WALKING_STEADINESS_EVENT` | event |
| `BREATHING_DISTURBANCE_EVENT` | event |
The live, machine-readable list of writable types and their units is
also served by `GET /api/meta/capabilities` under `ingest.quantityTypes`
— the authoritative source if this table ever lags a release.
### Worked example
The **Download example** button in **Settings → Export & Import** mints
exactly this payload, which is a valid import body:
```json
{
"measurements": [
{
"type": "WEIGHT",
"value": 80.5,
"unit": "kg",
"measuredAt": "2026-05-01T08:00:00.000Z",
"source": "manual",
"notes": "morning"
},
{
"type": "BLOOD_PRESSURE_SYS",
"value": 120,
"unit": "mmHg",
"measuredAt": "2026-05-01T08:05:00.000Z"
},
{
"type": "BLOOD_PRESSURE_DIA",
"value": 80,
"unit": "mmHg",
"measuredAt": "2026-05-01T08:05:00.000Z"
}
],
"moodEntries": [
{
"date": "2026-05-01",
"mood": "GUT",
"score": 4,
"tags": "work,exercise"
}
]
}
```
The response is a count envelope:
```json
{
"data": { "measurements": 3, "moodEntries": 1, "skipped": 0 }
}
```
## Converting a CSV into the import JSON
A spreadsheet export is the most common starting point. The shape is
small enough to convert by hand for a few rows, or with a one-off script
for many. The steps are the same either way:
1. **One CSV per concept.** Keep measurements and mood in separate
sheets — they map to the two arrays. A blood-pressure reading is two
measurement rows (`BLOOD_PRESSURE_SYS` and `BLOOD_PRESSURE_DIA`) that
share a `measuredAt`.
2. **Pick the `type`.** Map each CSV column to a `MeasurementType` from
the table above. A "Weight (kg)" column becomes `type: "WEIGHT"`,
`unit: "kg"`.
3. **Normalise the unit.** Convert each value into the canonical unit
before writing it — e.g. pounds → kg, hours of sleep → minutes,
`mmol/L` glucose → `mg/dL`. The importer rejects values outside the
plausible range for the type, which catches most unit mistakes.
4. **Format the timestamp.** Turn the CSV date/time column into an
ISO-8601 `measuredAt` (mood uses a `YYYY-MM-DD` `date`). Include the
timezone offset (`Z` for UTC) so the reading lands on the right day.
5. **Emit the two arrays.** Collect the rows into `measurements` and
`moodEntries` as shown above and paste the result into the JSON
import control, or upload it as a `.json` file. Keep each array under
10 000 entries; split a larger history across runs (5 per hour).
Because the import is idempotent on the unique constraints, you can
re-run a corrected file over a partial one without creating duplicates.
+61
View File
@@ -0,0 +1,61 @@
# Password reset (operator)
A self-hosted operator can reset a single user's password from inside the
running container. This is an **operator-only** maintenance path — there is no
user-facing or API route for it, and it bypasses the normal account-recovery
flow. Use it only for an account you administer (for example, after a forgotten
password on a single-user instance).
> **Operator-only.** The script writes directly to the `users` table and is
> intended to be run by the host operator with database access. Treat the new
> password like any other secret: pass it on stdin (it is read without echoing)
> rather than as a shell argument where it would land in shell history and the
> process list.
## Run it
The reset CLI ships in the image and runs under plain `node` against the built
runtime (the standalone image strips `tsx`, so this is a `.mjs` that uses the
in-image `pg` and `@node-rs/argon2` directly and reuses the app's exact Argon2id
parameters):
```sh
docker compose exec app node scripts/reset-password.mjs <username-or-email>
```
The script prompts for the new password without echoing it, then prints a
confirmation that names only the user — never the password:
```
New password (input hidden):
reset-password: password updated for "alice"
```
You can also pass the password as a second argument for non-interactive use,
but prefer the stdin prompt so the secret stays out of shell history:
```sh
docker compose exec app node scripts/reset-password.mjs alice 'a-strong-passphrase'
```
## Behaviour
- The identifier is matched **case-insensitively** against `username` **or**
`email`, mirroring the login lookup.
- The match must resolve to exactly one user. Zero matches exits non-zero with
`no user matches "<id>"`; multiple matches exits non-zero and refuses to guess.
- The new password must be at least 12 characters (the app's minimum).
- The password is hashed with Argon2id using the same cost parameters as the
application (`src/lib/auth/argon2-params.mjs`), so the stored hash is
identical to one the app would mint.
- `DATABASE_URL` is read from the environment — the same variable the app uses,
honouring any `sslmode=` in the connection string.
## Source checkout
From a source checkout (not the production image) the same script runs under
Node directly:
```sh
DATABASE_URL='postgres://…' node scripts/reset-password.mjs <username-or-email>
```
-98
View File
@@ -1,98 +0,0 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Doctor-report PDF flow. The real flow:
* 1. User clicks "Configure & generate" on `/settings/export`
* (relocated from `/settings/advanced` in v1.4.16 phase B7)
* 2. The dialog opens; user picks dates and submits.
* 3. Frontend POSTs `/api/doctor-report` to fetch aggregated data
* 4. Frontend dynamic-imports `src/lib/doctor-report-pdf.ts` and
* generates the PDF client-side via jsPDF; calls `doc.save(...)`
* which triggers a download.
*
* This spec mocks step 3 (so we don't need 90 days of seeded data) and
* captures step 4's download via Playwright's download API. We then
* confirm the file is non-empty and starts with `%PDF` magic bytes.
*/
test.describe("doctor report PDF generation", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("button click triggers a non-empty PDF download", async ({ page }) => {
// Mock the data endpoint with a minimal-but-shape-correct payload.
// The shape below mirrors the `ReportData` interface in
// `src/lib/doctor-report-pdf.ts` — empty stats / medications / mood
// is enough to exercise the table-rendering path without 90 days
// of seeded readings.
await page.route("**/api/doctor-report", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
period: {
days: 90,
since: new Date(Date.now() - 90 * 86_400_000).toISOString(),
},
patient: {
username: "e2e-tester",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 180,
},
stats: {},
bmi: null,
compliance: {},
medications: [],
mood: null,
},
error: null,
}),
}),
);
// Capture page errors so a failed dynamic-import surfaces in the
// test output instead of timing out silently.
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(`pageerror: ${err.message}`));
page.on("console", (msg) => {
if (msg.type() === "error")
pageErrors.push(`console.error: ${msg.text()}`);
});
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.4.37 renamed the trigger testid from `export-action-doctor-report`
// to `export-hero-doctor-report-action` when the card was lifted to a
// hero block on the export page.
const reportBtn = page.getByTestId("export-hero-doctor-report-action");
await expect(reportBtn).toBeVisible({ timeout: 10_000 });
// Click opens the dialog; the dialog's submit button triggers the PDF
// generation. Trigger the download and capture the resulting Download
// object once the user confirms.
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
await reportBtn.click();
// Submit the dialog with the default dates (last 90 days).
const submitBtn = page.getByRole("button", {
name: /generate pdf|pdf generieren/i,
});
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
await submitBtn.click();
const download = await downloadPromise.catch((err) => {
throw new Error(
`Download did not fire. Page errors:\n${pageErrors.join("\n")}\n\nOriginal: ${err}`,
);
});
const path = await download.path();
expect(path).toBeTruthy();
// Read first 4 bytes — a valid PDF starts with `%PDF`.
const fs = await import("node:fs/promises");
const buf = await fs.readFile(path!);
expect(buf.byteLength).toBeGreaterThan(1024);
expect(buf.subarray(0, 4).toString("ascii")).toBe("%PDF");
});
});
+39 -8
View File
@@ -3,15 +3,17 @@ import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Settings → Export consolidated UI smoke.
* Settings → Export & Import consolidated UI smoke.
*
* Validates that:
* 1. The export surfaces are all rendered with their stable testids:
* the health-record export hero, the four CSV/JSON tiles, and the
* demoted doctor-report card (v1.12).
* 2. The health-record "included data" checklist is a disclosure,
* 2. The import surfaces (v1.15.7, issue #281) render: the Apple Health
* and generic-JSON cards.
* 3. The health-record "included data" checklist is a disclosure,
* collapsed by default, and expands on demand.
* 3. Clicking the Measurements CSV download button fires a real
* 4. Clicking the Measurements CSV download button fires a real
* browser download — proving the `/api/export/measurements`
* endpoint is reachable from the browser end-to-end.
*
@@ -19,26 +21,55 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* download is enough to lock in the wiring without doubling the e2e
* runtime.
*/
test.describe("Settings → Export", () => {
test.describe("Settings → Export & Import", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("renders all export surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.12 — the health-record export is the page hero; the
// doctor-report card (`export-hero-*` prefix) is demoted to the
// bottom; the four CSV/backup tiles keep the `export-card-*` shape.
// The health-record export is the page hero; the four CSV/backup
// tiles keep the `export-card-*` shape. The doctor report lives
// under the health-record export, so it is no longer a separate
// surface here.
for (const id of [
"health-record-export-panel",
"export-card-measurements-csv",
"export-card-medications-csv",
"export-card-mood-csv",
"export-card-full-backup",
"export-hero-doctor-report",
]) {
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
}
});
test("renders the import surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
// cards live in the same section, below the export options.
for (const id of ["import-card-apple-health", "import-card-json"]) {
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
}
});
test("JSON import 'Download example' fires a real download", async ({
page,
}) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
const exampleBtn = page.getByTestId("import-json-download-example");
await expect(exampleBtn).toBeVisible({ timeout: 10_000 });
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
await exampleBtn.click();
const download = await downloadPromise;
const path = await download.path();
expect(path).toBeTruthy();
const fs = await import("node:fs/promises");
const buf = await fs.readFile(path!);
const parsed = JSON.parse(buf.toString("utf8"));
expect(Array.isArray(parsed.measurements)).toBe(true);
expect(Array.isArray(parsed.moodEntries)).toBe(true);
});
test("included-data checklist is collapsed by default and expands on demand", async ({
page,
}) => {
+11 -12
View File
@@ -15,11 +15,10 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* verifies neither path leaks into a horizontal scrollbar on the
* <html> element.
*
* 2. Render exactly one pill per integration card (Withings + Mood
* Log when both are configured). Belt-and-braces guard for the
* consolidation Marc requested — the Vitest spec covers this on
* the SSR markup level, but the Pixel-5 layout is what matters
* to him in practice.
* 2. Render exactly one pill per integration card. Belt-and-braces
* guard for the card consolidation — the Vitest spec covers this
* on the SSR markup level, but the Pixel-5 layout is what matters
* in practice.
*
* Mobile-only spec — desktop project skips it.
*/
@@ -144,18 +143,18 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// Wait for the pills to mount (queries resolve after first paint).
const pills = page.locator('[data-testid="integration-status-pill"]');
// Four integration cards now (Withings, moodLog, WHOOP, Google Health) →
// four pills.
await expect(pills).toHaveCount(4, { timeout: 10_000 });
// Three integration cards now (Withings, WHOOP, Google Health) → three
// pills.
await expect(pills).toHaveCount(3, { timeout: 10_000 });
// Withings + moodLog are connected in this fixture; WHOOP is a BYO-keys
// card that stays dormant until an operator adds credentials, so assert
// the two connected pills order-independently.
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
// connected pill.
await expect(
page.locator(
'[data-testid="integration-status-pill"][data-state="connected"]',
),
).toHaveCount(2);
).toHaveCount(1);
// The redundant v1.4.18 banner must be gone.
await expect(
+55 -8
View File
@@ -212,6 +212,7 @@
"themeLight": "Hell",
"collapseSidebar": "Einklappen",
"userMenu": "Benutzermenü",
"accountSettings": "Kontoeinstellungen",
"mood": "Stimmung",
"workouts": "Workouts",
"skipToContent": "Zum Inhalt springen",
@@ -699,6 +700,7 @@
"compliance7d": "7-Tage-Compliance",
"compliance30d": "30-Tage-Compliance",
"complianceWindow": "{days}-Tage-Compliance",
"complianceDoses": "{count} Dosen",
"lastIntake": "Letzte Einnahme:",
"nextIntake": "Nächste Einnahme:",
"dayStreak": "Tage Serie",
@@ -744,8 +746,6 @@
"glp1DosesPerUnit": "Dosen pro Pen / Ampulle",
"glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, wenn keine Dosis-pro-Pen-Verfolgung gewünscht ist.",
"glp1NextLabel": "Nächster Termin:",
"glp1LastLabel": "Letzter Termin:",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (in {days} Tagen)",
@@ -1641,7 +1641,7 @@
"regenerateAnalysis": "Einschätzung neu erstellen",
"regenerateSuccess": "Einschätzung wurde neu erstellt",
"warmStarted": "Einschätzungen werden im Hintergrund erstellt",
"narrativeTitle": "Dein Zeitraum im Rückblick",
"narrativeTitle": "Zeitraum im Rückblick",
"narrativeWeek": "Diese Woche",
"narrativeMonth": "Dieser Monat",
"narrativePreparing": "Deine Zusammenfassung wird erstellt…",
@@ -3288,8 +3288,8 @@
"description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten."
},
"export": {
"title": "Export",
"description": "Lade deine Gesundheitsdaten als PDF, CSV oder vollständiges JSON-Backup herunter.",
"title": "Export & Import",
"description": "Lade deine Gesundheitsdaten als PDF, CSV oder vollständiges JSON-Backup herunter — oder importiere Daten aus Apple Health oder einer JSON-Datei.",
"otherOptionsHeading": "Weitere Export-Optionen",
"downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.",
"hero": {
@@ -3326,6 +3326,53 @@
"fullBackup": {
"title": "Voll-Backup",
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload."
},
"cycle": {
"title": "Zyklus",
"description": "Zyklus- und Reproduktionsdaten als FHIR-Dokument — letzte Periode, durchschnittliche Zyklus- und Periodenlänge, aktuelle Phase."
}
},
"import": {
"heading": "Import",
"description": "Importiere Daten aus einem Apple-Health-Export oder einer JSON-Datei. Bereits vorhandene Einträge werden übersprungen, ein erneuter Import ist also unbedenklich.",
"appleHealth": {
"title": "Apple Health",
"description": "Lade die export.zip aus der Health-App auf dem iPhone hoch (Profil → Alle Gesundheitsdaten exportieren).",
"dropLabel": "export.zip hier ablegen",
"dropHint": "oder zum Auswählen klicken",
"fileInputLabel": "Eine Apple-Health-export.zip-Datei auswählen",
"choose": "Datei auswählen",
"idempotencyNote": "Ein erneuter Upload desselben Archivs führt zusammen — es entstehen keine Duplikate. Große Archive werden im Hintergrund verarbeitet.",
"uploading": "Wird hochgeladen…",
"uploadFailed": "Upload fehlgeschlagen. Bitte erneut versuchen.",
"rateLimited": "Zu viele Uploads. Bitte kurz warten und erneut versuchen.",
"tooLarge": "Diese Datei ist zu groß (Grenze 1,5 GB).",
"failed": "Der Import ist fehlgeschlagen.",
"rowsImported": "Bisher {count} Einträge importiert",
"doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.",
"phase": {
"queued": "In Warteschlange…",
"unpacking": "Archiv wird entpackt…",
"parsing": "Export wird gelesen…",
"upserting": "Datensätze werden importiert…"
}
},
"json": {
"title": "JSON-Datei",
"description": "JSON einfügen oder eine .json-Datei mit Messwerten und Stimmungseinträgen hochladen.",
"pasteLabel": "JSON einfügen",
"fileInputLabel": "Eine JSON-Datei zum Import auswählen",
"schemaHint": "Zwei Arrays: measurements und moodEntries.",
"docsLink": "Schema und Beispiel ansehen.",
"uploadFile": "Datei hochladen",
"downloadExample": "Beispiel herunterladen",
"import": "Importieren",
"invalidJson": "Das ist kein gültiges JSON. Bitte die Syntax prüfen und erneut versuchen.",
"invalidPayload": "Die Daten passen nicht zum Import-Schema.",
"rateLimited": "Zu viele Importe in dieser Stunde (Grenze 5). Bitte später erneut versuchen.",
"failed": "Der Import ist fehlgeschlagen. Bitte erneut versuchen.",
"readFailed": "Diese Datei konnte nicht gelesen werden.",
"resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen."
}
}
},
@@ -3583,7 +3630,7 @@
"withingsCredentialsSaved": "Zugangsdaten gespeichert",
"withingsCredentialsSavedPlaceholder": "Gespeichert — neue eingeben zum Ersetzen",
"withingsCredentialsSavedPlaceholderSecret": "Gespeichert — neues eingeben zum Ersetzen",
"withingsSaveCredentials": "Zugangsdaten speichern",
"withingsSaveCredentials": "Speichern",
"configured": "Konfiguriert",
"withingsSync": "Jetzt synchronisieren",
"withingsFullSync": "Alle Daten synchronisieren",
@@ -3608,7 +3655,7 @@
"whoopCredentialsSaved": "Zugangsdaten gespeichert",
"whoopCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
"whoopCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen",
"whoopSaveCredentials": "Zugangsdaten speichern",
"whoopSaveCredentials": "Speichern",
"whoopSync": "Jetzt synchronisieren",
"whoopFullSync": "Alle Daten synchronisieren",
"whoopFullSyncTitle": "Vollständige Synchronisierung?",
@@ -3635,7 +3682,7 @@
"fitbitCredentialsSaved": "Zugangsdaten gespeichert",
"fitbitCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
"fitbitCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen",
"fitbitSaveCredentials": "Zugangsdaten speichern",
"fitbitSaveCredentials": "Speichern",
"fitbitSync": "Jetzt synchronisieren",
"fitbitFullSync": "Alle Daten synchronisieren",
"fitbitFullSyncTitle": "Vollständige Synchronisierung?",
+55 -8
View File
@@ -212,6 +212,7 @@
"themeLight": "Light",
"collapseSidebar": "Collapse",
"userMenu": "User menu",
"accountSettings": "Account settings",
"mood": "Mood",
"workouts": "Workouts",
"skipToContent": "Skip to content",
@@ -699,6 +700,7 @@
"compliance7d": "7-day compliance",
"compliance30d": "30-day compliance",
"complianceWindow": "{days}-day compliance",
"complianceDoses": "{count} doses",
"lastIntake": "Last intake:",
"nextIntake": "Next intake:",
"dayStreak": "day streak",
@@ -744,8 +746,6 @@
"glp1DosesPerUnit": "Doses per pen / vial",
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
"glp1NextLabel": "Next:",
"glp1LastLabel": "Last:",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (in {days} days)",
@@ -1641,7 +1641,7 @@
"regenerateAnalysis": "Re-run assessment",
"regenerateSuccess": "Assessment refreshed",
"warmStarted": "Assessments are being prepared in the background",
"narrativeTitle": "Your period in review",
"narrativeTitle": "Period in review",
"narrativeWeek": "This week",
"narrativeMonth": "This month",
"narrativePreparing": "Preparing your summary…",
@@ -3288,8 +3288,8 @@
"description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record."
},
"export": {
"title": "Export",
"description": "Download your health data as PDF, CSV, or a full JSON backup.",
"title": "Export & Import",
"description": "Download your health data as PDF, CSV, or a full JSON backup — or bring data in from Apple Health or a JSON file.",
"otherOptionsHeading": "Other export options",
"downloadFailed": "Couldn't download the export. Please try again.",
"hero": {
@@ -3326,6 +3326,53 @@
"fullBackup": {
"title": "Full backup",
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload."
},
"cycle": {
"title": "Cycle",
"description": "Cycle and reproductive-health data as a FHIR document — last period, average cycle and period length, current phase."
}
},
"import": {
"heading": "Import",
"description": "Bring data into HealthLog from an Apple Health export or a JSON file. Imports skip rows that already exist, so re-running one is safe.",
"appleHealth": {
"title": "Apple Health",
"description": "Upload the export.zip from the Health app on iPhone (Profile → Export All Health Data).",
"dropLabel": "Drop export.zip here",
"dropHint": "or click to choose a file",
"fileInputLabel": "Choose an Apple Health export.zip file",
"choose": "Choose file",
"idempotencyNote": "Re-uploading the same archive merges — it won't create duplicates. Large archives are processed in the background.",
"uploading": "Uploading…",
"uploadFailed": "Upload failed. Please try again.",
"rateLimited": "Too many uploads. Please wait a moment and try again.",
"tooLarge": "That file is too large (limit 1.5 GB).",
"failed": "The import failed.",
"rowsImported": "{count} rows imported so far",
"doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.",
"phase": {
"queued": "Queued…",
"unpacking": "Unpacking the archive…",
"parsing": "Reading the export…",
"upserting": "Importing records…"
}
},
"json": {
"title": "JSON file",
"description": "Paste JSON or upload a .json file with measurements and mood entries.",
"pasteLabel": "Paste JSON",
"fileInputLabel": "Choose a JSON file to import",
"schemaHint": "Two arrays: measurements and moodEntries.",
"docsLink": "See the schema and an example.",
"uploadFile": "Upload file",
"downloadExample": "Download example",
"import": "Import",
"invalidJson": "That isn't valid JSON. Check the syntax and try again.",
"invalidPayload": "The data doesn't match the import schema.",
"rateLimited": "Too many imports this hour (limit 5). Please try again later.",
"failed": "The import failed. Please try again.",
"readFailed": "Couldn't read that file.",
"resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped."
}
}
},
@@ -3583,7 +3630,7 @@
"withingsCredentialsSaved": "Credentials saved",
"withingsCredentialsSavedPlaceholder": "Saved — enter new to replace",
"withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
"withingsSaveCredentials": "Save credentials",
"withingsSaveCredentials": "Save",
"configured": "Configured",
"withingsSync": "Sync now",
"withingsFullSync": "Sync all data",
@@ -3608,7 +3655,7 @@
"whoopCredentialsSaved": "Credentials saved",
"whoopCredentialsSavedPlaceholder": "Saved — enter new to replace",
"whoopCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
"whoopSaveCredentials": "Save credentials",
"whoopSaveCredentials": "Save",
"whoopSync": "Sync now",
"whoopFullSync": "Sync all data",
"whoopFullSyncTitle": "Full synchronization?",
@@ -3635,7 +3682,7 @@
"fitbitCredentialsSaved": "Credentials saved",
"fitbitCredentialsSavedPlaceholder": "Saved — enter new to replace",
"fitbitCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
"fitbitSaveCredentials": "Save credentials",
"fitbitSaveCredentials": "Save",
"fitbitSync": "Sync now",
"fitbitFullSync": "Sync all data",
"fitbitFullSyncTitle": "Full synchronization?",
+55 -8
View File
@@ -212,6 +212,7 @@
"themeLight": "Claro",
"collapseSidebar": "Contraer",
"userMenu": "Menú de usuario",
"accountSettings": "Ajustes de cuenta",
"mood": "Estado de ánimo",
"workouts": "Entrenamientos",
"skipToContent": "Saltar al contenido",
@@ -699,6 +700,7 @@
"compliance7d": "Cumplimiento 7 días",
"compliance30d": "Cumplimiento 30 días",
"complianceWindow": "Cumplimiento {days} días",
"complianceDoses": "{count} dosis",
"lastIntake": "Última toma:",
"nextIntake": "Próxima toma:",
"dayStreak": "días de racha",
@@ -744,8 +746,6 @@
"glp1DosesPerUnit": "Dosis por pluma / vial",
"glp1DosesPerUnitPlaceholder": "p. ej. 4 para una pluma Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Opcional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pluma de un solo uso = 1. Déjalo vacío si no quieres llevar el conteo de dosis por pluma.",
"glp1NextLabel": "Próxima cita:",
"glp1LastLabel": "Última cita:",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (en {days} días)",
@@ -1641,7 +1641,7 @@
"regenerateAnalysis": "Volver a generar la valoración",
"regenerateSuccess": "Valoración regenerada",
"warmStarted": "Las valoraciones se están preparando en segundo plano",
"narrativeTitle": "Tu período en resumen",
"narrativeTitle": "Período en resumen",
"narrativeWeek": "Esta semana",
"narrativeMonth": "Este mes",
"narrativePreparing": "Preparando tu resumen…",
@@ -3288,8 +3288,8 @@
"description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud."
},
"export": {
"title": "Exportar",
"description": "Descarga tus datos de salud como PDF, CSV o una copia de seguridad JSON completa.",
"title": "Exportar e importar",
"description": "Descarga tus datos de salud como PDF, CSV o una copia de seguridad JSON completa, o importa datos desde Apple Health o un archivo JSON.",
"otherOptionsHeading": "Otras opciones de exportación",
"downloadFailed": "No se pudo descargar la exportación. Inténtalo de nuevo.",
"hero": {
@@ -3326,6 +3326,53 @@
"fullBackup": {
"title": "Copia completa",
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador."
},
"cycle": {
"title": "Ciclo",
"description": "Datos del ciclo y de salud reproductiva como documento FHIR: última menstruación, duración media del ciclo y del periodo, fase actual."
}
},
"import": {
"heading": "Importar",
"description": "Importa datos a HealthLog desde una exportación de Apple Health o un archivo JSON. La importación omite las filas que ya existen, así que repetirla es seguro.",
"appleHealth": {
"title": "Apple Health",
"description": "Sube el archivo export.zip de la app Salud del iPhone (Perfil → Exportar todos los datos de salud).",
"dropLabel": "Suelta export.zip aquí",
"dropHint": "o haz clic para elegir un archivo",
"fileInputLabel": "Elegir un archivo export.zip de Apple Health",
"choose": "Elegir archivo",
"idempotencyNote": "Volver a subir el mismo archivo lo fusiona; no crea duplicados. Los archivos grandes se procesan en segundo plano.",
"uploading": "Subiendo…",
"uploadFailed": "Error al subir. Inténtalo de nuevo.",
"rateLimited": "Demasiadas subidas. Espera un momento e inténtalo de nuevo.",
"tooLarge": "El archivo es demasiado grande (límite 1,5 GB).",
"failed": "La importación falló.",
"rowsImported": "{count} filas importadas hasta ahora",
"doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.",
"phase": {
"queued": "En cola…",
"unpacking": "Descomprimiendo el archivo…",
"parsing": "Leyendo la exportación…",
"upserting": "Importando registros…"
}
},
"json": {
"title": "Archivo JSON",
"description": "Pega JSON o sube un archivo .json con mediciones y entradas de ánimo.",
"pasteLabel": "Pegar JSON",
"fileInputLabel": "Elegir un archivo JSON para importar",
"schemaHint": "Dos arrays: measurements y moodEntries.",
"docsLink": "Ver el esquema y un ejemplo.",
"uploadFile": "Subir archivo",
"downloadExample": "Descargar ejemplo",
"import": "Importar",
"invalidJson": "Eso no es JSON válido. Revisa la sintaxis e inténtalo de nuevo.",
"invalidPayload": "Los datos no coinciden con el esquema de importación.",
"rateLimited": "Demasiadas importaciones esta hora (límite 5). Inténtalo más tarde.",
"failed": "La importación falló. Inténtalo de nuevo.",
"readFailed": "No se pudo leer ese archivo.",
"resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas."
}
}
},
@@ -3583,7 +3630,7 @@
"withingsCredentialsSaved": "Credenciales guardadas",
"withingsCredentialsSavedPlaceholder": "Guardado — introduce uno nuevo para reemplazarlo",
"withingsCredentialsSavedPlaceholderSecret": "Guardado — introduce uno nuevo para reemplazarlo",
"withingsSaveCredentials": "Guardar credenciales",
"withingsSaveCredentials": "Guardar",
"configured": "Configurado",
"withingsSync": "Sincronizar ahora",
"withingsFullSync": "Sincronizar todos los datos",
@@ -3608,7 +3655,7 @@
"whoopCredentialsSaved": "Credenciales guardadas",
"whoopCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
"whoopCredentialsSavedPlaceholderSecret": "Guardado — introduce nuevas para reemplazar",
"whoopSaveCredentials": "Guardar credenciales",
"whoopSaveCredentials": "Guardar",
"whoopSync": "Sincronizar ahora",
"whoopFullSync": "Sincronizar todos los datos",
"whoopFullSyncTitle": "¿Sincronización completa?",
@@ -3635,7 +3682,7 @@
"fitbitCredentialsSaved": "Credenciales guardadas",
"fitbitCredentialsSavedPlaceholder": "Guardado: introduce nuevas para reemplazar",
"fitbitCredentialsSavedPlaceholderSecret": "Guardado: introduce nuevas para reemplazar",
"fitbitSaveCredentials": "Guardar credenciales",
"fitbitSaveCredentials": "Guardar",
"fitbitSync": "Sincronizar ahora",
"fitbitFullSync": "Sincronizar todos los datos",
"fitbitFullSyncTitle": "¿Sincronización completa?",
+55 -8
View File
@@ -212,6 +212,7 @@
"themeLight": "Clair",
"collapseSidebar": "Réduire",
"userMenu": "Menu utilisateur",
"accountSettings": "Paramètres du compte",
"mood": "Humeur",
"workouts": "Entraînements",
"skipToContent": "Aller au contenu",
@@ -699,6 +700,7 @@
"compliance7d": "Observance 7 jours",
"compliance30d": "Observance 30 jours",
"complianceWindow": "Observance {days} jours",
"complianceDoses": "{count} doses",
"lastIntake": "Dernière prise :",
"nextIntake": "Prochaine prise :",
"dayStreak": "jours de série",
@@ -744,8 +746,6 @@
"glp1DosesPerUnit": "Doses par stylo / flacon",
"glp1DosesPerUnitPlaceholder": "p. ex. 4 pour un stylo Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optionnel — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy stylo à usage unique = 1. Laisse vide si tu ne veux pas suivre la dose par stylo.",
"glp1NextLabel": "Prochain rendez-vous :",
"glp1LastLabel": "Dernier rendez-vous :",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (dans {days} jours)",
@@ -1641,7 +1641,7 @@
"regenerateAnalysis": "Relancer l’évaluation",
"regenerateSuccess": "Évaluation regénérée",
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
"narrativeTitle": "Votre période en revue",
"narrativeTitle": "Période en revue",
"narrativeWeek": "Cette semaine",
"narrativeMonth": "Ce mois-ci",
"narrativePreparing": "Préparation de votre résumé…",
@@ -3288,8 +3288,8 @@
"description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé."
},
"export": {
"title": "Exporter",
"description": "Téléchargez vos données de santé en PDF, CSV ou sauvegarde JSON complète.",
"title": "Export et import",
"description": "Téléchargez vos données de santé en PDF, CSV ou sauvegarde JSON complète, ou importez des données depuis Apple Santé ou un fichier JSON.",
"otherOptionsHeading": "Autres options d'export",
"downloadFailed": "Échec du téléchargement de l'export. Veuillez réessayer.",
"hero": {
@@ -3326,6 +3326,53 @@
"fullBackup": {
"title": "Sauvegarde complète",
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur."
},
"cycle": {
"title": "Cycle",
"description": "Données de cycle et de santé reproductive sous forme de document FHIR : dernières règles, durée moyenne du cycle et des règles, phase actuelle."
}
},
"import": {
"heading": "Import",
"description": "Importez des données dans HealthLog depuis un export Apple Santé ou un fichier JSON. L'import ignore les lignes déjà présentes : le relancer est sans risque.",
"appleHealth": {
"title": "Apple Santé",
"description": "Importez le fichier export.zip de l'app Santé sur iPhone (Profil → Exporter toutes les données de santé).",
"dropLabel": "Déposez export.zip ici",
"dropHint": "ou cliquez pour choisir un fichier",
"fileInputLabel": "Choisir un fichier export.zip d'Apple Santé",
"choose": "Choisir un fichier",
"idempotencyNote": "Réimporter la même archive fusionne les données ; aucun doublon n'est créé. Les grandes archives sont traitées en arrière-plan.",
"uploading": "Envoi…",
"uploadFailed": "Échec de l'envoi. Veuillez réessayer.",
"rateLimited": "Trop d'envois. Patientez un instant et réessayez.",
"tooLarge": "Ce fichier est trop volumineux (limite 1,5 Go).",
"failed": "L'import a échoué.",
"rowsImported": "{count} lignes importées jusqu'ici",
"doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.",
"phase": {
"queued": "En file d'attente…",
"unpacking": "Décompression de l'archive…",
"parsing": "Lecture de l'export…",
"upserting": "Import des enregistrements…"
}
},
"json": {
"title": "Fichier JSON",
"description": "Collez du JSON ou importez un fichier .json contenant des mesures et des entrées d'humeur.",
"pasteLabel": "Coller du JSON",
"fileInputLabel": "Choisir un fichier JSON à importer",
"schemaHint": "Deux tableaux : measurements et moodEntries.",
"docsLink": "Voir le schéma et un exemple.",
"uploadFile": "Importer un fichier",
"downloadExample": "Télécharger l'exemple",
"import": "Importer",
"invalidJson": "Ce n'est pas du JSON valide. Vérifiez la syntaxe et réessayez.",
"invalidPayload": "Les données ne correspondent pas au schéma d'import.",
"rateLimited": "Trop d'imports cette heure-ci (limite 5). Réessayez plus tard.",
"failed": "L'import a échoué. Veuillez réessayer.",
"readFailed": "Impossible de lire ce fichier.",
"resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées."
}
}
},
@@ -3583,7 +3630,7 @@
"withingsCredentialsSaved": "Identifiants enregistrés",
"withingsCredentialsSavedPlaceholder": "Enregistré — saisissez-en un nouveau pour remplacer",
"withingsCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en un nouveau pour remplacer",
"withingsSaveCredentials": "Enregistrer les identifiants",
"withingsSaveCredentials": "Enregistrer",
"configured": "Configuré",
"withingsSync": "Synchroniser maintenant",
"withingsFullSync": "Synchroniser toutes les données",
@@ -3608,7 +3655,7 @@
"whoopCredentialsSaved": "Identifiants enregistrés",
"whoopCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
"whoopCredentialsSavedPlaceholderSecret": "Enregistré — saisir de nouveaux pour remplacer",
"whoopSaveCredentials": "Enregistrer les identifiants",
"whoopSaveCredentials": "Enregistrer",
"whoopSync": "Synchroniser",
"whoopFullSync": "Synchroniser toutes les données",
"whoopFullSyncTitle": "Synchronisation complète ?",
@@ -3635,7 +3682,7 @@
"fitbitCredentialsSaved": "Identifiants enregistrés",
"fitbitCredentialsSavedPlaceholder": "Enregistré — saisissez-en de nouveaux pour remplacer",
"fitbitCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en de nouveaux pour remplacer",
"fitbitSaveCredentials": "Enregistrer les identifiants",
"fitbitSaveCredentials": "Enregistrer",
"fitbitSync": "Synchroniser maintenant",
"fitbitFullSync": "Tout synchroniser",
"fitbitFullSyncTitle": "Synchronisation complète ?",
+55 -8
View File
@@ -212,6 +212,7 @@
"themeLight": "Chiaro",
"collapseSidebar": "Riduci",
"userMenu": "Menu utente",
"accountSettings": "Impostazioni account",
"mood": "Umore",
"workouts": "Allenamenti",
"skipToContent": "Vai al contenuto",
@@ -699,6 +700,7 @@
"compliance7d": "Aderenza 7 giorni",
"compliance30d": "Aderenza 30 giorni",
"complianceWindow": "Aderenza {days} giorni",
"complianceDoses": "{count} dosi",
"lastIntake": "Ultima assunzione:",
"nextIntake": "Prossima assunzione:",
"dayStreak": "giorni di serie",
@@ -744,8 +746,6 @@
"glp1DosesPerUnit": "Dosi per penna / fiala",
"glp1DosesPerUnitPlaceholder": "es. 4 per una penna Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Opzionale — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy penna monouso = 1. Lascia vuoto se non vuoi monitorare le dosi per penna.",
"glp1NextLabel": "Prossimo appuntamento:",
"glp1LastLabel": "Ultimo appuntamento:",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (tra {days} giorni)",
@@ -1641,7 +1641,7 @@
"regenerateAnalysis": "Rigenera la valutazione",
"regenerateSuccess": "Valutazione rigenerata",
"warmStarted": "Le valutazioni vengono preparate in background",
"narrativeTitle": "Il tuo periodo in sintesi",
"narrativeTitle": "Periodo in sintesi",
"narrativeWeek": "Questa settimana",
"narrativeMonth": "Questo mese",
"narrativePreparing": "Preparazione del riepilogo…",
@@ -3288,8 +3288,8 @@
"description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute."
},
"export": {
"title": "Esporta",
"description": "Scarica i tuoi dati sanitari come PDF, CSV o backup JSON completo.",
"title": "Esporta e importa",
"description": "Scarica i tuoi dati sanitari come PDF, CSV o backup JSON completo, oppure importa dati da Apple Salute o da un file JSON.",
"otherOptionsHeading": "Altre opzioni di esportazione",
"downloadFailed": "Impossibile scaricare l'esportazione. Riprova.",
"hero": {
@@ -3326,6 +3326,53 @@
"fullBackup": {
"title": "Backup completo",
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore."
},
"cycle": {
"title": "Ciclo",
"description": "Dati del ciclo e di salute riproduttiva come documento FHIR: ultima mestruazione, durata media di ciclo e mestruazioni, fase attuale."
}
},
"import": {
"heading": "Importa",
"description": "Importa dati in HealthLog da un'esportazione di Apple Salute o da un file JSON. L'importazione salta le righe già presenti, quindi ripeterla è sicuro.",
"appleHealth": {
"title": "Apple Salute",
"description": "Carica il file export.zip dall'app Salute su iPhone (Profilo → Esporta tutti i dati sanitari).",
"dropLabel": "Trascina qui export.zip",
"dropHint": "o fai clic per scegliere un file",
"fileInputLabel": "Scegli un file export.zip di Apple Salute",
"choose": "Scegli file",
"idempotencyNote": "Ricaricare lo stesso archivio lo unisce: non crea duplicati. Gli archivi grandi vengono elaborati in background.",
"uploading": "Caricamento…",
"uploadFailed": "Caricamento non riuscito. Riprova.",
"rateLimited": "Troppi caricamenti. Attendi un momento e riprova.",
"tooLarge": "Il file è troppo grande (limite 1,5 GB).",
"failed": "L'importazione non è riuscita.",
"rowsImported": "{count} righe importate finora",
"doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.",
"phase": {
"queued": "In coda…",
"unpacking": "Estrazione dell'archivio…",
"parsing": "Lettura dell'esportazione…",
"upserting": "Importazione dei record…"
}
},
"json": {
"title": "File JSON",
"description": "Incolla JSON o carica un file .json con misurazioni e voci dell'umore.",
"pasteLabel": "Incolla JSON",
"fileInputLabel": "Scegli un file JSON da importare",
"schemaHint": "Due array: measurements e moodEntries.",
"docsLink": "Vedi lo schema e un esempio.",
"uploadFile": "Carica file",
"downloadExample": "Scarica esempio",
"import": "Importa",
"invalidJson": "Questo non è JSON valido. Controlla la sintassi e riprova.",
"invalidPayload": "I dati non corrispondono allo schema di importazione.",
"rateLimited": "Troppe importazioni in questa ora (limite 5). Riprova più tardi.",
"failed": "L'importazione non è riuscita. Riprova.",
"readFailed": "Impossibile leggere il file.",
"resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate."
}
}
},
@@ -3583,7 +3630,7 @@
"withingsCredentialsSaved": "Credenziali salvate",
"withingsCredentialsSavedPlaceholder": "Salvato — inseriscine uno nuovo per sostituirlo",
"withingsCredentialsSavedPlaceholderSecret": "Salvato — inseriscine uno nuovo per sostituirlo",
"withingsSaveCredentials": "Salva credenziali",
"withingsSaveCredentials": "Salva",
"configured": "Configurato",
"withingsSync": "Sincronizza ora",
"withingsFullSync": "Sincronizza tutti i dati",
@@ -3608,7 +3655,7 @@
"whoopCredentialsSaved": "Credenziali salvate",
"whoopCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
"whoopCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire",
"whoopSaveCredentials": "Salva credenziali",
"whoopSaveCredentials": "Salva",
"whoopSync": "Sincronizza ora",
"whoopFullSync": "Sincronizza tutti i dati",
"whoopFullSyncTitle": "Sincronizzazione completa?",
@@ -3635,7 +3682,7 @@
"fitbitCredentialsSaved": "Credenziali salvate",
"fitbitCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
"fitbitCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire",
"fitbitSaveCredentials": "Salva credenziali",
"fitbitSaveCredentials": "Salva",
"fitbitSync": "Sincronizza ora",
"fitbitFullSync": "Sincronizza tutti i dati",
"fitbitFullSyncTitle": "Sincronizzazione completa?",
+55 -8
View File
@@ -212,6 +212,7 @@
"themeLight": "Jasny",
"collapseSidebar": "Zwiń",
"userMenu": "Menu użytkownika",
"accountSettings": "Ustawienia konta",
"mood": "Nastrój",
"workouts": "Treningi",
"skipToContent": "Przejdź do treści",
@@ -699,6 +700,7 @@
"compliance7d": "Przestrzeganie 7 dni",
"compliance30d": "Przestrzeganie 30 dni",
"complianceWindow": "Przestrzeganie {days} dni",
"complianceDoses": "{count} dawek",
"lastIntake": "Ostatnie przyjęcie:",
"nextIntake": "Następne przyjęcie:",
"dayStreak": "dni serii",
@@ -744,8 +746,6 @@
"glp1DosesPerUnit": "Dawki na pen / fiolkę",
"glp1DosesPerUnitPlaceholder": "np. 4 dla pena Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Opcjonalne — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pen jednorazowy = 1. Zostaw puste, jeśli nie chcesz śledzić dawek na pen.",
"glp1NextLabel": "Następny termin:",
"glp1LastLabel": "Ostatni termin:",
"glp1LastInjection": "{label}",
"glp1LastInjectionWithSite": "{label} · {site}",
"glp1NextInjectionDays": "{label} (za {days} dni)",
@@ -1641,7 +1641,7 @@
"regenerateAnalysis": "Uruchom ocenę ponownie",
"regenerateSuccess": "Ocena wygenerowana ponownie",
"warmStarted": "Oceny są przygotowywane w tle",
"narrativeTitle": "Twój okres w skrócie",
"narrativeTitle": "Okres w skrócie",
"narrativeWeek": "Ten tydzień",
"narrativeMonth": "Ten miesiąc",
"narrativePreparing": "Przygotowywanie podsumowania…",
@@ -3288,8 +3288,8 @@
"description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych."
},
"export": {
"title": "Eksportuj",
"description": "Pobierz swoje dane zdrowotne jako PDF, CSV lub pełną kopię zapasową JSON.",
"title": "Eksport i import",
"description": "Pobierz swoje dane zdrowotne jako PDF, CSV lub pełną kopię zapasową JSON albo zaimportuj dane z Apple Health lub pliku JSON.",
"otherOptionsHeading": "Inne opcje eksportu",
"downloadFailed": "Nie udało się pobrać eksportu. Spróbuj ponownie.",
"hero": {
@@ -3326,6 +3326,53 @@
"fullBackup": {
"title": "Pełna kopia zapasowa",
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora."
},
"cycle": {
"title": "Cykl",
"description": "Dane cyklu i zdrowia reprodukcyjnego jako dokument FHIR: ostatnia miesiączka, średnia długość cyklu i miesiączki, bieżąca faza."
}
},
"import": {
"heading": "Import",
"description": "Zaimportuj dane do HealthLog z eksportu Apple Health lub pliku JSON. Import pomija wiersze, które już istnieją, więc ponowne uruchomienie jest bezpieczne.",
"appleHealth": {
"title": "Apple Health",
"description": "Prześlij plik export.zip z aplikacji Zdrowie na iPhonie (Profil → Eksportuj wszystkie dane zdrowotne).",
"dropLabel": "Upuść tutaj export.zip",
"dropHint": "lub kliknij, aby wybrać plik",
"fileInputLabel": "Wybierz plik export.zip z Apple Health",
"choose": "Wybierz plik",
"idempotencyNote": "Ponowne przesłanie tego samego archiwum scala dane — nie tworzy duplikatów. Duże archiwa są przetwarzane w tle.",
"uploading": "Przesyłanie…",
"uploadFailed": "Przesyłanie nie powiodło się. Spróbuj ponownie.",
"rateLimited": "Zbyt wiele przesłań. Poczekaj chwilę i spróbuj ponownie.",
"tooLarge": "Ten plik jest za duży (limit 1,5 GB).",
"failed": "Import nie powiódł się.",
"rowsImported": "Zaimportowano dotąd {count} wierszy",
"doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.",
"phase": {
"queued": "W kolejce…",
"unpacking": "Rozpakowywanie archiwum…",
"parsing": "Odczytywanie eksportu…",
"upserting": "Importowanie rekordów…"
}
},
"json": {
"title": "Plik JSON",
"description": "Wklej JSON lub prześlij plik .json z pomiarami i wpisami nastroju.",
"pasteLabel": "Wklej JSON",
"fileInputLabel": "Wybierz plik JSON do zaimportowania",
"schemaHint": "Dwie tablice: measurements i moodEntries.",
"docsLink": "Zobacz schemat i przykład.",
"uploadFile": "Prześlij plik",
"downloadExample": "Pobierz przykład",
"import": "Importuj",
"invalidJson": "To nie jest prawidłowy JSON. Sprawdź składnię i spróbuj ponownie.",
"invalidPayload": "Dane nie pasują do schematu importu.",
"rateLimited": "Zbyt wiele importów w tej godzinie (limit 5). Spróbuj później.",
"failed": "Import nie powiódł się. Spróbuj ponownie.",
"readFailed": "Nie można odczytać tego pliku.",
"resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}."
}
}
},
@@ -3583,7 +3630,7 @@
"withingsCredentialsSaved": "Dane logowania zapisane",
"withingsCredentialsSavedPlaceholder": "Zapisano — wprowadź nowy, aby zastąpić",
"withingsCredentialsSavedPlaceholderSecret": "Zapisano — wprowadź nowy, aby zastąpić",
"withingsSaveCredentials": "Zapisz dane logowania",
"withingsSaveCredentials": "Zapisz",
"configured": "Skonfigurowano",
"withingsSync": "Synchronizuj teraz",
"withingsFullSync": "Synchronizuj wszystkie dane",
@@ -3608,7 +3655,7 @@
"whoopCredentialsSaved": "Dane zapisane",
"whoopCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
"whoopCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić",
"whoopSaveCredentials": "Zapisz dane",
"whoopSaveCredentials": "Zapisz",
"whoopSync": "Synchronizuj teraz",
"whoopFullSync": "Synchronizuj wszystkie dane",
"whoopFullSyncTitle": "Pełna synchronizacja?",
@@ -3635,7 +3682,7 @@
"fitbitCredentialsSaved": "Dane uwierzytelniające zapisane",
"fitbitCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
"fitbitCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić",
"fitbitSaveCredentials": "Zapisz dane uwierzytelniające",
"fitbitSaveCredentials": "Zapisz",
"fitbitSync": "Synchronizuj teraz",
"fitbitFullSync": "Synchronizuj wszystkie dane",
"fitbitFullSyncTitle": "Pełna synchronizacja?",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.15.4",
"version": "1.15.8.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
+113
View File
@@ -0,0 +1,113 @@
/**
* scripts/reset-password.mjs — operator-only CLI to reset a single user's
* password from inside the running container.
*
* WHY plain `.mjs` and not the `.ts` scripts pattern: the production standalone
* image strips `tsx` and ships the Prisma client only as bundled TypeScript
* inside `server.js` (not importable from a standalone process). This script
* therefore talks to Postgres through `pg` (present in the image) and hashes
* with `@node-rs/argon2` (also present), reusing the ONE shared Argon2id param
* object that the app's `hashPassword()` uses, so the hash it writes is
* byte-compatible with a hash the app would mint.
*
* Usage (inside the container — operator only):
* docker compose exec app node scripts/reset-password.mjs <username-or-email> [new-password]
*
* If the password is omitted it is read from stdin without echoing. The
* password is never printed back. Exits non-zero with a clear message when the
* identifier matches zero or more than one user.
*
* Reads `DATABASE_URL` from the environment (the same var the app uses). The
* connection honours any `sslmode=` in the URL exactly like the app pool.
*/
import { createInterface } from "node:readline";
import { hash } from "@node-rs/argon2";
import pg from "pg";
import { ARGON2_HASH_OPTIONS } from "../src/lib/auth/argon2-params.mjs";
function fail(message) {
console.error(`reset-password: ${message}`);
process.exit(1);
}
/** Read a line from stdin without echoing it to the terminal. */
function promptHidden(label) {
return new Promise((resolve) => {
process.stdout.write(label);
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
// Override the line-writer so typed characters are never echoed. The
// prompt label above is already written; the closing newline is emitted
// when the answer resolves.
rl._writeToOutput = () => {};
rl.question("", (answer) => {
rl.close();
process.stdout.write("\n");
resolve(answer);
});
});
}
async function main() {
const identifier = process.argv[2];
if (!identifier) {
fail(
"usage: node scripts/reset-password.mjs <username-or-email> [new-password]",
);
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
fail("DATABASE_URL must be set");
}
let newPassword = process.argv[3];
if (!newPassword) {
newPassword = await promptHidden("New password (input hidden): ");
}
if (!newPassword || newPassword.length < 12) {
fail("new password must be at least 12 characters");
}
const client = new pg.Client({ connectionString: databaseUrl });
await client.connect();
try {
// Case-insensitive match on username OR email, mirroring the login lookup.
// Scope to a single user: zero or multiple matches is a hard error so an
// operator never silently resets the wrong account.
const found = await client.query(
`SELECT id, username FROM users
WHERE lower(username) = lower($1) OR lower(email) = lower($1)`,
[identifier],
);
if (found.rowCount === 0) {
fail(`no user matches "${identifier}"`);
}
if (found.rowCount > 1) {
fail(
`"${identifier}" matches ${found.rowCount} users — refusing to guess; reset by exact username`,
);
}
const user = found.rows[0];
const passwordHash = await hash(newPassword, ARGON2_HASH_OPTIONS);
await client.query(
`UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2`,
[passwordHash, user.id],
);
// Never echo the password; confirm by username only.
console.log(`reset-password: password updated for "${user.username}"`);
} finally {
await client.end();
}
}
main().catch((err) => {
fail(err instanceof Error ? err.message : String(err));
});
+154
View File
@@ -0,0 +1,154 @@
-- HealthLog demo seed for apps01 — v1.15 (cycle-inclusive, FEMALE demo).
--
-- Creates / refreshes the `demo` account as a WOMAN with cycle tracking on,
-- NOW()-anchored + idempotent (re-runnable: wipes the demo user's rows first).
-- Showcases vitals + mood + the v1.15 cycle vertical (5 cycles, daily BBT with
-- a biphasic shift, mucus/OPK progression, period flow, phase-clustered
-- symptoms), plus phase-dependent RHR/HRV/sleep so the phase×vitals insights
-- board surfaces real findings. role=USER (login is open on prod; an
-- admin demo would be a risk). AI insights are generated separately afterwards.
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-apps01.sql
BEGIN;
-- ── User (FEMALE, USER role) ──────────────────────────────────────
INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
SELECT 'usr_demo_' || replace(gen_random_uuid()::text, '-', ''),
'demo', 'demo@healthlog.app',
'$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
'USER', 168, TIMESTAMP '1992-03-15', 'FEMALE', 'Europe/Berlin', 'en', NOW(), NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'demo');
UPDATE users SET
gender = 'FEMALE', role = 'USER', height_cm = 168, date_of_birth = TIMESTAMP '1992-03-15',
password_hash = '$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
onboarding_completed_at = NOW(), timezone = 'Europe/Berlin', locale = 'en', updated_at = NOW()
WHERE username = 'demo';
CREATE TEMP TABLE _demo AS SELECT id AS user_id FROM users WHERE username = 'demo' LIMIT 1;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM _demo) THEN
RAISE EXCEPTION 'seed-demo-apps01: demo user not resolved';
END IF;
END $$;
-- ── Wipe prior demo rows (idempotent) ─────────────────────────────
DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo));
DELETE FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM cycle_predictions WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM menstrual_cycles WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM mood_entries WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM measurements WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM measurement_rollups WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM mood_entry_rollups WHERE user_id IN (SELECT user_id FROM _demo);
-- ── Cycle profile (tracking ON) ───────────────────────────────────
INSERT INTO cycle_profiles (id, user_id, goal, cycle_tracking_enabled, typical_cycle_length, typical_period_length, luteal_phase_length, prediction_enabled, raw_chart_mode, discreet_notifications, sensitive_category_encryption, created_at, updated_at)
SELECT 'cp_' || replace(gen_random_uuid()::text, '-', ''), user_id, 'GENERAL_HEALTH', true, 28, 5, 14, true, false, false, true, NOW(), NOW()
FROM _demo
WHERE NOT EXISTS (SELECT 1 FROM cycle_profiles cp JOIN _demo d ON cp.user_id = d.user_id);
UPDATE cycle_profiles SET
cycle_tracking_enabled = true, goal = 'GENERAL_HEALTH', prediction_enabled = true,
typical_cycle_length = 28, typical_period_length = 5, luteal_phase_length = 14, updated_at = NOW()
WHERE user_id IN (SELECT user_id FROM _demo);
-- ── Measurements (90-day sliding window, phase-aware) ─────────────
-- cycleDay(d) = ((128 - d) % 28) + 1 ; luteal = cycleDay > 13.
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
v.gc::glucose_context,
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (
SELECT ((128 - d) % 28) + 1 AS cd
) c,
LATERAL (VALUES
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
) AS v(type, value, unit, gc);
-- ── Mood (90 days, luteal dip) ────────────────────────────────────
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo),
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
s.score, 'MANUAL',
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
LATERAL (
SELECT GREATEST(1, LEAST(5,
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
)) AS score
) s;
-- ── Menstrual cycles (5, NOW-anchored; last open) ─────────────────
WITH offs AS (SELECT unnest(ARRAY[128, 100, 72, 44, 16]) AS off)
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM _demo),
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
CASE WHEN off = 16 THEN NULL ELSE to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD') END,
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
CASE WHEN off = 16 THEN NULL ELSE 28 END,
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
true, false, 'Europe/Berlin', NOW(), NOW()
FROM offs;
-- ── Cycle day-logs (128 days) + phase-clustered symptom links ─────
WITH demo AS (SELECT user_id FROM _demo),
gen AS (
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
FROM generate_series(0, 127) AS d
),
ins AS (
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), g.dstr,
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
FROM gen g
RETURNING id, date
),
link_menstrual AS (
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
WHERE g.cd BETWEEN 1 AND 3
)
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
WHERE g.cd BETWEEN 24 AND 28;
COMMIT;
-- ── Summary ───────────────────────────────────────────────────────
SELECT 'demo user' AS what, id FROM users WHERE username = 'demo';
SELECT 'measurements' AS what, count(*) FROM measurements WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'mood' AS what, count(*) FROM mood_entries WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'cycles' AS what, count(*) FROM menstrual_cycles WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'day_logs' AS what, count(*) FROM cycle_day_logs WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = (SELECT id FROM users WHERE username = 'demo');
+216
View File
@@ -0,0 +1,216 @@
-- HealthLog demo 1-year history extender for apps01.
--
-- WHAT THIS DOES
-- Extends the demo account's data BACKWARD from the existing ~90-day seed
-- (scripts/seed-demo-apps01.sql) to ~365 days of coverage, and adds ~365 days
-- of realistic medication-intake history for the two pre-existing demo meds.
-- It mirrors that seed EXACTLY: same column lists, enum values, NOW()-anchored
-- offset math (cycleDay(d) = ((128 - d) % 28) + 1), cuid/uuid-id style, and the
-- same phase-aware vitals / mood / cycle channels.
--
-- The seed covers offsets 0..89 (measurements + mood), 0..127 (cycle day-logs),
-- and 5 cycles at NOW-offsets {128,100,72,44,16}. This file ADDS ONLY the older
-- tail so nothing is duplicated:
-- - measurements / mood : offsets 90..364 (thinned to every 2nd day past ~180d)
-- - cycle day-logs : offsets 128..364
-- - menstrual cycles : 7 more 28-day cycles at offsets 156..352
-- - medication intakes : ~365d of due slots for both demo meds
--
-- SCOPE + SAFETY
-- - Demo-user-scoped: EVERY statement filters on the hardcoded demo user id.
-- - Idempotent: every INSERT guards on the live unique index via ON CONFLICT
-- DO NOTHING (or a date-offset window that begins past the seed's coverage),
-- so re-running adds nothing new.
-- - NO DDL (no CREATE/ALTER/DROP/TRUNCATE), no changes to any other user,
-- no table-wide DELETE/UPDATE.
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-extend-1y.sql
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
\set medA 'cmq2lzjqh005x01nrys2crzg9' -- Mounjaro: weekly @ 08:00 (rolling 7d)
\set medB 'cmq2m0mzk006101nrdpijbij0' -- Ramipril: twice daily @ 08:00 + 18:00
BEGIN;
-- Refuse to run if the demo user does not exist.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
RAISE EXCEPTION 'seed-demo-extend-1y: demo user not found';
END IF;
END $$;
-- ── Measurements: offsets 90..364, phase-aware (mirrors the seed channels) ─────
-- Thin the deep tail: keep every day for 90..179, every 2nd day for 180..364, to
-- keep row volume sane while still covering the full year. Unique index
-- (user_id, type, measured_at, source, sleep_stage) NULLS NOT DISTINCT — but
-- measured_at carries a random sub-day jitter, so collisions are effectively
-- impossible on a fresh tail; ON CONFLICT DO NOTHING makes re-runs safe anyway.
WITH days AS (
SELECT d FROM generate_series(90, 364) AS d
WHERE d < 180 OR (d % 2) = 0
)
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
v.gc::glucose_context,
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
LATERAL (VALUES
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
) AS v(type, value, unit, gc)
ON CONFLICT DO NOTHING;
-- ── Mood: offsets 90..364, luteal dip (mirrors the seed) ───────────────────────
-- Unique index (user_id, date, mood_logged_at). One entry per day, fresh tail.
WITH days AS (SELECT generate_series(90, 364) AS d)
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
s.score, 'MANUAL',
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
LATERAL (
SELECT GREATEST(1, LEAST(5,
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
)) AS score
) s
ON CONFLICT DO NOTHING;
-- ── Menstrual cycles: 7 more 28-day cycles, offsets 156..352 ───────────────────
-- Continues the seed's cadence backward (seed had {128,100,72,44,16}; next is 156).
-- Unique index (user_id, start_date) → ON CONFLICT DO NOTHING. All closed cycles.
WITH offs AS (SELECT unnest(ARRAY[156, 184, 212, 240, 268, 296, 324, 352]) AS off)
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD'),
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
28,
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
true, false, 'Europe/Berlin', NOW(), NOW()
FROM offs
ON CONFLICT (user_id, start_date) DO NOTHING;
-- ── Cycle day-logs: offsets 128..364 + phase-clustered symptom links ───────────
-- Mirrors the seed exactly (flow/BBT/mucus/OPK by cycleDay). Unique index
-- (user_id, date) → ON CONFLICT DO NOTHING. Symptom links only attach to rows
-- this run actually inserts (RETURNING), so re-runs add no duplicate links.
WITH gen AS (
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
FROM generate_series(128, 364) AS d
),
ins AS (
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d', g.dstr,
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
FROM gen g
ON CONFLICT (user_id, date) DO NOTHING
RETURNING id, date
),
link_menstrual AS (
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
WHERE g.cd BETWEEN 1 AND 3
)
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
WHERE g.cd BETWEEN 24 AND 28;
-- ── Medication intakes: Med A (weekly @ 08:00 Berlin), ~365d ───────────────────
-- The existing demo rows store scheduled_for in UTC at 06:00 (= 08:00 Berlin DST).
-- We mint slots at the Berlin local 08:00 and convert to the stored UTC wall-time
-- with AT TIME ZONE so winter (07:00 UTC) vs summer (06:00 UTC) is honoured.
-- Live partial unique index: (user_id, medication_id, scheduled_for, source)
-- WHERE deleted_at IS NULL → ON CONFLICT on that predicate makes re-runs safe.
-- Compliance ~85-90%: deterministic per-slot pseudo-random skip ~12% of weeks.
-- Never future-dated: WHERE the slot timestamp <= NOW().
WITH slots AS (
SELECT
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (w * 7 || ' days')::interval + interval '8 hours')
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
-- deterministic skip decision per week offset (no random(): re-run stable)
( (hashtextextended('medA-week-' || w, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
FROM generate_series(0, 52) AS w -- 53 weekly slots = w*7 days back ≈ 371 days
)
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
SELECT 'cmq_demo_a_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
'cmq2lzjqh005x01nrys2crzg9',
s.sched_utc,
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '90 minutes')) ELSE NULL END,
CASE WHEN s.r < 0.88 THEN false ELSE true END,
'WEB'::intake_source,
s.sched_utc, s.sched_utc, 0
FROM slots s
WHERE s.sched_utc <= NOW()
-- skip slots already covered by the seeded rows (06-06 06:00 + 06-13 06:00)
AND s.sched_utc < date_trunc('day', NOW()) - interval '1 day'
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
-- ── Medication intakes: Med B (twice daily @ 08:00 + 18:00 Berlin), ~365d ──────
-- One row per due slot per day. Compliance ~85-90%: deterministic per-slot skip
-- ~12% of individual slots. Same TZ + ON CONFLICT + no-future-date guards.
WITH slots AS (
SELECT
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (dd || ' days')::interval + (hh || ' hours')::interval)
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
( (hashtextextended('medB-' || dd || '-' || hh, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
FROM generate_series(0, 364) AS dd
CROSS JOIN (VALUES (8), (18)) AS h(hh)
)
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
SELECT 'cmq_demo_b_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
'cmq2m0mzk006101nrdpijbij0',
s.sched_utc,
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '120 minutes')) ELSE NULL END,
CASE WHEN s.r < 0.88 THEN false ELSE true END,
'WEB'::intake_source,
s.sched_utc, s.sched_utc, 0
FROM slots s
WHERE s.sched_utc <= NOW()
-- skip slots already covered by the seeded rows (today's 06:00 + 16:00 UTC)
AND s.sched_utc < date_trunc('day', NOW())
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
COMMIT;
-- ── Verification (demo-user-scoped totals after apply) ─────────────────────────
\echo === post-apply totals (demo user) ===
SELECT 'measurements' AS what, count(*), min(measured_at)::date AS oldest, max(measured_at)::date AS newest FROM measurements WHERE user_id = :'uid';
SELECT 'mood' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM mood_entries WHERE user_id = :'uid';
SELECT 'cycles' AS what, count(*), min(start_date) AS oldest, max(start_date) AS newest FROM menstrual_cycles WHERE user_id = :'uid';
SELECT 'day_logs' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM cycle_day_logs WHERE user_id = :'uid';
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = :'uid';
SELECT 'intake_medA' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2lzjqh005x01nrys2crzg9';
SELECT 'intake_medB' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2m0mzk006101nrdpijbij0';
+153
View File
@@ -0,0 +1,153 @@
-- HealthLog demo computed-wellness-score seeder for apps01 (1 year).
--
-- WHAT THIS DOES
-- Seeds the three PERSISTED nightly wellness scores for the demo account so
-- all of the dashboard's "Deine Gesundheitswerte" rings render with a full
-- year of history. These three are normally written by the nightly score
-- crons (src/lib/jobs/{recovery,stress,strain}-score.ts) as COMPUTED
-- Measurement rows; the demo never had them computed, so only the two
-- compute-on-read composites (READINESS + SLEEP_SCORE — derived live from the
-- demo's RHR/HRV/sleep/mood, NOT stored) were showing.
--
-- channel type external_id unit value
-- Recovery RECOVERY_SCORE recovery:YYYY-MM-DD score 0..100 (55-90)
-- Strain STRAIN_SCORE strain:YYYY-MM-DD score 0..100 (20-75)
-- Stress STRESS_SCORE stress:YYYY-MM-DD score 0..100 (25-65)
--
-- READINESS and SLEEP_SCORE are NOT seeded here: neither exists in the
-- measurement_type enum (verified live) — both are recomputed on read from
-- the underlying signals the demo already carries, so they already render and
-- a stored row would be ignored. Seeding them would be dead data.
--
-- Row shape mirrors src/lib/insights/score-row.ts::upsertScoreRow exactly:
-- source = COMPUTED, unit = 'score', measured_at = noon UTC on the scored day,
-- external_id = '<prefix>:YYYY-MM-DD', sleep_stage / device_type /
-- external_source_version / deleted_at all NULL.
--
-- Coverage: one row per (channel, UTC day) for the last 365 days (offsets
-- 1..365 — offset 0 is "today", which the nightly cron scores as the PREVIOUS
-- day, so the freshest persisted day is yesterday; this matches the job and
-- keeps every row strictly measured_at <= NOW()). 365 days x 3 channels =
-- 1,095 rows on a clean apply.
--
-- DETERMINISM
-- Values + ids are hash-derived from the day stamp (hashtextextended /
-- md5 — NO random()), so a re-run is byte-stable. Idempotent re-runs are
-- additionally guarded by ON CONFLICT DO NOTHING on the live unique index
-- (user_id, type, source, external_id) — the exact key the cron upserts on.
--
-- SCOPE + SAFETY
-- - Demo-user-scoped: EVERY statement hardcodes the demo user id.
-- - Idempotent: ON CONFLICT DO NOTHING; re-running adds nothing.
-- - No DDL (no CREATE/ALTER/DROP/TRUNCATE), no other-user writes, no
-- table-wide DELETE/UPDATE, no future-dated rows (measured_at <= NOW()).
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-scores-1y.sql
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
BEGIN;
-- Refuse to run if the demo user does not exist.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
RAISE EXCEPTION 'seed-demo-scores-1y: demo user not found';
END IF;
END $$;
-- ── The three persisted score channels, one row per (channel, day) ─────────────
-- offsets 1..365 (the scored day is the day that just ended; offset 0 = today is
-- never persisted by the cron). day_key is the UTC calendar day; measured_at is
-- noon UTC on it, mirroring scoreMeasuredAt(). Deterministic values:
-- - week phase : a gentle 7-day sinusoid keyed to the day-of-year (weekly
-- rhythm — lighter mid-week, heavier toward the weekend).
-- - noise : per-(channel,day) hash in [0,1), no random().
-- - recovery : 55..90, dips after a "hard" (high-strain) day.
-- - strain : 20..75, weekend / mid-week training spikes (~25% of days).
-- - stress : 25..65, inversely coupled to recovery (recovery up → stress
-- down) so the two rings tell a coherent story.
WITH days AS (
SELECT
d,
(NOW() AT TIME ZONE 'UTC')::date - d AS day_key
FROM generate_series(1, 365) AS d
),
calc AS (
SELECT
d,
day_key,
(day_key + 12)::timestamp AS measured_at, -- noon UTC on the scored day
-- weekly rhythm in [-1, 1] from ISO day-of-week (Mon=1..Sun=7)
sin(2 * pi() * (extract(isodow FROM day_key)::numeric - 1) / 7.0) AS week_phase,
-- deterministic per-day "is this a training day" flag (~25%)
( (hashtextextended('score-train-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS train_r,
-- independent deterministic noise streams per channel, all in [0,1)
( (hashtextextended('score-rec-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS rec_r,
( (hashtextextended('score-str-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS str_r,
( (hashtextextended('score-stress-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS stress_r
FROM days
),
vals AS (
SELECT
d, day_key, measured_at, train_r,
-- STRAIN 20..75: base + weekly rhythm + a spike on training days.
LEAST(75, GREATEST(20, round(
40 + week_phase * 8
+ (CASE WHEN train_r < 0.25 THEN 22 ELSE 0 END)
+ (str_r - 0.5) * 14
)))::int AS strain,
-- RECOVERY 55..90: base + weekly rhythm, suppressed the day after a hard day.
LEAST(90, GREATEST(55, round(
74 + week_phase * 6
- (CASE WHEN train_r < 0.25 THEN 7 ELSE 0 END)
+ (rec_r - 0.5) * 16
)))::int AS recovery
FROM calc
),
final AS (
SELECT
v.d, v.day_key, v.measured_at, v.strain, v.recovery,
-- STRESS 25..65: inverse to recovery (high recovery → low stress) + noise.
LEAST(65, GREATEST(25, round(
90 - v.recovery * 0.5
+ (c.stress_r - 0.5) * 14
)))::int AS stress
FROM vals v JOIN calc c USING (d)
),
rows AS (
SELECT day_key, measured_at, type, ext_prefix, value FROM final,
LATERAL (VALUES
('RECOVERY_SCORE', 'recovery:', recovery),
('STRAIN_SCORE', 'strain:', strain),
('STRESS_SCORE', 'stress:', stress)
) AS ch(type, ext_prefix, value)
)
INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
SELECT
-- deterministic id from the per-row external_id → byte-stable re-runs.
'm_score_' || md5(ext_prefix || to_char(day_key, 'YYYY-MM-DD')),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
type::measurement_type,
value::float8,
'score',
'COMPUTED'::measurement_source,
measured_at,
ext_prefix || to_char(day_key, 'YYYY-MM-DD'),
measured_at,
measured_at
FROM rows
WHERE measured_at <= NOW()
ON CONFLICT (user_id, type, source, external_id) DO NOTHING;
COMMIT;
-- ── Verification (demo-user-scoped, all five score channels) ───────────────────
\echo === post-apply score coverage (demo user) ===
SELECT type, count(*) AS rows, min(measured_at)::date AS oldest, max(measured_at)::date AS newest
FROM measurements
WHERE user_id = :'uid'
AND type IN ('RECOVERY_SCORE', 'STRAIN_SCORE', 'STRESS_SCORE')
GROUP BY type
ORDER BY type;
\echo (READINESS + SLEEP_SCORE are compute-on-read composites — not stored, render from underlying signals.)
+4
View File
@@ -395,6 +395,10 @@ export default function InsightsPage() {
extraTile={
user?.cycleTrackingEnabled ? <CycleRingTile /> : undefined
}
// v1.15.5 — when the cycle ring is shown it TAKES the Strain slot:
// hide Strain so the strip stays compact instead of growing a sixth
// tile. Strain stays visible for non-cycle accounts.
hideStrain={user?.cycleTrackingEnabled === true}
/>
{flags.briefing && (
-2
View File
@@ -11,7 +11,6 @@ import { ExportSection } from "@/components/settings/export-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
import { SharingSection } from "@/components/settings/sharing-section";
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
// separate sections again. `ThresholdsSection` renders the target-range
// editor; `SourcesSection` (standalone mode) renders the source-priority
@@ -53,7 +52,6 @@ const SECTION_COMPONENTS: Record<
thresholds: ThresholdsSection,
sources: () => <SourcesSection />,
api: ApiSection,
sharing: SharingSection,
export: ExportSection,
advanced: AdvancedSection,
};
@@ -142,6 +142,16 @@ describe("<CycleInsightSummaryCard>", () => {
expect(html).toContain('href="/cycle?tab=insights"');
expect(html).toContain("View cycle insights");
expect(html).toContain('data-phase="LUTEAL"');
// Regression: the entrance-animation element (`.wellness-tile-rise`, which
// is `opacity: 0` until revealed) must sit UNDER a `data-revealed` ancestor
// — the CSS selector is a descendant combinator. With both on the same node
// the card stayed permanently invisible but still occupied its box, which
// read as an oversized gap on the overview. Assert the wrapper precedes the
// rise element so the descendant selector matches.
const revealedIdx = html.indexOf('data-revealed="true"');
const riseIdx = html.indexOf("wellness-tile-rise");
expect(revealedIdx).toBeGreaterThan(-1);
expect(riseIdx).toBeGreaterThan(revealedIdx);
});
it("shows the honest empty headline line when nothing cleared the FDR gate", () => {
@@ -77,65 +77,75 @@ export function CycleInsightSummaryCard() {
: t("cycle.ring.ariaUnknown");
return (
<section
data-slot="cycle-insight-summary"
data-revealed="true"
data-phase={phase ?? "none"}
aria-label={t("cycle.insightsSummary.title")}
style={{ "--tile-hue": hue } as React.CSSProperties}
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
>
<div className="flex items-start justify-between gap-3">
{/* Zone 1 — eyebrow + the current phase / cycle-day read. */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles
className="h-4 w-4 shrink-0"
style={{ color: hue }}
// `data-revealed` must sit on an ANCESTOR of the `.wellness-tile-rise`
// element — the keyframe selector is `[data-revealed="true"]
// .wellness-tile-rise` (descendant combinator). Putting both on the same
// node never matched, so the tile stayed stuck at the rise rule's
// `opacity: 0` — invisible but still occupying its box, which read as an
// oversized gap on the overview. The wrapper restores the entrance.
<div data-revealed="true" className="contents">
<section
data-slot="cycle-insight-summary"
data-phase={phase ?? "none"}
aria-label={t("cycle.insightsSummary.title")}
style={{ "--tile-hue": hue } as React.CSSProperties}
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
>
<div className="flex items-start justify-between gap-3">
{/* Zone 1 — eyebrow + the current phase / cycle-day read. */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles
className="h-4 w-4 shrink-0"
style={{ color: hue }}
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("cycle.insightsSummary.title")}
</span>
</div>
<h3
className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold"
aria-label={ariaLabel}
>
<span
aria-hidden="true"
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hue }}
/>
<span>{phaseName}</span>
</h3>
<p
className="text-muted-foreground mt-1 text-sm"
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("cycle.insightsSummary.title")}
</span>
>
{dayOfCycle != null
? t("cycle.insightsSummary.currentDay", { day: dayOfCycle })
: t("cycle.insightsSummary.noActiveCycle")}
</p>
</div>
<h3
className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold"
aria-label={ariaLabel}
>
<span
aria-hidden="true"
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hue }}
/>
<span>{phaseName}</span>
</h3>
<p className="text-muted-foreground mt-1 text-sm" aria-hidden="true">
{dayOfCycle != null
? t("cycle.insightsSummary.currentDay", { day: dayOfCycle })
: t("cycle.insightsSummary.noActiveCycle")}
</p>
</div>
</div>
{/* Zone 2 — the one headline phase finding (shared component owns the
{/* Zone 2 — the one headline phase finding (shared component owns the
FDR-gated copy AND the honest "not enough cycles yet" empty line, so
this can never show a broken state). */}
<div className="mt-3">
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
</div>
<div className="mt-3">
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
</div>
{/* Zone 3 — the deep-link into the single source of truth: the cycle
{/* Zone 3 — the deep-link into the single source of truth: the cycle
insights tab. The cycle page reads `?tab=insights` and opens that tab. */}
<Link
href="/cycle?tab=insights"
data-slot="cycle-insight-summary-link"
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
>
{t("cycle.insightsSummary.viewDetails")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</section>
<Link
href="/cycle?tab=insights"
data-slot="cycle-insight-summary-link"
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
>
{t("cycle.insightsSummary.viewDetails")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</section>
</div>
);
}
@@ -115,18 +115,14 @@ describe("<CoincidentDeviationCard>", () => {
expect(fired).toContain("min-h-48");
});
it("renders the insufficient (building baselines) state, never an alarm", () => {
it("renders nothing in the insufficient (building baselines) state", () => {
mock(insufficient());
const html = render(<CoincidentDeviationCard />);
expect(html).toContain('data-state="insufficient"');
expect(html).toContain('data-slot="coincident-building"');
expect(html).toContain('data-slot="coverage-meter"');
// Calm, not amber/red.
expect(html).not.toContain("border-warning");
expect(html).not.toContain("text-destructive");
// No signal yet → the section stays absent rather than an empty husk.
expect(html).toBe("");
});
it("renders the all-clear state with the count checked", () => {
it("renders nothing in the all-clear state (no signal to show)", () => {
mock(
ok({
fired: false,
@@ -136,12 +132,8 @@ describe("<CoincidentDeviationCard>", () => {
}),
);
const html = render(<CoincidentDeviationCard />);
expect(html).toContain('data-state="all-clear"');
expect(html).toContain("All your vitals are within their personal range");
expect(html).toContain("text-success");
// No alarm tone.
expect(html).not.toContain("border-warning");
expect(html).not.toContain("text-destructive");
// Every vital inside its personal range → hide the whole card.
expect(html).toBe("");
});
it("renders the watch state for a single out-of-band vital, not the fired tone", () => {
@@ -1,6 +1,6 @@
"use client";
import { CheckCircle2, AlertTriangle, Activity } from "lucide-react";
import { AlertTriangle, Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -17,22 +17,20 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
/**
* v1.10.3 — "Today's signal" headline card.
*
* Promotes the COINCIDENT_DEVIATION flag from one buried below-the-fold vitals
* tile (painted only when it fired) to a dedicated, always-mounted card at the
* top of the Insights overview, matching the always-present pattern Apple
* Vitals / WHOOP Health Monitor / Oura Symptom Radar use. The signal is the
* daily headline read, not an alarm: an "all clear" day is itself the
* reassuring product.
* Surfaces the COINCIDENT_DEVIATION flag at the top of the Insights overview as
* a calm awareness read drawn off the SINGLE existing `Derived` payload — no new
* engine math, no new route, no schema change.
*
* It renders four calm states off the SINGLE existing `Derived` payload — no
* new engine math, no new route, no schema change:
* - insufficient (< 2 banded vitals) → "building your baselines" + coverage.
* - all-clear (ok, !fired, 0 contributing) → a green check + the count
* checked.
* - watch (ok, !fired, 1 contributing) → a calm, non-alert line naming the
* one vital.
* - fired (ok, fired, ≥ 2 contributing) → an amber awareness card naming the
* vitals + the load-bearing "possible factors — never a cause" line.
* The card renders ONLY when there is an actual signal to show. A day where
* every vital sits inside its personal range (all-clear), or an account still
* building its baselines (insufficient), produces no card at all — the section
* stays absent rather than surfacing an empty header. The two states that DO
* render:
* - watch (ok, 1 contributing vital, or a thin-history multi-signal flag) →
* a calm, non-alert line naming the vital.
* - fired (ok, fired, ≥ 2 contributing, with enough history) → an amber
* awareness card naming the vitals + the load-bearing "possible factors —
* never a cause" line.
*
* Restraint guarantees: at most the `warning` (amber) band — never
* `destructive`/red, never a score, never a chart (so no Recharts), never a
@@ -167,22 +165,10 @@ export function CoincidentDeviationCard({
);
}
// Building the baselines — fewer than two banded vitals. Calm, never an
// alarm, never blank.
// Building the baselines — fewer than two banded vitals. There is no signal
// to show, so the section stays absent rather than rendering an empty husk.
if (data.status === "insufficient") {
return (
<div className={className}>
<CardShell state="insufficient" provenance={data.provenance}>
<p
className="text-muted-foreground text-sm"
data-slot="coincident-building"
>
{t("insights.derived.coincident.building")}
</p>
<CoverageMeter coverage={data.coverage} />
</CardShell>
</div>
);
return null;
}
const v = data.value!;
@@ -208,32 +194,10 @@ export function CoincidentDeviationCard({
? "fired"
: "watch";
// All clear — every vital is inside its personal range, so there is no signal
// worth a card. Hide the whole section rather than surface an empty header.
if (state === "all-clear") {
return (
<div className={className}>
<CardShell state="all-clear" provenance={data.provenance}>
<div className="flex items-start gap-2">
<CheckCircle2
className="text-success mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<p
className="text-foreground text-sm font-medium"
data-slot="coincident-headline"
>
{t("insights.derived.coincident.allClear")}
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.derived.coincident.allClearMeta", {
count: v.vitals.length,
})}
</p>
</div>
</div>
</CardShell>
</div>
);
return null;
}
if (state === "watch") {
@@ -101,4 +101,23 @@ describe("<WellnessScores>", () => {
expect(html).not.toContain('data-metric="STRESS_SCORE"');
expect(html).not.toContain('data-metric="STRAIN_SCORE"');
});
it("drops the Strain tile when hideStrain is set (cycle ring takes its slot)", () => {
const scores = {
READINESS: ok({ score: 80, band: "green" }),
STRAIN_SCORE: ok({ score: 40, band: "yellow" }),
};
// Without the flag Strain renders.
const withStrain = render(
<WellnessScores read={readFrom(scores)} isLoading={false} />,
);
expect(withStrain).toContain('data-metric="STRAIN_SCORE"');
// With the flag Strain is suppressed while the other score stays.
const hidden = render(
<WellnessScores read={readFrom(scores)} isLoading={false} hideStrain />,
);
expect(hidden).not.toContain('data-metric="STRAIN_SCORE"');
expect(hidden).toContain('data-metric="READINESS"');
});
});
@@ -57,6 +57,13 @@ interface ScoreStripProps {
* show, so the strip subtracts it from the count in that case.
*/
extraTile?: React.ReactNode;
/**
* Drop the Strain score tile from the rendered strip. Set when the cycle ring
* takes Strain's slot (cycle-tracking accounts) so the row stays compact at
* the same column count instead of growing a sixth tile. Strain stays
* available for non-cycle accounts; it is only hidden, never removed.
*/
hideStrain?: boolean;
className?: string;
}
@@ -160,6 +167,7 @@ export function WellnessScores({
isError = false,
refetch,
extraTile,
hideStrain = false,
className,
}: ScoreStripProps) {
const { t } = useTranslations();
@@ -302,7 +310,10 @@ export function WellnessScores({
/>,
);
}
if (!isLoading && strain?.status === "ok" && strain.value) {
// When the cycle ring takes the slot (cycle-tracking accounts), the Strain
// tile is dropped so the row stays compact rather than growing a sixth tile.
// Strain renders normally for everyone else.
if (!hideStrain && !isLoading && strain?.status === "ok" && strain.value) {
tiles.push(
<RingTile
key="STRAIN_SCORE"
@@ -159,9 +159,9 @@ export function PeriodNarrativeCard({
className={cn(SHELL, className)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
<h2 className="text-foreground truncate text-base font-semibold">
{t("insights.narrativeTitle")}
</span>
</h2>
{narrative?.provenance ? (
<ProvenanceDisclosure provenance={narrative.provenance} />
) : null}
@@ -98,6 +98,19 @@ describe("<SidebarNav> cycle entry gate (v1.15.0)", () => {
const html = render({ cycleTrackingEnabled: false });
expect(html).not.toContain('href="/cycle"');
});
it("places the Cycle entry between Medications and Insights when enabled", () => {
const html = render({ cycleTrackingEnabled: true });
const med = html.indexOf('href="/medications"');
const cycle = html.indexOf('href="/cycle"');
const insights = html.indexOf('href="/insights"');
expect(med).toBeGreaterThan(-1);
expect(cycle).toBeGreaterThan(-1);
expect(insights).toBeGreaterThan(-1);
// Cycle sits after Medications and before Insights in document order.
expect(med).toBeLessThan(cycle);
expect(cycle).toBeLessThan(insights);
});
});
describe("<SidebarNav> targets deprecation (v1.8.6)", () => {
+64 -35
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import {
Activity,
Bell,
@@ -124,22 +124,31 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
className="w-60"
sideOffset={8}
>
<div className="flex items-center gap-3 px-2 py-2">
<Avatar className="h-9 w-9 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{user.username}</p>
{user.email && (
<p className="text-muted-foreground truncate text-xs">
{user.email}
</p>
)}
</div>
</div>
{/* R20 — the user header is the direct route into the account
settings. Clicking the avatar / name jumps straight to
`/settings/account` rather than making the user hunt for a
separate "Settings" entry. */}
<DropdownMenuItem asChild>
<Link
href="/settings/account"
className="flex cursor-pointer items-center gap-3 px-2 py-2"
>
<Avatar className="h-9 w-9 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{user.username}</p>
{user.email && (
<p className="text-muted-foreground truncate text-xs">
{user.email}
</p>
)}
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/notifications" className="cursor-pointer">
@@ -219,20 +228,29 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
return (
<div className="border-sidebar-border border-t p-3">
<div className="flex items-center gap-3 px-2 py-1">
<Avatar className="h-8 w-8 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.username}</p>
{user.email && (
<p className="text-muted-foreground truncate text-xs">
{user.email}
</p>
)}
</div>
{/* R20 — the avatar + name in the expanded footer routes straight
into the account settings; the kebab to the right still opens
the rest of the user menu. */}
<Link
href="/settings/account"
aria-label={t("nav.accountSettings")}
className="hover:bg-accent flex min-w-0 flex-1 items-center gap-3 rounded-md transition-colors"
>
<Avatar className="h-8 w-8 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.username}</p>
{user.email && (
<p className="text-muted-foreground truncate text-xs">
{user.email}
</p>
)}
</div>
</Link>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t("nav.userMenu")}
@@ -260,10 +278,21 @@ export function SidebarNav() {
// with no sub-item expansion in the global sidebar — `<AdminShell>`
// renders its own per-section nav inside the page itself.
const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/");
// v1.15.0 — append the cycle entry only when the gate resolves true.
const visibleNavItems = user?.cycleTrackingEnabled
? [...navItems, cycleNavItem]
: navItems;
// v1.15.5 — surface the cycle entry directly after Medications (before
// Insights) when the gate resolves true, instead of tacking it on at the
// end. Splice by the Medications index so the position survives a reorder
// of the static list; fall back to append if the anchor ever moves.
const visibleNavItems = useMemo(() => {
if (!user?.cycleTrackingEnabled) return navItems;
const afterMedications =
navItems.findIndex((item) => item.href === "/medications") + 1;
if (afterMedications <= 0) return [...navItems, cycleNavItem];
return [
...navItems.slice(0, afterMedications),
cycleNavItem,
...navItems.slice(afterMedications),
];
}, [user?.cycleTrackingEnabled]);
const [collapsed, setCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
try {
@@ -84,6 +84,51 @@ describe("medication card-parts — shared presentational components", () => {
expect(html).not.toContain("lucide-flame");
});
it("compliance bars show taken / expected counts next to each rate", () => {
// v1.15.8 — two identical percentages with no context read as a stuck
// display; the count after each rate makes them distinguishable. A
// rolling weekly med reading 100% on both windows now shows
// `100% · 4 / 4` and `100% · 52 / 52`.
const html = render(
<MedicationComplianceBars
rate7={100}
rate30={100}
streak={3}
takenShort={4}
expectedShort={4}
takenLong={52}
expectedLong={52}
/>,
);
expect(html).toContain("4 / 4");
expect(html).toContain("52 / 52");
// The middle-dot separator joins the percentage and the count.
expect(html).toContain("·");
});
it("compliance bars fall back to the bare expected count when no taken count is on the wire", () => {
const html = render(
<MedicationComplianceBars
rate7={80}
rate30={75}
streak={0}
expectedShort={12}
expectedLong={52}
/>,
);
expect(html).toContain("12 doses");
expect(html).toContain("52 doses");
});
it("compliance bars render no count line when neither count is available", () => {
// Older mocks / the pre-display fallback path pass only the rates.
const html = render(
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
);
expect(html).not.toContain("·");
expect(html).not.toContain("doses");
});
it("status pill stamps the success token + take-now glyph in window", () => {
const html = render(
<MedicationStatusPill
@@ -207,49 +252,18 @@ describe("medication cycle status — open-cycle line", () => {
expect(html).not.toContain("Next dose in");
});
it("on_track several days out reads the relative-day phrasing in success tone", () => {
const html = render(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt: inDays(4) }}
/>,
);
expect(html).toContain("Next dose in 4 days");
expect(html).toContain("text-success");
expect(html).toContain("lucide-circle-check");
});
it("on_track reads the same day count when nextDueAt arrives as an ISO string", () => {
// Over the wire `nextDueAt` is JSON, so it reaches the card as a string
// even though the type says `Date`. The component must re-hydrate it for
// the Berlin day-bucketing — a raw string would silently drift the count.
const iso = inDays(4).toISOString() as unknown as Date;
const html = render(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt: iso }}
/>,
);
expect(html).toContain("Next dose in 4 days");
});
it("on_track one day out reads tomorrow", () => {
const html = render(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt: inDays(1) }}
/>,
);
expect(html).toContain("Next dose tomorrow");
expect(html).not.toContain("in 1 days");
});
it("on_track later today reads today", () => {
// Six hours past the pinned noon — still the same Berlin calendar day.
const later = new Date(Date.now() + 6 * 60 * 60 * 1000);
const html = render(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt: later }}
/>,
);
expect(html).toContain("Next dose today");
it("on_track renders nothing — the next-dose timing already reads on the card's top slot", () => {
// The calm on_track "next dose in N days" phrasing duplicates the
// next-intake slot at the top of the card, so the open-cycle line stays
// silent for an on-track med; only due / overdue earn a line here.
for (const nextDueAt of [inDays(4), inDays(1), new Date(Date.now() + 6 * 60 * 60 * 1000)]) {
const html = render(
<MedicationCycleStatus
cycle={{ ...baseCycle, state: "on_track", nextDueAt }}
/>,
);
expect(html).toBe("");
}
});
it("due reads the amber due-today line", () => {
@@ -320,14 +320,16 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
// The last-line renders the "Last:" label in its own left column and
// v1.15.8 — the GLP-1 card unified onto the generic "Next intake" /
// "Last intake" labels (the appointment-phrased override is gone). The
// last-line renders the "Last intake:" label in its own left column and
// the value (label · site) flush right; both halves show in the SSR.
expect(html).toContain("Last:");
expect(html).toContain("Last intake:");
expect(html).toContain("Abdomen, lower left");
// The next-line carries the "Next:" label in its left column,
// The next-line carries the "Next intake:" label in its left column,
// independent of which of the three value variants (today / tomorrow /
// "in N days") the helper produced.
expect(html).toMatch(/Next:/);
expect(html).toMatch(/Next intake:/);
});
it("no longer renders the inline dose-history disclosure (retired v1.4.28)", () => {
@@ -370,15 +372,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
expect(html).not.toContain("Dosis-Historie");
});
it("renders the injection-site rotation nudge folded into the upcoming line", () => {
// The rotation nudge surfaces only when `lastSite` and the recommender
// disagree. We feed a left-cluster history → the recommender lands on a
// right-side or thigh/arm site. The nudge is no longer a separate
// bordered block (which made the GLP-1 card taller than the generic card
// in a grid row); it is folded into the upcoming-injection line as a
// "· Recommended next: …" suffix, so the card keeps the same row
// inventory as the generic card. The last site stays visible on the
// last-injection line.
it("does not render an injection-site recommendation on the card", () => {
// The card no longer carries a "recommended next site" nudge — the
// recommendation is noise on the at-a-glance surface. Site TRACKING stays
// (the last site reads on the last-injection line; the post-dose picker
// still captures the next site), only the recommendation copy is gone.
const client = makeClient();
seedCompliance(client, med7p5.id);
seedGlp1Details(client, med7p5.id, {
@@ -401,11 +399,9 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
// The recommended-next nudge renders in the upcoming line.
expect(html).toContain("Recommended next:");
// The standalone bordered rotation block is gone (height-neutral fold).
expect(html).not.toContain("bg-muted/40 rounded-md border");
// Last site stays visible on the last-injection line.
// No recommendation copy anywhere on the card.
expect(html).not.toContain("Recommended next:");
// Last site stays visible on the last-injection line (tracking preserved).
expect(html).toContain("Abdomen, lower left");
});
@@ -612,7 +608,9 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
// visually symmetric with the generic card on the medications list.
expect(html).toContain("Sonstiges");
expect(html).not.toContain("GLP-1-Injektion");
expect(html).toContain("Letzter Termin:");
// v1.15.8 — unified onto the generic "Letzte Einnahme:" label (the
// appointment-phrased "Letzter Termin:" override is gone).
expect(html).toContain("Letzte Einnahme:");
expect(html).toContain("Bauch, unten links");
// v1.4.28 retired the "Dosis-Historie" disclosure on the GLP-1 card.
expect(html).not.toContain("Dosis-Historie");
@@ -3,6 +3,30 @@ import { Flame } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
type Translate = ReturnType<typeof useTranslations>["t"];
type Formatters = ReturnType<typeof useFormatters>;
/**
* v1.15.8 — build the dose-count caption shown after the percentage.
*
* Returns `taken / expected` (`4 / 12`) when both counts are known, the
* pluralised `<expected> doses` string when only the denominator is on the
* wire, and `null` when neither is available (older mocks / pre-display
* fallback) so the caller renders nothing rather than an empty separator.
*/
function doseCount(
t: Translate,
fmt: Formatters,
taken: number | undefined,
expected: number | undefined,
): string | null {
if (typeof expected !== "number") return null;
if (typeof taken === "number") {
return `${fmt.number(taken)} / ${fmt.number(expected)}`;
}
return t("medications.complianceDoses", { count: expected });
}
interface MedicationComplianceBarsProps {
rate7: number;
rate30: number;
@@ -17,6 +41,20 @@ interface MedicationComplianceBarsProps {
shortDays?: number;
/** v1.8.6 — the span of the long row in days. */
longDays?: number;
/**
* v1.15.8 — taken-dose count over the short window (numerator). Rendered
* after the percentage as a `taken / expected` count so two identical
* percentages stay distinguishable: a rolling weekly med reading 100% on
* every window now shows `100% · 4 / 4` vs `100% · 52 / 52` instead of two
* bare identical numbers that read as a stuck display.
*/
takenShort?: number;
/** v1.15.8 — expected-dose count over the short window (denominator). */
expectedShort?: number;
/** v1.15.8 — taken-dose count over the long window (numerator). */
takenLong?: number;
/** v1.15.8 — expected-dose count over the long window (denominator). */
expectedLong?: number;
}
/**
@@ -41,6 +79,10 @@ export function MedicationComplianceBars({
streak,
shortDays = 7,
longDays = 30,
takenShort,
expectedShort,
takenLong,
expectedLong,
}: MedicationComplianceBarsProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -54,12 +96,27 @@ export function MedicationComplianceBars({
const shortPct = fmt.number(Math.round(rate7));
const longPct = fmt.number(Math.round(rate30));
// v1.15.8 — the dose-count line rendered after each percentage. Shows the
// taken-of-expected count when both are known (`4 / 12`), else the bare
// expected count when only the denominator is on the wire (`12 Dosen`).
// Two windows reading the same percentage stay distinguishable by their
// counts, which is what an operator needs to trust the number.
const shortCount = doseCount(t, fmt, takenShort, expectedShort);
const longCount = doseCount(t, fmt, takenLong, expectedLong);
return (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{shortLabel}</span>
<span className="font-medium">{shortPct}%</span>
<span className="font-medium">
{shortPct}%
{shortCount && (
<span className="text-muted-foreground ml-1.5 font-normal">
· {shortCount}
</span>
)}
</span>
</div>
{/* aria-label so the bar has an accessible name. */}
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
@@ -68,7 +125,14 @@ export function MedicationComplianceBars({
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{longLabel}</span>
<span className="font-medium">{longPct}%</span>
<span className="font-medium">
{longPct}%
{longCount && (
<span className="text-muted-foreground ml-1.5 font-normal">
· {longCount}
</span>
)}
</span>
</div>
<Progress value={rate30} className="h-2" aria-label={longLabel} />
</div>
@@ -1,30 +1,13 @@
import { AlertTriangle, CalendarClock, CircleCheck, CircleDashed } from "lucide-react";
import { AlertTriangle, CalendarClock, CircleDashed } from "lucide-react";
import type { CurrentCycle } from "@/lib/analytics/compliance";
import { useTranslations } from "@/lib/i18n/context";
import { toBerlinDate } from "@/lib/medications/window-status";
interface MedicationCycleStatusProps {
/** The open-cycle descriptor from `complianceDisplay.currentCycle`. */
cycle: CurrentCycle;
}
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Whole-day delta between two instants, measured on the user's calendar
* (Europe/Berlin) so "in 2 days" lines up with the day a sparse dose lands on
* rather than a raw 48-hour count. Positive = future, 0 = today, negative =
* past. Mirrors the day-bucketing the card already does for its next/last slot.
*/
function calendarDayDelta(target: Date, now: Date): number {
const a = toBerlinDate(target);
const b = toBerlinDate(now);
const targetMidnight = new Date(a.getFullYear(), a.getMonth(), a.getDate());
const nowMidnight = new Date(b.getFullYear(), b.getMonth(), b.getDate());
return Math.round((targetMidnight.getTime() - nowMidnight.getTime()) / DAY_MS);
}
/**
* v1.14.0 — the open-cycle status line for the medication cards.
*
@@ -32,15 +15,20 @@ function calendarDayDelta(target: Date, now: Date): number {
* rolling med (a GLP-1 injection) that is simply between doses has no scored
* cycle in flight, so the percentage alone reads as a scary 0% / 100% that
* says nothing about whether the user is actually on schedule. This line is
* decoupled from the rate: it reports the open cycle's calm status — when the
* next dose lands, whether it's due or overdue, or that there's not enough
* history to score yet.
* decoupled from the rate: it reports the open cycle's calm status — whether
* the dose is due or overdue, or that there's not enough history to score yet.
*
* The calm `on_track` "next dose in N days" phrasing is intentionally NOT
* rendered here: the upcoming-dose timing already reads on the card's
* next-intake slot at the top, so repeating it would be a duplicate. Only the
* actionable due / overdue tiers (and the neutral no-closed-cycles note) earn
* this line.
*
* Driven by `currentCycle.state` (`on_track` / `due` / `missed` / `none`) plus
* `hasClosedCycles`. Colour follows the same semantic ramp the status pill
* uses (success / warning / destructive) and pairs every tier with a Lucide
* glyph so colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color).
* It complements the compliance number; it does not replace it.
* uses (warning / destructive) and pairs every tier with a Lucide glyph so
* colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color). It
* complements the compliance number; it does not replace it.
*/
export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
const { t } = useTranslations();
@@ -61,6 +49,12 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
);
}
// on_track — the next dose simply hasn't come round yet. The upcoming-dose
// timing already reads on the card's next-intake slot at the top, so a second
// "next dose in N days" line here is a duplicate. Render nothing for the
// on-track case; only the actionable due / overdue tiers earn this line.
if (cycle.state !== "missed" && cycle.state !== "due") return null;
let tone: string;
let icon: React.ReactNode;
let label: string;
@@ -69,30 +63,11 @@ export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
tone = "text-destructive";
icon = <AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />;
label = t("medications.cycleOverdue");
} else if (cycle.state === "due") {
} else {
// due
tone = "text-warning";
icon = <CalendarClock className="size-3.5 shrink-0" aria-hidden="true" />;
label = t("medications.cycleDueToday");
} else {
// on_track — the next dose simply hasn't come round yet.
tone = "text-success";
icon = <CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />;
const now = new Date();
// `nextDueAt` is typed `Date` but arrives as an ISO string over the wire;
// re-hydrate it so the Berlin day-bucketing (toLocaleString with a
// timeZone) actually applies — a raw string would silently skip it and
// drift the count by a day near midnight / off Berlin.
const days = cycle.nextDueAt
? calendarDayDelta(new Date(cycle.nextDueAt), now)
: null;
if (days === null || days <= 0) {
// on_track with the slot today (now < dueAt but same calendar day).
label = t("medications.cycleNextDoseToday");
} else if (days === 1) {
label = t("medications.cycleNextDoseTomorrow");
} else {
label = t("medications.cycleNextDoseInDays", { days });
}
}
return (
@@ -22,16 +22,18 @@ import { useTranslations } from "@/lib/i18n/context";
* that renders both lines, so the dose rows line up across the grid.
*
* Each card still builds the value *content* itself (the generic card emits a
* day-label + window-range; the GLP-1 card emits a weekly-cadence string),
* because the two cadences read differently — but the wrapper, order, colour,
* label/value layout, and gating are now guaranteed identical. The GLP-1 card
* overrides the left-column label with its appointment-phrased wording.
* day-label + window-range; the GLP-1 card emits a relative-day cadence
* string), because the two cadences read differently — but the wrapper,
* order, colour, label/value layout, and gating are guaranteed identical.
* v1.15.8 dropped the GLP-1-specific appointment-phrased label override so
* both cards name the concept identically ("Next intake" / "Last intake");
* only the value content stays per-card.
*
* Layout: when `labelled`, each line is a `flex justify-between` row with the
* bold label pinned left and the value flush right (`text-right`), so the
* label and the time read as two distinct columns rather than crammed onto
* one run of text. The label truncates first on a narrow viewport; the value
* (the time / cadence) never truncates.
* Layout: each line is a `flex justify-between` row with the bold label
* pinned left and the value flush right (`text-right`), so the label and the
* time read as two distinct columns rather than crammed onto one run of text.
* The label truncates first on a narrow viewport; the value (the time /
* cadence) never truncates.
*/
interface MedicationNextLastSlotProps {
/**
@@ -42,57 +44,32 @@ interface MedicationNextLastSlotProps {
next: React.ReactNode | null;
/** The last-intake line content, or null when the med has never been taken. */
last: React.ReactNode | null;
/**
* When true the part renders each line as a two-column row: a bold label
* pinned left and the value flush right. The generic card uses the default
* oral "Next intake" / "Last intake" labels; the GLP-1 card overrides them
* with its appointment-phrased labels (see `nextLabel` / `lastLabel`).
* When false the line renders as a single full-width run of text.
*/
labelled?: boolean;
/**
* Override for the left-column label on the next-line. Defaults to the
* oral `medications.nextIntake` string. The GLP-1 card passes its own
* "next appointment" wording so the value column carries only the time.
*/
nextLabel?: React.ReactNode;
/** Override for the left-column label on the last-line. */
lastLabel?: React.ReactNode;
}
export function MedicationNextLastSlot({
next,
last,
labelled = true,
nextLabel,
lastLabel,
}: MedicationNextLastSlotProps) {
const { t } = useTranslations();
return (
<div className="min-h-[2.75rem] space-y-3.5 text-sm">
{next &&
(labelled ? (
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{nextLabel ?? t("medications.nextIntake")}
</span>
<span className="text-right">{next}</span>
</div>
) : (
<p className="text-muted-foreground">{next}</p>
))}
{last &&
(labelled ? (
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{lastLabel ?? t("medications.lastIntake")}
</span>
<span className="text-right">{last}</span>
</div>
) : (
<p className="text-muted-foreground">{last}</p>
))}
{next && (
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("medications.nextIntake")}
</span>
<span className="text-right">{next}</span>
</div>
)}
{last && (
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("medications.lastIntake")}
</span>
<span className="text-right">{last}</span>
</div>
)}
</div>
);
}
@@ -29,10 +29,7 @@ import {
} from "@/lib/query-keys";
import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import {
nextInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import {
@@ -288,6 +285,13 @@ export function Glp1MedicationCard({
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
// v1.15.8 — taken-of-expected counts, shown after each percentage so two
// identical rates stay distinguishable (a weekly GLP-1 reading 100% on
// both windows shows e.g. `100% · 4 / 4` vs `100% · 13 / 13`).
const takenShort = display?.short.taken;
const expectedShort = display?.expectedShort;
const takenLong = display?.long.taken;
const expectedLong = display?.expectedLong;
// v1.4.37 W4b — symmetric take-now / overdue pill with the generic
// card. Sort schedules by `windowStart` so the most-actionable
@@ -324,11 +328,6 @@ export function Glp1MedicationCard({
const recentInjections = details?.recentIntakes ?? [];
const lastSite =
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
const recommendedNextSite = nextInjectionSite(
recentInjections
.map((r) => r.injectionSite)
.filter(Boolean) as InjectionSiteKey[],
);
function lastInjectionLabel(): string | null {
if (!medication.lastTakenAt) return null;
@@ -411,14 +410,14 @@ export function Glp1MedicationCard({
{/* Injection state — next + last, rendered through the SAME shared
<MedicationNextLastSlot> the generic card uses, so the order
(next-then-last), colour, spacing, gating, reserved min-height
and the label-left / value-right two-column layout are identical
across the two card types. The GLP-1 card overrides only the
left-column label with its appointment-phrased wording; the value
column carries the time (plus the optional rotation nudge). */}
(next-then-last), labels ("Next intake" / "Last intake"), colour,
spacing, gating, reserved min-height and the label-left /
value-right two-column layout are identical across the two card
types. v1.15.8 dropped the GLP-1-specific appointment-phrased
label override so both cards name the concept identically; the
relative-day value phrasing ("in N days" / "today") stays the
GLP-1 card's own and is preserved. */}
<MedicationNextLastSlot
nextLabel={t("medications.glp1NextLabel")}
lastLabel={t("medications.glp1LastLabel")}
next={
next && currentWindowStatus.status !== "in_window" ? (
<>
@@ -433,23 +432,6 @@ export function Glp1MedicationCard({
— {schedule.dose}
</span>
)}
{/* Rotation nudge — folded into the upcoming-injection line
instead of a separate bordered block, so the GLP-1 card
keeps the same row inventory (and height) as the generic
card. Only when we have a last + recommended site that
differs from it. */}
{lastSite &&
recommendedNextSite &&
recommendedNextSite !== lastSite && (
<span className="hidden sm:inline">
{" · "}
{t("medications.glp1RotationSuggested", {
site: t(
`medications.site${siteSuffix(recommendedNextSite)}`,
),
})}
</span>
)}
</>
) : null
}
@@ -487,6 +469,10 @@ export function Glp1MedicationCard({
streak={streak}
shortDays={shortDays}
longDays={longDays}
takenShort={takenShort}
expectedShort={expectedShort}
takenLong={takenLong}
expectedLong={expectedLong}
/>
) : (
<MedicationComplianceSkeleton />
@@ -211,6 +211,14 @@ export function MedicationCard({
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
// v1.15.8 — taken-of-expected counts, shown after each percentage so two
// identical rates stay distinguishable. Falls back to the static 7-day
// `taken` when the cadence-scaled display block is absent.
const takenShort = display?.short.taken ?? compliance?.compliance7?.taken;
const expectedShort =
display?.expectedShort ?? compliance?.compliance7?.totalExpected;
const takenLong = display?.long.taken;
const expectedLong = display?.expectedLong;
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
const sortedSchedules = [...medication.schedules].sort(
(a, b) =>
@@ -398,6 +406,10 @@ export function MedicationCard({
streak={streak}
shortDays={shortDays}
longDays={longDays}
takenShort={takenShort}
expectedShort={expectedShort}
takenLong={takenLong}
expectedLong={expectedLong}
/>
) : (
<MedicationComplianceSkeleton />
@@ -203,28 +203,35 @@ export function ApiTokensRow({
<p className="text-muted-foreground text-xs">
{t("medications.detail.api.caption", { name: medicationName })}
</p>
{/* The raw endpoint URL is redundant with the copy button below, so
it is rendered muted + single-line-truncated rather than as a
full-width block — the long absolute URL was pure visual noise. */}
<code
className="text-muted-foreground bg-muted/60 block truncate rounded px-2 py-1 font-mono text-xs"
title={endpoint}
{/* R26 — the endpoint URL is an inline read-only field with a copy
icon on the right rather than a separate "URL kopieren" button.
The `<code>` carries the value; the icon button copies it. */}
<div
className="border-input bg-muted/60 flex items-center gap-2 rounded-md border px-2 py-1"
data-slot="api-endpoint-url"
>
POST {endpoint}
</code>
<code
className="text-muted-foreground min-w-0 flex-1 truncate font-mono text-xs"
title={endpoint}
>
POST {endpoint}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
void copy(endpoint, t("medications.detail.api.urlCopied"))
}
className="h-7 w-7 shrink-0"
aria-label={t("medications.detail.api.copyUrl")}
data-slot="api-tokens-copy-url"
>
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void copy(endpoint, t("medications.detail.api.urlCopied"))}
className="min-h-11 sm:min-h-9"
data-slot="api-tokens-copy-url"
>
<Copy aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.api.copyUrl")}
</Button>
<Button
size="sm"
onClick={() => void mintToken()}
disabled={busy}
@@ -201,7 +201,7 @@ export function NotificationsBody({
</label>
)}
{status && status.channels.length > 0 && (
{Array.isArray(status?.channels) && status.channels.length > 0 && (
<div
className="flex flex-wrap gap-1.5"
aria-hidden="true"
@@ -1,121 +0,0 @@
/**
* Arztbericht (doctor-report) card contract.
*
* v1.4.37 W7a promoted this surface to the page hero; v1.12 demotes it
* back to a small secondary card at the bottom of `/settings/export`
* (the health-record export now owns the hero). The card stays fully
* functional — only the framing and visual weight changed. This suite
* pins the SSR contract:
*
* 1. The card renders with the expected `data-testid` slot.
* 2. The value statement is present (Marc-Voice copy, no AI mention).
* 3. The CTA button is present, clears the 44 px touch floor, and
* carries an `aria-describedby` link to the value statement.
* 4. The CTA is wired to a click handler (the prop `data-testid`
* surface is the e2e contract).
* 5. Both DE and EN locales resolve their copy without leaking the
* raw i18n key.
*
* Project convention is SSR-only tests (no `@testing-library/react`).
* The actual click-flow + PDF generation is exercised by the e2e
* Playwright path and the dialog's own integration tests.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/export",
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ArztberichtHeroCard } from "../arztbericht-hero-card";
function renderSSR(node: React.ReactElement, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<ArztberichtHeroCard> — SSR contract", () => {
it("renders the card slot with eyebrow + title", () => {
const html = renderSSR(<ArztberichtHeroCard />);
expect(html).toContain('data-testid="export-hero-doctor-report"');
// Title falls back to the existing doctorReport.title key — keeps
// a single source-of-truth for the brand name.
expect(html).toContain("Doctor Report");
// Eyebrow surfaces the use-case framing.
expect(html).toContain("Doctor visit");
});
it("renders the value statement as a Marc-Voice one-liner", () => {
const html = renderSSR(<ArztberichtHeroCard />);
expect(html).toContain('data-testid="export-hero-doctor-report-value"');
expect(html).toContain("printable PDF report");
// No AI/Coach name-drop in the user-facing artefact (Marc-Voice).
expect(html.toLowerCase()).not.toContain("coach");
expect(html.toLowerCase()).not.toContain(" ai ");
});
it("renders the CTA with min-h-11 touch target + aria-describedby", () => {
const html = renderSSR(<ArztberichtHeroCard />);
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
// 44 px touch floor (WCAG 2.5.5) — the demoted CTA keeps `min-h-11`
// on mobile and falls back to the compact `sm:min-h-9` on pointer
// devices.
expect(html).toContain("min-h-11");
// Aria-describedby links the CTA to the value statement so screen
// readers announce the framing alongside the action label.
expect(html).toMatch(/aria-describedby="[^"]+"/);
});
it("does NOT leak raw i18n keys", () => {
const html = renderSSR(<ArztberichtHeroCard />);
expect(html).not.toContain("settings.sections.export.hero");
expect(html).not.toContain("settings.sections.export.cards.doctorReport");
});
it("renders German copy when the DE locale is active", () => {
const html = renderSSR(<ArztberichtHeroCard />, "de");
expect(html).toContain("Arztbericht");
expect(html).toContain("Arzttermin");
// Umlauts encoded as their literal UTF-8 character (not their HTML
// entity), matching the rest of the codebase.
expect(html).toContain("druckbarer PDF-Bericht");
expect(html).not.toContain("settings.sections.export.hero");
});
it("renders as a plain secondary card, not a hero", () => {
const html = renderSSR(<ArztberichtHeroCard />);
// v1.12 demoted the doctor-report from the page hero — the
// health-record export now owns the hero treatment, so this card
// must NOT carry the hero gradient/glow utilities.
expect(html).not.toContain("hero-gradient");
expect(html).not.toContain("glow-purple");
// It renders with the standard secondary-card surface.
expect(html).toContain("bg-card");
});
it("does not paint dialog markup in the closed-state SSR pass", () => {
// Radix Dialog renders its portal content lazily, so the closed
// SSR pass never carries `role="dialog"`. The CTA testid IS
// present so the e2e suite can drive the open from there. Pinning
// this contract guards against an accidental `defaultOpen={true}`
// regression that would briefly paint the dialog above the page on
// every cold mount.
const html = renderSSR(<ArztberichtHeroCard />);
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
expect(html).not.toContain('role="dialog"');
});
});
describe("<ArztberichtHeroCard> — heading hierarchy", () => {
it("uses an <h2> so it nests cleanly below the page-level <h1>", () => {
const html = renderSSR(<ArztberichtHeroCard />);
// Page-level h1 (settings-section-export-title) wraps the section;
// hero owns the h2 slot. The export-section test pins the outer
// h1; this assertion pins the hero's contribution.
expect(html).toMatch(/<h2[^>]*id="export-hero-doctor-report-title"/);
});
});
@@ -7,18 +7,16 @@
* - Medications CSV (with optional intake-history toggle)
* - Mood CSV
* - Full JSON Backup
* - Doctor Report (PDF) — small secondary card at the bottom (v1.12)
*
* Each card must surface a title, a 1-line description, and a
* download/generate button. SSR-only smoke test — interaction is
* exercised by the e2e suite.
*
* v1.12 — the health-record export takes the page hero; the
* doctor-report card (`<ArztberichtHeroCard>`) is demoted to a small
* secondary card at the bottom of the page. The four CSV/JSON tiles
* stay under a "Weitere Export-Optionen" / "Other export options"
* sub-heading. Each component owns its own contract test; this suite
* pins the page-level shape.
* The health-record export takes the page hero. The doctor-report PDF
* now lives under the health-record export and is no longer offered as
* a separate card here. The four CSV/JSON tiles stay under a "Weitere
* Export-Optionen" / "Other export options" sub-heading. Each component
* owns its own contract test; this suite pins the page-level shape.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -29,6 +27,9 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@tanstack/react-query", () => ({
// The cycle-export card and the Apple-Health status poll both call
// `useQuery`; `data: null` keeps the gated cycle card hidden and the
// import poll idle, which is the correct default-render state.
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
@@ -55,11 +56,27 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
describe("<ExportSection> — SSR smoke", () => {
it("renders the section heading + description", () => {
const html = render(<ExportSection />);
expect(html).toContain("Export");
// v1.15.7 — the section was relabelled "Export & Import" (issue #281).
expect(html).toContain("Export &amp; Import");
// Raw key never leaks past i18n.
expect(html).not.toContain("settings.sections.export.");
});
it("mounts the import area below the export options", () => {
const html = render(<ExportSection />);
// R28 — the import surface (issue #281) lives in the same section.
expect(html).toContain('id="settings-section-import-title"');
expect(html).toContain('data-testid="import-card-apple-health"');
expect(html).toContain('data-testid="import-card-json"');
});
it("keeps the gated cycle export hidden when cycle is off", () => {
const html = render(<ExportSection />);
// The mocked `useQuery` returns no prefs, so the gated cycle card
// must not render (fail-closed gate).
expect(html).not.toContain('data-testid="export-card-cycle"');
});
it("mounts the health-record export panel as the page hero", () => {
const html = render(<ExportSection />);
// v1.12 — the health-record export owns the hero treatment.
@@ -68,12 +85,11 @@ describe("<ExportSection> — SSR smoke", () => {
expect(html).toContain("glow-purple");
});
it("mounts the demoted doctor-report card", () => {
it("no longer mounts a separate doctor-report card", () => {
const html = render(<ExportSection />);
// The card owns its own contract suite; here we pin that the page
// mounts it, and that the legacy in-grid doctor-report card is
// gone. The card no longer carries the hero gradient/glow (v1.12).
expect(html).toContain('data-testid="export-hero-doctor-report"');
// The doctor-report PDF now lives under the health-record export;
// the Export page no longer offers it as a separate surface.
expect(html).not.toContain('data-testid="export-hero-doctor-report"');
expect(html).not.toContain('data-testid="export-card-doctor-report"');
});
@@ -107,19 +123,16 @@ describe("<ExportSection> — SSR smoke", () => {
expect(html).toContain('data-testid="export-medications-include-intake"');
});
it("renders a download/generate button per secondary card + the hero CTA", () => {
it("renders a download/generate button per secondary card", () => {
const html = render(<ExportSection />);
// Four secondary cards each carry an `export-action-*` testid; the
// hero owns the `export-hero-doctor-report-action` slot.
// Four secondary cards each carry an `export-action-*` testid.
const buttonMatches = html.match(/data-testid="export-action-/g);
expect(buttonMatches?.length ?? 0).toBe(4);
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
});
it("German locale renders the DE heading + hero copy", () => {
const html = render(<ExportSection />, "de");
expect(html).toContain("Export");
expect(html).toContain("Arztbericht");
expect(html).toContain("Weitere Export-Optionen");
expect(html).not.toContain("settings.sections.export.");
});
@@ -0,0 +1,129 @@
/**
* v1.15.7 — Settings → Export & Import → `<ImportPanel>` contract suite.
*
* Project convention is SSR-only component tests (vitest runs in the
* `node` environment; `@testing-library/react` is not installed). The
* panel's interactive paths (upload, poll, paste-and-import) are
* exercised end-to-end by the e2e suite; here we pin:
*
* 1. The page-level shape — both import cards render with their stable
* testids, localised copy, and the accessible controls (labelled
* file inputs, keyboard-operable drop area, aria-live status slot).
* 2. The "Download example" payload — the inline example JSON the
* button mints must parse and carry the documented field shape (the
* two arrays, the German-anchored mood enum), so the docs and the
* button never drift from the route.
* 3. The error guard — the JSON import refuses an unparseable paste
* before it ever hits the network (validated through the exported
* helper).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/export",
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
ImportPanel,
EXAMPLE_IMPORT,
parseImportJson,
} from "../import-panel";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<ImportPanel> — SSR smoke", () => {
it("renders the import heading and both cards", () => {
const html = render(<ImportPanel />);
expect(html).toContain('id="settings-section-import-title"');
expect(html).toContain('data-testid="import-card-apple-health"');
expect(html).toContain('data-testid="import-card-json"');
// Raw i18n keys never leak past the provider.
expect(html).not.toContain("settings.sections.export.import.");
});
it("exposes the action controls with stable testids", () => {
const html = render(<ImportPanel />);
for (const id of [
"import-action-apple-health",
"import-json-textarea",
"import-json-choose-file",
"import-json-download-example",
"import-action-json",
]) {
expect(html).toContain(`data-testid="${id}"`);
}
});
it("gives the Apple Health drop area a keyboard-operable role", () => {
const html = render(<ImportPanel />);
// A div masquerading as a button must be focusable + role=button so a
// keyboard user can trigger the file picker.
expect(html).toContain('role="button"');
expect(html).toContain("aria-live");
});
it("labels the hidden file inputs for assistive tech", () => {
const html = render(<ImportPanel />);
// Both file inputs are visually hidden (sr-only) but must carry an
// accessible name.
expect(html).toContain('type="file"');
expect(html).toContain("aria-label");
});
it("renders the German copy under the de locale", () => {
const html = render(<ImportPanel />, "de");
expect(html).toContain("Import");
expect(html).not.toContain("settings.sections.export.import.");
});
});
describe("EXAMPLE_IMPORT payload", () => {
it("round-trips through JSON and stays parseable", () => {
const serialised = JSON.stringify(EXAMPLE_IMPORT, null, 2);
const parsed = parseImportJson(serialised);
expect(parsed.ok).toBe(true);
});
it("carries both arrays with the documented field shape", () => {
expect(Array.isArray(EXAMPLE_IMPORT.measurements)).toBe(true);
expect(Array.isArray(EXAMPLE_IMPORT.moodEntries)).toBe(true);
const m = EXAMPLE_IMPORT.measurements[0]!;
expect(m).toHaveProperty("type");
expect(m).toHaveProperty("value");
expect(m).toHaveProperty("unit");
expect(m).toHaveProperty("measuredAt");
const mood = EXAMPLE_IMPORT.moodEntries[0]!;
expect(mood).toHaveProperty("date");
expect(mood).toHaveProperty("mood");
expect(mood).toHaveProperty("score");
// The mood enum values must be the German-anchored server enum.
expect(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]).toContain(
mood.mood,
);
});
});
describe("parseImportJson guard", () => {
it("rejects an unparseable string before any network call", () => {
const result = parseImportJson("{ not json");
expect(result.ok).toBe(false);
});
it("accepts a minimal valid body", () => {
const result = parseImportJson('{"measurements":[]}');
expect(result.ok).toBe(true);
});
});
@@ -131,9 +131,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
});
const html = render();
// Exactly one pill per card → 4 pills total (Withings, moodLog, WHOOP,
// Fitbit / Google Health).
expect(count(html, 'data-testid="integration-status-pill"')).toBe(4);
// Exactly one pill per card → 3 pills total (Withings, WHOOP,
// Fitbit / Google Health). The moodLog integration was removed.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(3);
// The redundant banner from v1.4.15 is gone.
expect(html).not.toContain('data-testid="integration-status-banner"');
// Card-body "letzter Sync" repetition is gone — no
@@ -216,19 +216,12 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).not.toContain('data-testid="integration-status-banner"');
});
it("Mood Log disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => {
it("disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
},
{
integration: "moodlog",
state: "disconnected",
lastSuccessAt: "2026-05-08T10:00:00.000Z",
lastAttemptAt: "2026-05-08T10:00:00.000Z",
@@ -240,7 +233,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
const html = render();
expect(html).toContain("Not connected");
expect(html).not.toContain('data-testid="integration-status-banner"');
// Mood Log card no longer has a trailing "letzter Sync" line.
// No trailing "letzter Sync" line outside the pill.
expect(html).not.toContain("Last sync:");
});
@@ -285,7 +278,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).toContain("Withings activity error: 293");
});
it("Mood Log card carries the visual divider Withings has (consistency)", () => {
it("every integration card carries the visual divider (consistency)", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
@@ -300,25 +293,13 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
},
{
integration: "moodlog",
state: "connected",
lastSuccessAt: "2026-05-09T17:00:00.000Z",
lastAttemptAt: "2026-05-09T17:00:00.000Z",
lastError: null,
configured: true,
enabled: true,
legacyLastSyncedAt: "2026-05-09T17:00:00.000Z",
entryCount: 42,
webhookSecret: "secret123",
},
],
});
const html = render();
// Every integration card includes the section divider data-testid so the
// header → body separation is visually consistent (Withings, moodLog,
// WHOOP, Fitbit / Google Health).
expect(count(html, 'data-testid="integration-card-divider"')).toBe(4);
// header → body separation is visually consistent (Withings, WHOOP,
// Fitbit / Google Health). The moodLog integration was removed.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(3);
});
});
@@ -71,6 +71,20 @@ describe("<NotificationStatusCard />", () => {
expect(html).not.toContain('data-testid="notification-status-list"');
});
it("renders empty state instead of crashing when data is not an array", () => {
// Regression: the status endpoint returns `{ channels, events }` where
// `events` is an object map. A shape-drifted / poisoned cache read that
// hands the whole object to the card must not reach `.map` on a
// non-array and white-screen the notifications panel.
useQueryMock.mockReturnValue({
data: { channels: [], events: {} } as unknown as MockChannel[],
isLoading: false,
});
const html = render(<NotificationStatusCard />);
expect(html).toContain("No channels configured yet");
expect(html).not.toContain('data-testid="notification-status-list"');
});
it("paints Active badge for a healthy channel", () => {
setChannels([
{
@@ -48,8 +48,6 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// IW-D merged it into `thresholds`.
// v1.8.7.1 — `sources` (Sources) is its own slug again, sitting
// between `thresholds` (Targets) and `ai`.
// v1.11.0 — `sharing` (clinician share links) added between `api` and
// `export`. Section count: 12.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
@@ -59,7 +57,6 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"sources",
"ai",
"api",
"sharing",
"export",
"advanced",
"about",
@@ -1,125 +0,0 @@
/**
* v1.11.0 — Settings → Sharing (Epic C, C7).
*
* The owner share-link surface. The security-load-bearing guarantees the
* test pins:
* 1. The list never carries a raw `hls_` token — the server stores only the
* hash, so the list response shape has no token field and the rendered
* markup must not surface one.
* 2. The one-time token reveal card (`share-token-reveal`) is absent until a
* create succeeds — on first paint (no token in state) it must not render.
* 3. Active links render with a revoke control; revoked/expired links fold
* into the inactive list.
* 4. The expiry input is bounded at the server cap (90 days).
*
* SSR-static render only (no jsdom), matching the sibling settings-section
* tests. The TanStack hooks are mocked so the query returns a fixed link list.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER" },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
const FIXTURE_LINKS = [
{
id: "link-active",
label: "Cardiology referral",
rangeStart: "2026-01-01T00:00:00.000Z",
rangeEnd: null,
resourceTypes: ["Patient", "Observation"],
allowFhirApi: true,
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: "2026-06-01T00:00:00.000Z",
revokedAt: null,
lastAccessAt: "2026-06-02T10:00:00.000Z",
accessCount: 3,
active: true,
},
{
id: "link-revoked",
label: "Old GP link",
rangeStart: "2026-01-01T00:00:00.000Z",
rangeEnd: null,
resourceTypes: [],
allowFhirApi: false,
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: "2026-05-01T00:00:00.000Z",
revokedAt: "2026-05-15T00:00:00.000Z",
lastAccessAt: null,
accessCount: 0,
active: false,
},
];
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: FIXTURE_LINKS, isLoading: false }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SharingSection } from "../sharing-section";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<SharingSection />
</I18nProvider>,
);
}
describe("<SharingSection> — owner share-link surface (C7)", () => {
it("renders the create form with a 90-day-capped expiry input", () => {
const html = render();
expect(html).toContain("New share link");
// The expiry field carries the server's max-days cap.
expect(html).toContain('max="90"');
expect(html).toContain("Create link");
});
it("does not reveal a token before a create succeeds", () => {
const html = render();
// The one-time reveal card only mounts once `newToken` state is set.
expect(html).not.toContain('data-testid="share-token-reveal"');
});
it("lists the active link with a revoke control and never a raw token", () => {
const html = render();
expect(html).toContain('data-testid="share-active-list"');
expect(html).toContain("Cardiology referral");
expect(html).toContain("Revoke");
// The list response shape carries no token; the markup must not surface
// an `hls_` string anywhere.
expect(html).not.toContain("hls_");
// The FHIR badge surfaces for the FHIR-enabled link.
expect(html).toContain("FHIR");
});
it("folds revoked links into the inactive list (not the active list)", () => {
const html = render();
// The active list must NOT contain the revoked link's label.
const activeListStart = html.indexOf('data-testid="share-active-list"');
const activeListEnd = html.indexOf('data-testid="share-inactive-list"');
// Inactive list is collapsed by default, so the label only appears once
// the disclosure is open — but the active list must never carry it.
const activeSlice =
activeListEnd > activeListStart
? html.slice(activeListStart, activeListEnd)
: html.slice(activeListStart);
expect(activeSlice).not.toContain("Old GP link");
// The inactive-count disclosure surfaces.
expect(html).toContain("Revoked and expired");
});
it("renders localized section copy", () => {
const html = render("de");
expect(html).toContain("Neuer Freigabe-Link");
});
});
@@ -1,178 +0,0 @@
"use client";
/**
* Settings → Export Arztbericht (doctor-report) card.
*
* The doctor-report PDF is the artefact a user carries into a doctor's
* appointment. v1.4.37 W7a promoted it to the page hero; v1.12 demotes
* it back to a small secondary card at the bottom of the export page —
* the health-record export now owns the page hero. The card is fully
* functional: the CTA routes through the existing `<DoctorReportDialog>`
* + `/api/doctor-report` flow exactly as before. Only the framing and
* visual weight changed.
*
* Layout (compact card, no hero gradient):
*
* ┌─ card ────────────────────────────────────────────┐
* │ [icon] H2 title eyebrow │
* │ one-line value statement │
* │ [Configure & generate] PDF · printable │
* └───────────────────────────────────────────────────┘
*
* a11y:
* - The CTA button clears the 44 px touch floor (`min-h-11`).
* - `focus-visible:ring` ships via the default `<Button>` variant.
* - The CTA is `aria-describedby={valueStatementId}` so a screen
* reader announces the value statement alongside the action.
* - The card's `<h2>` slots beneath the page-level `<h1>`.
*/
import { useEffect, useId, useState } from "react";
import { Loader2, Stethoscope } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DoctorReportDialog,
type DoctorReportSubmitPayload,
} from "@/components/doctor-report/doctor-report-dialog";
import { useTranslations } from "@/lib/i18n/context";
export function ArztberichtHeroCard() {
const { t, locale } = useTranslations();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [defaultPracticeName, setDefaultPracticeName] = useState<string | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const valueStatementId = useId();
// Pre-fill the practice name from the user profile so the dialog opens
// ready-to-submit. Best-effort: if the request fails, the dialog still
// works without a pre-fill — mirrors the legacy `<DoctorReportCard>`.
useEffect(() => {
let cancelled = false;
fetch("/api/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (cancelled || !json) return;
const name = json?.data?.lastReportPracticeName;
if (typeof name === "string") setDefaultPracticeName(name);
})
.catch(() => {
// ignore — pre-fill is a UX nicety
});
return () => {
cancelled = true;
};
}, []);
async function handleSubmit(payload: DoctorReportSubmitPayload) {
setBusy(true);
setError(null);
try {
const res = await fetch("/api/doctor-report", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
startDate: payload.startDate,
endDate: payload.endDate,
practiceName: payload.practiceName,
sections: payload.sections,
}),
});
if (!res.ok) {
setError(`${res.status}`);
return;
}
const json = await res.json();
const { generateDoctorReportPDF } =
await import("@/lib/doctor-report-pdf");
const doc = generateDoctorReportPDF(json.data, { t, locale });
const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report";
doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`);
if (payload.practiceName) setDefaultPracticeName(payload.practiceName);
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<section
data-testid="export-hero-doctor-report"
aria-labelledby="export-hero-doctor-report-title"
className="bg-card border-border rounded-xl border p-5 sm:p-6"
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Stethoscope
className="text-primary h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h2
id="export-hero-doctor-report-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.cards.doctorReport.title")}
</h2>
</div>
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("settings.sections.export.hero.eyebrow")}
</span>
</div>
<p
id={valueStatementId}
data-testid="export-hero-doctor-report-value"
className="text-muted-foreground max-w-2xl text-xs leading-relaxed"
>
{t("settings.sections.export.hero.valueStatement")}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
data-testid="export-hero-doctor-report-action"
variant="outline"
size="sm"
// `min-h-11` (44 px) is the WCAG 2.5.5 touch-target floor;
// we keep it on mobile and fall back to the compact
// `sm:min-h-9` on pointer devices so a future className
// override can't re-lift the mobile minimum below the
// touch-target contract.
className="min-h-11 sm:min-h-9"
onClick={() => setOpen(true)}
disabled={busy}
aria-describedby={valueStatementId}
>
{busy && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sections.export.hero.cta")}
</Button>
<span className="text-muted-foreground text-xs">
{t("settings.sections.export.hero.formatHint")}
</span>
</div>
{error && (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
)}
</div>
<DoctorReportDialog
open={open}
onOpenChange={setOpen}
defaultPracticeName={defaultPracticeName}
onSubmit={handleSubmit}
/>
</section>
);
}
+127 -11
View File
@@ -16,19 +16,19 @@
* 3. Medications CSV — optional intake-history toggle
* 4. Mood CSV — optional `since`/`until`
* 5. Full JSON Backup — single-file user-scoped dump
* 6. Doctor Report (PDF) — small secondary card at the bottom
* (`<ArztberichtHeroCard>`)
*
* v1.12 — the health-record export is the headline "data out" path; it
* takes the page hero. The doctor-report PDF is demoted to a small
* secondary card at the bottom of the page — fully functional, just no
* longer the flagship surface it was in v1.4.37 W7a.
* takes the page hero. The doctor-report PDF lives under the
* health-record export now and is no longer offered as a separate card
* here (it used to be a small secondary card at the bottom).
*
* Mobile-first: cards stack on `<md`, two-column grid on `>=md`.
*/
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CalendarHeart,
Download,
FileJson,
FileSpreadsheet,
@@ -42,11 +42,12 @@ import { Button } from "@/components/ui/button";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { ArztberichtHeroCard } from "@/components/settings/arztbericht-hero-card";
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
import { ImportPanel } from "@/components/settings/import-panel";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
type ExportFormat = "CSV" | "JSON";
type ExportFormat = "CSV" | "JSON" | "FHIR";
/**
* Trigger a browser download for a given URL by spinning up an anchor
@@ -112,13 +113,18 @@ export function ExportSection() {
<MedicationsCsvCard />
<MoodCsvCard />
<FullBackupCard />
{/* R30 — cycle export, gated on cycle tracking being enabled.
The card mounts only for accounts with cycle data; it reuses
the health-record FHIR export with the reproductive section
opted in, so there is no separate backend path. */}
<CycleExportCard />
</div>
</section>
{/* v1.12 — doctor-report PDF, demoted from the former page hero to a
small secondary card at the bottom. Fully functional — the same
`<DoctorReportDialog>` + `/api/doctor-report` flow runs from here. */}
<ArztberichtHeroCard />
{/* R28 / issue #281 — the import surface for the Apple Health
`export.zip` and the generic JSON paths. The export routes had
backends with no UI until now. */}
<ImportPanel />
</section>
);
}
@@ -475,3 +481,113 @@ function FullBackupCard() {
</ExportCardShell>
);
}
// ─────────────────────────── Cycle export (gated) ───────────────────────────
/**
* R30 — cycle / reproductive-health export, GATED on cycle tracking
* being enabled for the account. A standalone cycle export was removed
* from the cycle settings in v1.15.4 (it lives in the full backup); this
* surfaces an explicit, opt-in cycle export here for accounts that track
* a cycle. It reuses the flagship `/api/export/health-record` route with
* the reproductive section opted in and every other section off — no
* separate backend path. The card does not render at all when cycle
* tracking is off, so a non-cycle account never sees it.
*/
function CycleExportCard() {
const { t } = useTranslations();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The ungated prefs read tells us whether cycle tracking is enabled
// without tripping the cycle gate's 403.
const prefsQuery = useQuery({
queryKey: queryKeys.cyclePrefs(),
queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => {
const res = await fetch("/api/auth/me/cycle-prefs", {
credentials: "include",
});
if (!res.ok) throw new Error(`prefs-${res.status}`);
return (await res.json()).data as { cycleTrackingEnabled: boolean };
},
staleTime: 5 * 60 * 1000,
});
// Gate: render nothing until we know cycle is on. A failed/absent read
// keeps the card hidden (fail-closed) so it never appears spuriously.
if (prefsQuery.data?.cycleTrackingEnabled !== true) return null;
async function handleGenerate() {
setBusy(true);
setError(null);
try {
const res = await fetch("/api/export/health-record", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
format: "fhir",
sections: {
vitals: {},
cardioFitness: {},
activity: {},
glucose: false,
medications: {},
mood: false,
bmi: false,
cycle: true,
},
}),
});
if (!res.ok) {
setError(t("settings.sections.export.downloadFailed"));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-cycle-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
}
return (
<ExportCardShell
testId="export-card-cycle"
icon={CalendarHeart}
title={t("settings.sections.export.cards.cycle.title")}
description={t("settings.sections.export.cards.cycle.description")}
format="FHIR"
footer={
<Button
data-testid="export-action-cycle"
variant="outline"
size="sm"
onClick={handleGenerate}
disabled={busy}
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.sections.export.actions.download")}
</Button>
}
>
{error && (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
)}
</ExportCardShell>
);
}
@@ -196,66 +196,70 @@ export function HealthRecordExportPanel() {
// `isolate` traps the purple glow inside the hero so the shadow
// doesn't leak through the cards below — same trick the Insights
// `<HeroStrip>` uses.
"relative isolate overflow-hidden rounded-xl p-5 sm:p-6",
"relative isolate overflow-hidden rounded-xl p-4 sm:p-5",
)}
>
<div className="mb-2 flex items-center gap-2">
<div className="mb-1 flex items-center gap-2">
<FileText
className="text-primary h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h2
id="health-record-export-title"
className="text-xl font-semibold tracking-tight sm:text-2xl"
className="text-lg font-semibold tracking-tight sm:text-xl"
>
{t("settings.healthRecord.title")}
</h2>
</div>
<p className="text-muted-foreground mb-5 max-w-2xl text-sm leading-relaxed">
<p className="text-muted-foreground mb-4 max-w-2xl text-sm leading-snug">
{t("settings.healthRecord.description")}
</p>
<div className="space-y-5">
{/* Format toggle */}
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.healthRecord.format")}
</legend>
<div className="flex flex-wrap gap-2" role="radiogroup">
{EXPORT_FORMATS.map((f, index) => (
<Button
key={f}
type="button"
role="radio"
aria-checked={format === f}
variant={format === f ? "default" : "outline"}
className="min-h-11 sm:min-h-9"
onClick={() => setFormat(f)}
{...getFormatRadioProps(index)}
>
{t(`settings.healthRecord.format_${f}`)}
</Button>
))}
</div>
</fieldset>
<div className="space-y-4">
{/* Format + range share a row on desktop so the panel opens
denser; on mobile they stack. */}
<div className="grid gap-4 sm:grid-cols-2">
<fieldset className="space-y-1.5">
<legend className="mb-1 text-sm font-medium">
{t("settings.healthRecord.format")}
</legend>
<div className="flex flex-wrap gap-2" role="radiogroup">
{EXPORT_FORMATS.map((f, index) => (
<Button
key={f}
type="button"
role="radio"
aria-checked={format === f}
variant={format === f ? "default" : "outline"}
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setFormat(f)}
{...getFormatRadioProps(index)}
>
{t(`settings.healthRecord.format_${f}`)}
</Button>
))}
</div>
</fieldset>
{/* Date range */}
<div className="space-y-2">
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
<NativeSelect
id="hr-range"
value={String(days)}
onChange={(e) => setDays(Number(e.target.value))}
>
<option value="90">{t("settings.healthRecord.range90")}</option>
<option value="180">{t("settings.healthRecord.range180")}</option>
<option value="365">{t("settings.healthRecord.range365")}</option>
</NativeSelect>
{/* Date range */}
<div className="space-y-1.5">
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
<NativeSelect
id="hr-range"
value={String(days)}
onChange={(e) => setDays(Number(e.target.value))}
>
<option value="90">{t("settings.healthRecord.range90")}</option>
<option value="180">{t("settings.healthRecord.range180")}</option>
<option value="365">{t("settings.healthRecord.range365")}</option>
</NativeSelect>
</div>
</div>
{/* Practice name — PDF only */}
{isPdfLike && (
<div className="space-y-2">
<div className="space-y-1.5">
<Label htmlFor="hr-practice">
{t("settings.healthRecord.practiceName")}
</Label>
@@ -427,18 +431,22 @@ export function HealthRecordExportPanel() {
)}
</fieldset>
{isFhirLike && (
<p className="text-muted-foreground text-xs">
{t("settings.healthRecord.fhirNote")}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
{/* The FHIR note and the generate action share the footer row so
the panel ends compact instead of stacking a note above a
right-aligned button. */}
<div className="flex flex-wrap items-center justify-between gap-3">
{isFhirLike ? (
<p className="text-muted-foreground max-w-md text-xs">
{t("settings.healthRecord.fhirNote")}
</p>
) : (
<span />
)}
<Button
type="button"
onClick={handleGenerate}
disabled={busy}
className="min-h-11 sm:min-h-9"
className="ml-auto min-h-11 sm:min-h-9"
>
{busy ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
+622
View File
@@ -0,0 +1,622 @@
"use client";
/**
* v1.15.7 — Settings → Export & Import → Import area.
*
* Web UI for the two existing import backends (issue #281: the routes
* shipped without a surface). Two clearly-separated controls:
*
* 1. Apple Health `export.zip` — multipart POST to
* `/api/import/apple-health-export` (field `file`), then poll
* `GET /api/import/apple-health-export/[jobId]/status` on a
* `refetchInterval` while the job runs, stopping on a terminal
* state (`done` / `failed`). The upload streams server-side so a
* multi-GB archive never buffers in the browser beyond the POST.
* Idempotent on the file's SHA-256 — re-uploading the same archive
* merges rather than duplicating.
*
* 2. Generic JSON import — upload a `.json` file OR paste JSON,
* validated client-side for parseability, POSTed to `/api/import`.
* A "Download example" button mints a small valid example as a Blob
* (no static asset). Returns `{ measurements, moodEntries, skipped }`
* counts.
*
* Both controls surface rate-limit (429) and error states cleanly and
* announce progress to assistive tech via `aria-live` regions.
*/
import { useCallback, useId, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Download,
FileJson,
Loader2,
Upload,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
// ─────────────────────────── Section wrapper ───────────────────────────
export function ImportPanel() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-import-title"
className="space-y-3"
>
<div className="space-y-1">
<h2
id="settings-section-import-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.import.heading")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.description")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<AppleHealthImportCard />
<JsonImportCard />
</div>
</section>
);
}
// ─────────────────────────── Card shell ───────────────────────────
interface ImportCardShellProps {
testId: string;
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
children: React.ReactNode;
}
function ImportCardShell({
testId,
icon: Icon,
title,
description,
children,
}: ImportCardShellProps) {
return (
<div
data-testid={testId}
className="bg-card border-border flex h-full flex-col rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
<div className="mt-3 flex flex-1 flex-col gap-3">{children}</div>
</div>
);
}
// ─────────────────────────── Apple Health import ───────────────────────────
/** Terminal states the status poll stops on. */
const TERMINAL_STATES: readonly string[] = ["done", "failed"];
interface JobStatus {
jobId: string;
status: string;
progress: {
currentPhase?: string;
recordsRead?: number;
rowsUpserted?: number;
percent?: number | null;
} | null;
result: {
totals?: { recordsRead?: number; rowsUpserted?: number };
clinical?: { skipped?: number };
} | null;
failureReason: string | null;
}
function AppleHealthImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [dragActive, setDragActive] = useState(false);
const dropDescId = useId();
const statusQuery = useQuery({
queryKey: queryKeys.importJobStatus(jobId ?? "none"),
enabled: jobId !== null,
refetchInterval: (query) => {
const data = query.state.data as JobStatus | undefined;
if (data && TERMINAL_STATES.includes(data.status)) return false;
return 2000;
},
queryFn: async (): Promise<JobStatus> => {
const res = await fetch(
`/api/import/apple-health-export/${jobId}/status`,
{ credentials: "include" },
);
if (!res.ok) {
throw new Error(`status-${res.status}`);
}
return (await res.json()).data as JobStatus;
},
});
const upload = useCallback(
async (file: File) => {
setUploadError(null);
setUploading(true);
setJobId(null);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/import/apple-health-export", {
method: "POST",
credentials: "include",
body: form,
});
if (res.status === 429) {
setUploadError(t("settings.sections.export.import.appleHealth.rateLimited"));
return;
}
if (res.status === 413) {
setUploadError(t("settings.sections.export.import.appleHealth.tooLarge"));
return;
}
if (!res.ok) {
setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed"));
return;
}
const body = (await res.json()).data as {
jobId: string;
status: string;
};
if (!body?.jobId) {
setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed"));
return;
}
setJobId(body.jobId);
} catch {
setUploadError(t("settings.sections.export.import.appleHealth.uploadFailed"));
} finally {
setUploading(false);
}
},
[t],
);
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) void upload(file);
// Reset so picking the same file twice re-fires the change event.
e.target.value = "";
}
function onDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) void upload(file);
}
const status = statusQuery.data ?? null;
const isRunning =
status !== null && !TERMINAL_STATES.includes(status.status);
const isDone = status?.status === "done";
const isFailed = status?.status === "failed";
const busy = uploading || isRunning;
const percent = status?.progress?.percent ?? null;
return (
<ImportCardShell
testId="import-card-apple-health"
icon={Upload}
title={t("settings.sections.export.import.appleHealth.title")}
description={t("settings.sections.export.import.appleHealth.description")}
>
{/* Keyboard-operable drop area. The hidden file input does the
actual file selection; the div forwards Enter/Space to it. */}
<div
role="button"
tabIndex={0}
aria-describedby={dropDescId}
aria-disabled={busy}
onClick={() => !busy && fileInputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !busy) {
e.preventDefault();
fileInputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
if (!busy) setDragActive(true);
}}
onDragLeave={() => setDragActive(false)}
onDrop={(e) => !busy && onDrop(e)}
className={cn(
"border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none",
dragActive && "border-primary bg-primary/5",
busy && "pointer-events-none opacity-60",
)}
>
<Upload className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<span className="text-foreground text-sm font-medium">
{t("settings.sections.export.import.appleHealth.dropLabel")}
</span>
<span id={dropDescId} className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.dropHint")}
</span>
</div>
<input
ref={fileInputRef}
type="file"
accept=".zip,application/zip"
className="sr-only"
aria-label={t("settings.sections.export.import.appleHealth.fileInputLabel")}
onChange={onFileChange}
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.idempotencyNote")}
</p>
{/* Live progress / outcome — announced to assistive tech. */}
<div aria-live="polite" className="space-y-2">
{uploading && (
<p className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{t("settings.sections.export.import.appleHealth.uploading")}
</p>
)}
{isRunning && (
<div
data-testid="import-apple-health-progress"
className="space-y-1.5"
>
<p className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{t(
`settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`,
)}
</p>
{typeof percent === "number" && (
<Progress value={percent} />
)}
{typeof status?.progress?.rowsUpserted === "number" && (
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.rowsImported", {
count: status.progress.rowsUpserted,
})}
</p>
)}
</div>
)}
{isDone && (
<div
data-testid="import-apple-health-result"
className="text-foreground flex items-start gap-2 text-xs"
>
<CheckCircle2
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-500"
aria-hidden="true"
/>
<span>
{t("settings.sections.export.import.appleHealth.doneSummary", {
imported: status?.result?.totals?.rowsUpserted ?? 0,
read: status?.result?.totals?.recordsRead ?? 0,
skipped: status?.result?.clinical?.skipped ?? 0,
})}
</span>
</div>
)}
{(isFailed || uploadError) && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-xs"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{uploadError ??
status?.failureReason ??
t("settings.sections.export.import.appleHealth.failed")}
</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
data-testid="import-action-apple-health"
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.appleHealth.choose")}
</Button>
</div>
</ImportCardShell>
);
}
// ─────────────────────────── Generic JSON import ───────────────────────────
/**
* Small valid example payload, minted as a downloadable Blob by the
* "Download example" button and used by the docs. Exported so a test can
* assert it stays a valid import body — the button and the route schema
* must never drift.
*/
export const EXAMPLE_IMPORT = {
measurements: [
{
type: "WEIGHT",
value: 80.5,
unit: "kg",
measuredAt: "2026-05-01T08:00:00.000Z",
source: "manual",
notes: "morning",
},
{
type: "BLOOD_PRESSURE_SYS",
value: 120,
unit: "mmHg",
measuredAt: "2026-05-01T08:05:00.000Z",
},
{
type: "BLOOD_PRESSURE_DIA",
value: 80,
unit: "mmHg",
measuredAt: "2026-05-01T08:05:00.000Z",
},
],
moodEntries: [
{
date: "2026-05-01",
mood: "GUT",
score: 4,
tags: "work,exercise",
},
],
};
interface JsonImportResult {
measurements: number;
moodEntries: number;
skipped: number;
}
/**
* Client-side parse guard for the JSON-import textarea. Returns the
* parsed value when the text is valid JSON, otherwise a failure marker —
* we never POST an unparseable body. Exported for unit testing so the
* guard is covered without a browser.
*/
export function parseImportJson(
text: string,
): { ok: true; value: unknown } | { ok: false } {
try {
return { ok: true, value: JSON.parse(text) };
} catch {
return { ok: false };
}
}
function JsonImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<JsonImportResult | null>(null);
const textareaId = useId();
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setResult(null);
try {
const content = await file.text();
setText(content);
} catch {
setError(t("settings.sections.export.import.json.readFailed"));
}
}
function downloadExample() {
const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "healthlog-import-example.json";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function handleImport() {
setError(null);
setResult(null);
const parsed = parseImportJson(text);
if (!parsed.ok) {
setError(t("settings.sections.export.import.json.invalidJson"));
return;
}
const payload = parsed.value;
setBusy(true);
try {
const res = await fetch("/api/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
if (res.status === 429) {
setError(t("settings.sections.export.import.json.rateLimited"));
return;
}
if (res.status === 422) {
const body = await res.json().catch(() => null);
setError(
body?.error ?? t("settings.sections.export.import.json.invalidPayload"),
);
return;
}
if (!res.ok) {
setError(t("settings.sections.export.import.json.failed"));
return;
}
const data = (await res.json()).data as JsonImportResult;
setResult({
measurements: data?.measurements ?? 0,
moodEntries: data?.moodEntries ?? 0,
skipped: data?.skipped ?? 0,
});
} catch {
setError(t("settings.sections.export.import.json.failed"));
} finally {
setBusy(false);
}
}
return (
<ImportCardShell
testId="import-card-json"
icon={FileJson}
title={t("settings.sections.export.import.json.title")}
description={t("settings.sections.export.import.json.description")}
>
<div className="space-y-1.5">
<Label htmlFor={textareaId} className="text-xs">
{t("settings.sections.export.import.json.pasteLabel")}
</Label>
<Textarea
id={textareaId}
data-testid="import-json-textarea"
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
spellCheck={false}
placeholder='{"measurements":[…],"moodEntries":[…]}'
className="font-mono text-xs"
/>
</div>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
className="sr-only"
aria-label={t("settings.sections.export.import.json.fileInputLabel")}
onChange={onFileChange}
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.json.schemaHint")}{" "}
<a
href="/docs/integrations/data-import"
className="text-primary underline underline-offset-2"
>
{t("settings.sections.export.import.json.docsLink")}
</a>
</p>
<div aria-live="polite" className="space-y-2">
{result && (
<p
data-testid="import-json-result"
className="text-foreground flex items-start gap-2 text-xs"
>
<CheckCircle2
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-500"
aria-hidden="true"
/>
<span>
{t("settings.sections.export.import.json.resultSummary", {
measurements: result.measurements,
moods: result.moodEntries,
skipped: result.skipped,
})}
</span>
</p>
)}
{error && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-xs"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>{error}</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
data-testid="import-json-choose-file"
>
<Upload className="mr-1 h-3.5 w-3.5" />
{t("settings.sections.export.import.json.uploadFile")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={downloadExample}
data-testid="import-json-download-example"
>
<Download className="mr-1 h-3.5 w-3.5" />
{t("settings.sections.export.import.json.downloadExample")}
</Button>
<Button
type="button"
size="sm"
disabled={busy || text.trim().length === 0}
onClick={handleImport}
data-testid="import-action-json"
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileJson className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.json.import")}
</Button>
</div>
</ImportCardShell>
);
}
@@ -10,7 +10,6 @@ import {
Loader2,
RefreshCw,
Save,
Smile,
Unlink,
} from "lucide-react";
@@ -42,14 +41,6 @@ import {
queryKeys,
} from "@/lib/query-keys";
interface GlobalServiceAvailability {
telegramGlobal: boolean;
ntfyGlobal: boolean;
webPushGlobal: boolean;
apiGlobal: boolean;
moodLogGlobal: boolean;
}
// v1.4.15 Phase B2: shared status payload for both integration cards.
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
// IntegrationStatusPill now owns state + last-sync presentation, and
@@ -183,23 +174,11 @@ export function IntegrationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data: globalServices } = useQuery({
queryKey: queryKeys.settingsGlobalServices(),
queryFn: async () => {
const res = await fetch("/api/settings/global-services");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as GlobalServiceAvailability;
},
enabled: isAuthenticated,
});
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
const moodLogEnabled = globalServices?.moodLogGlobal ?? true;
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
const moodLogViewModel = pickStatus(integrationStatus, "moodlog");
return (
<section
@@ -221,7 +200,6 @@ export function IntegrationsSection() {
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
{moodLogEnabled && <MoodLogCard viewModel={moodLogViewModel} />}
</section>
);
}
@@ -1545,354 +1523,3 @@ function FitbitCard({
</div>
);
}
function MoodLogCard({
viewModel,
}: {
viewModel: IntegrationStatusViewModel | undefined;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// v1.12.1 — read off the consolidated /api/integrations/status
// envelope; the per-card /api/integrations/moodlog/status round-trip
// is gone. Mutations invalidate the envelope so the card's webhook
// secret / entry-count / last-sync repaint on the next refetch.
const status = viewModel;
const refreshStatus = () =>
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
const res = await fetch("/api/settings/moodlog", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url.trim(), apiKey: apiKey.trim() }),
});
if (res.ok) {
setMsg(t("settings.saved"));
setMsgType("success");
setUrl("");
setApiKey("");
void refreshStatus();
} else {
const json = await res.json();
setMsg(json.error || t("settings.savingError"));
setMsgType("error");
}
setSaving(false);
}
async function handleSync(fullSync = false) {
setSyncing(true);
setMsg(null);
try {
const res = await fetch("/api/integrations/moodlog/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullSync }),
});
if (res.ok) {
const json = await res.json();
setMsg(
t("settings.moodLogSyncResult", { count: json.data.imported }),
);
setMsgType("success");
void refreshStatus();
} else {
setMsg(t("settings.moodLogSyncFailed"));
setMsgType("error");
}
} catch {
setMsg(t("settings.moodLogSyncFailed"));
setMsgType("error");
}
setSyncing(false);
}
async function handleDisconnect() {
const res = await fetch("/api/settings/moodlog", { method: "DELETE" });
if (res.ok) {
setMsg(t("settings.moodLogDisconnected"));
setMsgType("success");
void refreshStatus();
}
}
// Mirror Withings logic: the pill state comes from the cross-
// integration envelope, which also carries `legacyLastSyncedAt`
// (the user's moodLog last-sync timestamp) so a manual sync paints
// a fresh "X min ago" once the envelope refetches.
const pillState: IntegrationPillState = status?.configured
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
const errorMessage =
pillState === "error" && viewModel?.lastError ? viewModel.lastError : null;
return (
<div className="bg-card border-border rounded-xl border p-6">
<SettingsCardHeader
icon={Smile}
title={
<a
href="https://moodlog.onback.io"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{t("settings.moodLogTitle")}
</a>
}
description={
<>
<p>{t("settings.moodLogDescription")}</p>
<p className="text-muted-foreground/80">
{t("settings.moodLogDeprecated")}
</p>
</>
}
status={
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
}
/>
{/* v1.4.19 A5 — visual divider matches Withings for consistency
(the maintainer explicitly called the asymmetry out). */}
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
<form onSubmit={handleSave} className="space-y-3">
<div>
<Label htmlFor="moodlog-url">{t("settings.moodLogUrl")}</Label>
<Input
id="moodlog-url"
type="url"
placeholder={t("settings.moodLogUrlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div>
<Label htmlFor="moodlog-api-key">
{t("settings.moodLogApiKey")}
</Label>
<PasswordInput
id="moodlog-api-key"
placeholder={
status?.configured
? t("settings.withingsCredentialsSavedPlaceholder")
: t("settings.moodLogApiKeyPlaceholder")
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
{/* v1.4.33 — save-button placement contract:
right-aligned primary `Speichern` with the secondary
`Verbindung testen` immediately to its left. Mood Log
used to left-align its row, which was the only
integration form that did. */}
<div className="flex flex-wrap items-center justify-end gap-2">
<TestConnectionButton
endpoint="/api/integrations/moodlog/test"
disabled={!status?.configured}
/>
<Button
type="submit"
disabled={saving || (!url.trim() && !apiKey.trim())}
size="sm"
className="min-h-11"
>
{saving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
)}
{t("common.save")}
</Button>
</div>
</form>
{status?.configured && (
<div className="space-y-3 border-t pt-3">
{status.webhookSecret && (
<div>
<Label htmlFor="moodlog-webhook-secret">
{t("settings.moodLogWebhookSecret")}
</Label>
{/* v1.4.33 — the webhook secret is a long hex string;
on a 393 CSS px viewport the input + Copy button
used to push past the card edge and trigger a body
horizontal scroll. Stack vertically on `<sm` and
break the input value so it wraps inside its own
box. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
id="moodlog-webhook-secret"
value={status.webhookSecret}
readOnly
className="font-mono text-xs break-all"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(status.webhookSecret!);
setMsg(t("common.copied"));
setMsgType("success");
}}
className="min-h-11 w-full sm:w-auto"
>
{/* v1.4.22 D / D-DSGN-M-04 — use the proper
common.copy key instead of stripping the
exclamation off common.copied. */}
{t("common.copy")}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.moodLogWebhookSecretHelp")}
</p>
</div>
)}
{/* v1.4.19 A5 — The "letzter Sync" line that used to
live here is gone. The pill in the card header
already carries that information; repeating it
inside the body is the redundancy the maintainer flagged. The
entry-count is the only number we still surface
because it's an integration-specific datapoint the
pill cannot convey. */}
<div className="flex flex-wrap gap-4 text-sm">
<span>
{t("settings.moodLogEntries")}: {status.entryCount}
</span>
</div>
{/* v1.4.33 — the Sync / Voll-Sync / Trennen triplet
overflowed the card edge on a 393 CSS px viewport
(~376 px of buttons + gaps inside 345 px of inner
width). Match the Withings card pattern: `flex-wrap`
+ `min-w-[10rem]` on `<sm` so the triggers stack two
per row and fill their column cleanly; tablet+ keeps
the inline row. */}
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
onClick={() => handleSync(false)}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.moodLogSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11"
disabled={syncing}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("settings.moodLogFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.moodLogFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.moodLogFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.moodLogFullSyncConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
{/* v1.4.3: aligned with the Withings disconnect button —
outline + text-destructive instead of solid destructive
so the trigger reads as a reversible action; the actual
"yes, disconnect" confirmation inside the dialog keeps
its solid-red treatment. */}
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
{t("settings.moodLogDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.moodLogDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.moodLogDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDisconnect}>
{t("settings.moodLogDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "error" ? "text-destructive" : "text-success"}`}
>
{msg}
</p>
)}
</div>
</div>
);
}
@@ -84,7 +84,7 @@ export function MoodReminderCard({
});
const enabled = optimistic ?? profile?.moodReminderEnabled ?? false;
const reminderHour = optimisticHour ?? prefs?.mood.reminderHour ?? 22;
const reminderHour = optimisticHour ?? prefs?.mood?.reminderHour ?? 22;
async function handleToggle(next: boolean) {
setOptimistic(next);
@@ -119,7 +119,12 @@ export function NotificationStatusCard() {
);
}
const channels = data ?? [];
// Defensive: the status endpoint returns `{ channels, events }` where
// `events` is an object map, not an array. A stale or shape-drifted
// payload (or a cache read that kept the whole object instead of the
// `.channels` slice) must never reach `.map` on a non-array — guard the
// type here rather than white-screening the whole notifications panel.
const channels = Array.isArray(data) ? data : [];
if (channels.length === 0) {
return (
<div
@@ -20,7 +20,6 @@ import {
LayoutDashboard,
Link2,
Settings2,
Share2,
SlidersHorizontal,
Sparkles,
User,
@@ -40,7 +39,6 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
sources: Layers,
ai: Sparkles,
api: KeyRound,
sharing: Share2,
export: Download,
advanced: Settings2,
about: Info,
-4
View File
@@ -22,10 +22,6 @@ export const SETTINGS_SECTION_SLUGS = [
"sources",
"ai",
"api",
// v1.11.0 — `sharing` owns clinician share links: a time-boxed, scope-
// frozen read-only view of the owner's record, optionally a scoped FHIR
// face. Sits next to `api` (the other "hand a credential out" surface).
"sharing",
"export",
"advanced",
"about",
@@ -25,7 +25,6 @@ import {
LayoutDashboard,
Link2,
Settings2,
Share2,
SlidersHorizontal,
Sparkles,
User,
@@ -110,11 +109,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
},
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
{
slug: "sharing",
titleKey: "settings.sections.sharing.title",
icon: Share2,
},
{
slug: "export",
titleKey: "settings.sections.export.title",
-505
View File
@@ -1,505 +0,0 @@
"use client";
/**
* v1.11.0 — Settings → Sharing (Epic C, C7).
*
* The OWNER surface for clinician share links. A share link is a time-boxed,
* scope-frozen, read-only view of the owner's own health record at
* `/c/<token>`, optionally exposing a scoped read-only FHIR face. The raw
* `hls_` token is shown EXACTLY ONCE on create — the list never carries it
* (the server only stores its hash), so the copy-on-create card is the single
* chance to capture it.
*
* Client island: the create form, the active/revoked lists, and the revoke
* action all need state and mutate via the C4 lifecycle API
* (`/api/share-links`). Reads unwrap `(await res.json()).data`; the query key
* comes from the centralised factory. No markdown anywhere — every value
* renders as escaped React text.
*/
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Copy, Loader2, Share2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAuth } from "@/hooks/use-auth";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/** The FHIR resource types a share link may serve — mirrors C4's enum. */
const RESOURCE_TYPES = [
"Patient",
"Observation",
"MedicationStatement",
"MedicationAdministration",
] as const;
type ResourceType = (typeof RESOURCE_TYPES)[number];
/** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */
const MAX_DAYS = 90;
const DEFAULT_DAYS = 30;
/** Owner-facing shape returned by `GET /api/share-links` (never the token). */
interface ShareLinkSummary {
id: string;
label: string;
rangeStart: string;
rangeEnd: string | null;
resourceTypes: string[];
allowFhirApi: boolean;
expiresAt: string;
createdAt: string;
revokedAt: string | null;
lastAccessAt: string | null;
accessCount: number;
active: boolean;
}
export function SharingSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-sharing-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-sharing-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.sharing.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sharing.description")}
</p>
</header>
<ShareLinksCard />
</section>
);
}
function isoDaysFromNow(days: number): string {
return new Date(Date.now() + days * 86_400_000).toISOString();
}
function ShareLinksCard() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [label, setLabel] = useState("");
const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS);
const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS);
const [allowFhirApi, setAllowFhirApi] = useState(false);
const [resourceTypes, setResourceTypes] = useState<ResourceType[]>([
"Patient",
"Observation",
]);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [showRevoked, setShowRevoked] = useState(false);
const { data: links } = useQuery({
queryKey: queryKeys.shareLinks(),
queryFn: async () => {
const res = await fetch("/api/share-links");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data.shareLinks as ShareLinkSummary[];
},
enabled: isAuthenticated,
});
const createMutation = useMutation({
mutationFn: async () => {
const trimmed = label.trim();
// Surface the same expiry-bound the server enforces before the round
// trip, so the validation feedback is immediate.
if (expiryDays < 1 || expiryDays > MAX_DAYS) {
throw new Error("EXPIRY_RANGE");
}
const res = await fetch("/api/share-links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: trimmed,
rangeStart: isoDaysFromNow(-rangeDays),
rangeEnd: null,
resourceTypes,
allowFhirApi,
expiresAt: isoDaysFromNow(expiryDays),
}),
});
const json = await res.json();
if (!res.ok) {
throw new Error(json.error || "FAILED");
}
return json.data as ShareLinkSummary & { token: string };
},
onSuccess: (created) => {
setNewToken(created.token);
setCopied(false);
setLabel("");
setFormError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
},
onError: (err: Error) => {
setNewToken(null);
setFormError(
err.message === "EXPIRY_RANGE"
? t("settings.sharing.expiryInvalid", { max: MAX_DAYS })
: err.message === "FAILED" || err.message === ""
? t("common.error")
: err.message,
);
},
});
const revokeMutation = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/share-links/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
},
});
function toggleResourceType(type: ResourceType) {
setResourceTypes((prev) =>
prev.includes(type)
? prev.filter((r) => r !== type)
: [...prev, type],
);
}
async function copyToken() {
if (!newToken) return;
try {
const origin = window.location.origin;
await navigator.clipboard.writeText(`${origin}/c/${newToken}`);
setCopied(true);
} catch {
// Clipboard can be unavailable (insecure context); the token stays
// visible in the card so the owner can copy it by hand.
}
}
const activeLinks = useMemo(
() => (links ?? []).filter((l) => l.active),
[links],
);
const inactiveLinks = useMemo(
() => (links ?? []).filter((l) => !l.active),
[links],
);
return (
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
<SettingsCardHeader
icon={Share2}
title={t("settings.sharing.createTitle")}
description={t("settings.sharing.createDescription")}
/>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
createMutation.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="share-label">{t("settings.sharing.label")}</Label>
<Input
id="share-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("settings.sharing.labelPlaceholder")}
maxLength={120}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="share-range">{t("settings.sharing.range")}</Label>
<Input
id="share-range"
type="number"
min={1}
max={3650}
value={rangeDays}
onChange={(e) =>
setRangeDays(Math.max(1, Number(e.target.value) || 1))
}
/>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.rangeHint")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="share-expiry">
{t("settings.sharing.expiry")}
</Label>
<Input
id="share-expiry"
type="number"
min={1}
max={MAX_DAYS}
value={expiryDays}
onChange={(e) =>
setExpiryDays(Math.max(1, Number(e.target.value) || 1))
}
aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS}
/>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.expiryHint", { max: MAX_DAYS })}
</p>
</div>
</div>
<div className="border-border flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5 pr-3">
<Label htmlFor="share-fhir" className="text-sm font-medium">
{t("settings.sharing.fhirApi")}
</Label>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.fhirApiHint")}
</p>
</div>
<Switch
id="share-fhir"
checked={allowFhirApi}
onCheckedChange={setAllowFhirApi}
/>
</div>
{allowFhirApi && (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharing.resourceTypes")}
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{RESOURCE_TYPES.map((type) => (
<label
key={type}
className="flex items-center gap-2 text-sm"
htmlFor={`share-rt-${type}`}
>
<Checkbox
id={`share-rt-${type}`}
checked={resourceTypes.includes(type)}
onCheckedChange={() => toggleResourceType(type)}
/>
<span className="font-mono text-xs">{type}</span>
</label>
))}
</div>
</fieldset>
)}
{formError && (
<p role="alert" className="text-destructive text-sm">
{formError}
</p>
)}
<Button
type="submit"
disabled={createMutation.isPending || !label.trim()}
>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sharing.create")}
</Button>
</form>
{newToken && (
<div
className="bg-success/10 space-y-2 rounded-lg p-3 text-sm"
data-testid="share-token-reveal"
>
<p className="text-success font-medium">
{t("settings.sharing.tokenCreated")}
</p>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.tokenOnce")}
</p>
<div className="flex items-center gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`}
</code>
<Button
type="button"
variant="outline"
size="icon"
onClick={copyToken}
aria-label={t("settings.sharing.copy")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
</div>
)}
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t("settings.sharing.activeTitle")}
</h3>
{activeLinks.length === 0 ? (
<p
className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm"
data-testid="share-active-empty"
>
{t("settings.sharing.noActive")}
</p>
) : (
<ul className="space-y-2" data-testid="share-active-list">
{activeLinks.map((link) => (
<li
key={link.id}
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="bg-success/15 text-success text-[10px]">
{t("settings.sharing.statusActive")}
</Badge>
{link.allowFhirApi && (
<Badge variant="outline" className="text-[10px]">
FHIR
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.sharing.created")}:
</span>{" "}
{formatDate(link.createdAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.sharing.expires")}:
</span>{" "}
{formatDateTime(link.expiresAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
{link.lastAccessAt
? ` · ${formatDateTime(link.lastAccessAt)}`
: ""}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
))}
</ul>
)}
</div>
{inactiveLinks.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevoked((prev) => !prev)}
className="text-foreground hover:text-primary text-sm font-medium transition-colors"
>
{t("settings.sharing.inactiveTitle", {
count: inactiveLinks.length,
})}
</button>
{showRevoked && (
<ul className="space-y-2" data-testid="share-inactive-list">
{inactiveLinks.map((link) => (
<li
key={link.id}
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<Badge variant="secondary" className="text-[10px]">
{link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}
</Badge>
</div>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
</p>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+22 -2
View File
@@ -37,20 +37,40 @@ import { cn } from "@/lib/utils";
*/
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5; shrink
// to 40 px on sm+ where the pointer precision is higher.
//
// Chevron symmetry: `appearance-none` drops the browser-drawn dropdown arrow,
// whose right inset is OS-controlled and reads visibly farther from the edge
// than the value text sits from the left. We paint our own chevron as a
// background SVG positioned `right 0.75rem center` (12 px) so its inset mirrors
// the left text padding (`px-3`) — matching the shadcn `<SelectTrigger>`.
// Doing it as a background (rather than an overlay element) keeps `<select>` a
// single element, so every call site's width / margin className still composes
// straight onto it. `pe-9` reserves room so a long value never runs under the
// chevron. The chevron glyph is the same path Lucide's `ChevronDownIcon` draws,
// at `currentColor` with 50 % opacity to match the trigger.
const CHEVRON_SVG =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' opacity='0.5'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E\")";
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
export const NativeSelect = React.forwardRef<
HTMLSelectElement,
NativeSelectProps
>(function NativeSelect({ className, children, ...props }, ref) {
>(function NativeSelect({ className, children, style, ...props }, ref) {
return (
<select
ref={ref}
data-slot="native-select"
className={cn(NATIVE_SELECT_CLASS, className)}
style={
{
"--native-select-chevron": CHEVRON_SVG,
...style,
} as React.CSSProperties
}
{...props}
>
{children}
+8 -1
View File
@@ -56,7 +56,14 @@ function SelectTrigger({
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
{/* `-me-0.5` (-2 px end margin) optically aligns the chevron with the
left text inset. The trigger box is `px-3` (12 px both sides), but
the Lucide chevron glyph carries ~2 px of empty space inside its
16 px bounding box, so the *visible* stroke landed ~2 px farther
from the right edge than the value text sits from the left — the
trigger read lopsided. Pulling the icon box 2 px toward the edge
makes the visible chevron and the value text inset symmetric. */}
<ChevronDownIcon className="-me-0.5 size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
@@ -1653,6 +1653,118 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
expect(display.shortDays).toBe(90);
expect(display.longDays).toBe(365);
});
// v1.15.8 — the card renders the taken-of-expected count next to each rate
// so two identical percentages stay distinguishable. These pin the count
// numerators onto the display block and guard against a future regression
// where every window collapses to the same rate AND the same count (which
// would read as a stuck display rather than a real, trustworthy number).
it("carries a taken-dose count on each window row", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
// Daily med, perfect adherence over the trailing 30 days.
const events = [];
for (let d = 1; d <= 30; d++) {
const at = new Date(NOW.getTime() - d * DAY_MS);
at.setUTCHours(8, 10, 0, 0);
events.push({ scheduledFor: at, takenAt: at, skipped: false });
}
const display = buildComplianceDisplay(events, schedules, ctx(), {
now: NOW,
});
// Both rows ~100% — but the COUNTS differ (7-day window has fewer taken
// doses than the 30-day window), so the two rows are distinguishable even
// when the percentages are identical.
expect(display.short.taken).toBeGreaterThan(0);
expect(display.long.taken).toBeGreaterThan(0);
expect(display.long.taken).toBeGreaterThan(display.short.taken);
// The taken count never exceeds its window's expected denominator.
expect(display.short.taken).toBeLessThanOrEqual(display.expectedShort);
expect(display.long.taken).toBeLessThanOrEqual(display.expectedLong);
});
it("a daily med with misses concentrated outside the 7-day window → 7d ≠ 30d rate", () => {
// Guards the "looks broken" report from the other direction: the windows
// must NOT collapse to one value. A daily med taken every day inside the
// last 7 days but with a cluster of misses in the 8–30-day range yields a
// perfect short window and a degraded long window, so the two rows
// genuinely differ.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const events = [];
for (let d = 1; d <= 30; d++) {
// Days 1–7: every dose taken. Days 10–20: missed (no event row).
if (d >= 10 && d <= 20) continue;
const at = new Date(NOW.getTime() - d * DAY_MS);
at.setUTCHours(8, 10, 0, 0);
events.push({ scheduledFor: at, takenAt: at, skipped: false });
}
const display = buildComplianceDisplay(events, schedules, ctx(), {
now: NOW,
});
// Daily med stays on the 7 / 30-day rung.
expect(display.shortDays).toBe(7);
expect(display.longDays).toBe(30);
// The recent week is clean; the month carries the 10–20-day miss cluster.
// The windows MUST diverge — the short window outscores the long one,
// which is the whole point of showing two rows rather than one number.
expect(display.short.rate).toBeGreaterThan(display.long.rate);
expect(display.long.rate).toBeLessThan(100);
expect(display.short.rate).not.toBe(display.long.rate);
});
it("a rolling weekly med with a partial-adherence history → short and long rates CAN differ", () => {
// The operator's "every window is 100%" suspicion is real ONLY for a
// faithfully-on-cadence med; once doses are missed the windows MUST be
// able to diverge. A weekly rolling injection with recent perfect weeks
// but missed shots further back drives the short rate above the long rate
// — proving the metric does not structurally collapse to one value.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
// 16 weekly slots back; drop a cluster of older shots (weeks 8–12) so the
// long window scores a real miss while the recent weeks stay perfect.
const events = Array.from({ length: 16 }, (_, i) => i)
.filter((i) => i < 6 || i > 11)
.map((i) => {
const at = new Date(lastShot.getTime() - i * 7 * DAY_MS);
at.setUTCHours(20, 0, 0, 0);
return { scheduledFor: at, takenAt: at, skipped: false };
});
const display = buildComplianceDisplay(
events,
schedules,
ctx({
startsOn: new Date(lastShot.getTime() - 15 * 7 * DAY_MS),
lastIntakeAt: lastShot,
}),
{ now: NOW },
);
// The windows are NOT forced equal: the recent short window outscores the
// longer one that carries the dropped cluster.
expect(display.short.rate).toBeGreaterThan(display.long.rate);
});
});
describe("expectedSlotsBetween", () => {
+11 -6
View File
@@ -636,10 +636,15 @@ export interface ComplianceDisplay {
expectedLong: number;
/** Echo of the density floor so a client can re-derive the rung. */
minStableDoses: number;
/** Compliance percentage + day-streak over the short window. */
short: { rate: number; streak: number };
/** Compliance percentage over the long window. */
long: { rate: number };
/**
* Compliance percentage + taken-dose count + day-streak over the short
* window. `taken` is the count of doses taken in the window (the numerator
* the card renders next to the rate so two identical percentages stay
* distinguishable and trustworthy).
*/
short: { rate: number; taken: number; streak: number };
/** Compliance percentage + taken-dose count over the long window. */
long: { rate: number; taken: number };
/**
* v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage
* rows so a between-doses sparse med never renders a scary red number.
@@ -785,8 +790,8 @@ export function buildComplianceDisplay(
expectedShort,
expectedLong,
minStableDoses: MIN_STABLE_DOSES,
short: { rate: short.rate, streak: short.streak },
long: { rate: long.rate },
short: { rate: short.rate, taken: short.taken, streak: short.streak },
long: { rate: long.rate, taken: long.taken },
currentCycle,
};
}
+23
View File
@@ -0,0 +1,23 @@
/**
* The single source of truth for the Argon2id hashing parameters.
*
* Both the application's `hashPassword()` (src/lib/auth/password.ts) and the
* operator password-reset CLI (scripts/reset-password.mjs) import these exact
* options so a hash minted from the container matches one minted by the running
* app byte-for-byte. The CLI must run under plain `node` inside the production
* standalone image (which strips `tsx` and cannot import the TS client), so the
* shared params live in a plain `.mjs` module that both an ESM `node` process
* and the bundled TypeScript app can import.
*
* Do not fork these values. Changing them changes the verifier cost for every
* new hash; existing stored hashes carry their own parameters in the encoded
* string and keep verifying regardless.
*
* @type {{ memoryCost: number; timeCost: number; outputLen: number; parallelism: number }}
*/
export const ARGON2_HASH_OPTIONS = {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
};
+6 -6
View File
@@ -3,14 +3,14 @@ import zxcvbn from "zxcvbn-typescript";
import { getZxcvbnTranslations } from "@/lib/zxcvbn-i18n";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
// The Argon2id params live in a plain `.mjs` module so the operator
// password-reset CLI (scripts/reset-password.mjs) can mint a byte-identical
// hash under plain `node` in the production standalone image — there is one
// source of truth for the cost parameters, not two.
import { ARGON2_HASH_OPTIONS } from "./argon2-params.mjs";
export async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
return hash(password, ARGON2_HASH_OPTIONS);
}
export async function verifyPassword(
-554
View File
@@ -1,554 +0,0 @@
/**
* Client-side PDF generation for doctor reports.
*
* Every string and every number/date is driven by an injected `t()` and
* locale-aware formatters so the exported PDF matches the user's UI language.
* Call sites must pass `{ t, locale }` (use the ones from `useTranslations()`
* / `useFormatters()`).
*/
import { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { makeFormatters } from "./format-locale";
import type { Locale } from "./i18n/config";
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
interface ReportData {
period: { days: number; since: string; start?: string; end?: string };
patient: {
username: string | null;
dateOfBirth: string | null;
gender: string | null;
heightCm: number | null;
};
/** Free-text practice / clinic name printed on the cover. Optional. */
practiceName?: string | null;
stats: Record<
string,
{ avg: number; min: number; max: number; count: number; latest: number }
>;
/** Per-context glucose stats (canonical mg/dL). */
glucoseStats?: Record<
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
| { avg: number; min: number; max: number; count: number; latest: number }
| undefined
>;
/** Display-unit preference: "mg/dL" (default) or "mmol/L". */
glucoseUnit?: "mg/dL" | "mmol/L";
/** Per-context custom range (canonical mg/dL); fall back to defaults. */
glucoseRanges?: Record<
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
{ min: number; max: number } | undefined
>;
bmi: number | null;
compliance: Record<
string,
{ total: number; taken: number; skipped: number; missed: number }
>;
medications: Array<{
name: string;
dose: string;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string | null;
}>;
}>;
mood: {
avg: number;
min: number;
max: number;
count: number;
distribution: Record<number, number>;
} | null;
}
type T = (key: string, params?: Record<string, string | number>) => string;
export interface DoctorReportOptions {
t: T;
locale: Locale;
}
const TYPE_LABEL_KEYS: Record<string, string> = {
WEIGHT: "doctorReport.typeWeight",
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
PULSE: "doctorReport.typePulse",
BODY_FAT: "doctorReport.typeBodyFat",
SLEEP_DURATION: "doctorReport.typeSleep",
ACTIVITY_STEPS: "doctorReport.typeSteps",
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
BONE_MASS: "doctorReport.typeBoneMass",
OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
};
const TYPE_UNIT_KEYS: Record<string, string | null> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: null, // translated unit
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
};
const MOOD_LABEL_KEYS: Record<number, string> = {
1: "doctorReport.moodAwful",
2: "doctorReport.moodBad",
3: "doctorReport.moodNeutral",
4: "doctorReport.moodGood",
5: "doctorReport.moodGreat",
};
function getBmiClassificationKey(bmi: number): string {
if (bmi < 18.5) return "doctorReport.bmiUnderweight";
if (bmi < 25) return "doctorReport.bmiNormal";
if (bmi < 30) return "doctorReport.bmiOverweight";
if (bmi < 35) return "doctorReport.bmiObeseGrade1";
if (bmi < 40) return "doctorReport.bmiObeseGrade2";
return "doctorReport.bmiObeseGrade3";
}
function getBpClassificationKey(sys: number, dia: number): string {
if (sys < 120 && dia < 80) return "doctorReport.bpOptimal";
if (sys < 130 && dia < 85) return "doctorReport.bpNormal";
if (sys < 140 && dia < 90) return "doctorReport.bpHighNormal";
if (sys < 160 && dia < 100) return "doctorReport.bpHypertensionGrade1";
if (sys < 180 && dia < 110) return "doctorReport.bpHypertensionGrade2";
return "doctorReport.bpHypertensionGrade3";
}
export function generateDoctorReportPDF(
data: ReportData,
options: DoctorReportOptions,
): jsPDF {
const { t, locale } = options;
const formatters = makeFormatters(locale);
const num = (value: number, decimals = 1) =>
formatters.number(value, decimals);
const fmtDate = (iso: string) => formatters.date(iso);
const unitFor = (type: string): string => {
// Map entry === null means the unit needs translation (e.g. ACTIVITY_STEPS).
const staticUnit = TYPE_UNIT_KEYS[type];
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return t("doctorReport.unitSteps");
}
return staticUnit ?? "";
};
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
let y = margin;
doc.setFontSize(22);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.title"), margin, y);
y += 8;
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(100, 100, 100);
doc.text(t("doctorReport.subtitle"), margin, y);
y += 6;
// Practice / clinic name (optional). Rendered prominently above the
// separator so the addressee is visible at first glance.
if (data.practiceName) {
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(
`${t("doctorReport.practiceLabel")}: ${data.practiceName}`,
margin,
y,
);
y += 6;
doc.setFont("helvetica", "normal");
}
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5);
doc.line(margin, y, pageWidth - margin, y);
y += 8;
doc.setFontSize(9);
doc.setTextColor(80, 80, 80);
const patientInfo: string[] = [];
if (data.patient.username) {
patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.username}`);
}
if (data.patient.dateOfBirth) {
patientInfo.push(
`${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`,
);
}
if (data.patient.gender) {
const genderKeys: Record<string, string> = {
MALE: "doctorReport.genderMale",
FEMALE: "doctorReport.genderFemale",
};
const genderKey =
genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
}
if (data.patient.heightCm) {
patientInfo.push(
`${t("doctorReport.height")}: ${data.patient.heightCm} cm`,
);
}
// Reporting period uses explicit start/end when available; falls back to
// (since, now()) for legacy payloads.
const periodStart = data.period.start ?? data.period.since;
const periodEnd = data.period.end ?? new Date().toISOString();
patientInfo.push(
`${t("doctorReport.period")}: ${fmtDate(periodStart)} — ${fmtDate(periodEnd)}`,
);
patientInfo.push(
`${t("doctorReport.createdOn")}: ${fmtDate(new Date().toISOString())}`,
);
for (const line of patientInfo) {
doc.text(line, margin, y);
y += 4.5;
}
y += 4;
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.vitalsTitle"), margin, y);
y += 6;
const vitalRows: string[][] = [];
const vitalTypes = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"TOTAL_BODY_WATER",
"BONE_MASS",
"OXYGEN_SATURATION",
];
for (const type of vitalTypes) {
const s = data.stats[type];
if (!s) continue;
const unit = unitFor(type);
vitalRows.push([
t(TYPE_LABEL_KEYS[type] ?? ""),
`${num(s.latest)} ${unit}`.trim(),
`${num(s.avg)} ${unit}`.trim(),
num(s.min),
num(s.max),
String(s.count),
]);
}
// Per-context glucose rows (one per logged context). Values stored
// canonically in mg/dL — convert to the user's display unit. Reference
// ranges come from getEffectiveRange() server-side via data.glucoseRanges,
// falling back to ADA defaults below.
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
const glucoseLabelKeys = {
FASTING: "doctorReport.typeGlucoseFasting",
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
RANDOM: "doctorReport.typeGlucoseRandom",
BEDTIME: "doctorReport.typeGlucoseBedtime",
} as const;
const defaultGlucoseRanges = {
FASTING: { min: 70, max: 99 },
POSTPRANDIAL: { min: 70, max: 140 },
RANDOM: { min: 70, max: 140 },
BEDTIME: { min: 90, max: 150 },
} as const;
const glucoseContexts: Array<keyof typeof glucoseLabelKeys> = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
for (const ctx of glucoseContexts) {
const s = data.glucoseStats?.[ctx];
if (!s) continue;
const conv = (v: number) => convertGlucose(v, glucoseUnit);
vitalRows.push([
t(glucoseLabelKeys[ctx]),
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
num(conv(s.min)),
num(conv(s.max)),
String(s.count),
]);
}
if (vitalRows.length > 0) {
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colParameter"),
t("doctorReport.colCurrent"),
t("doctorReport.colAvgPeriod"),
t("doctorReport.colMin"),
t("doctorReport.colMax"),
t("doctorReport.colN"),
],
],
body: vitalRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
const sysStat = data.stats.BLOOD_PRESSURE_SYS;
const diaStat = data.stats.BLOOD_PRESSURE_DIA;
if (sysStat && diaStat) {
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.bpClassificationTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg));
doc.text(
`${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`,
margin,
y,
);
y += 8;
}
if (data.bmi) {
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.bmiTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.text(
t("doctorReport.bmiRow", {
bmi: num(data.bmi, 1),
class: t(getBmiClassificationKey(data.bmi)),
}),
margin,
y,
);
y += 8;
}
// Glucose classification block — one line per logged context with the
// reference range and an in/out classification. Range source: server-side
// getEffectiveRange() (data.glucoseRanges). Falls back to ADA defaults.
const loggedGlucose = glucoseContexts.filter(
(ctx) => data.glucoseStats?.[ctx],
);
if (loggedGlucose.length > 0) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
for (const ctx of loggedGlucose) {
const s = data.glucoseStats![ctx]!;
const range = data.glucoseRanges?.[ctx] ?? defaultGlucoseRanges[ctx];
const conv = (v: number) => convertGlucose(v, glucoseUnit);
const inRange = s.avg >= range.min && s.avg <= range.max;
const classKey = inRange
? "doctorReport.glucoseInTarget"
: s.avg < range.min
? "doctorReport.glucoseBelowTarget"
: "doctorReport.glucoseAboveTarget";
doc.text(
t("doctorReport.glucoseRow", {
label: t(glucoseLabelKeys[ctx]),
avg: num(conv(s.avg), 1),
unit: glucoseUnit,
rangeMin: num(conv(range.min)),
rangeMax: num(conv(range.max)),
class: t(classKey),
}),
margin,
y,
);
y += 5;
}
y += 3;
}
const complianceEntries = Object.entries(data.compliance);
if (complianceEntries.length > 0) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.complianceTitle"), margin, y);
y += 6;
const compRows = complianceEntries.map(([name, c]) => {
const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
return [
name,
String(c.taken),
String(c.skipped),
String(c.missed),
String(c.total),
rate,
];
});
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colMedication"),
t("doctorReport.colTaken"),
t("doctorReport.colSkipped"),
t("doctorReport.colMissed"),
t("doctorReport.colTotal"),
t("doctorReport.colComplianceRate"),
],
],
body: compRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
if (data.mood) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.moodTitle"), margin, y);
y += 6;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.text(
t("doctorReport.moodSummary", {
avg: num(data.mood.avg, 1),
count: data.mood.count,
min: num(data.mood.min, 0),
max: num(data.mood.max, 0),
}),
margin,
y,
);
y += 6;
const distRows = Object.entries(data.mood.distribution).map(
([score, count]) => [
t(MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral"),
String(count),
data.mood!.count > 0
? `${num((count / data.mood!.count) * 100, 1)}%`
: "0%",
],
);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colMood"),
t("doctorReport.colCount"),
t("doctorReport.colShare"),
],
],
body: distRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
const addFooter = (pageDoc: jsPDF) => {
const pageHeight = pageDoc.internal.pageSize.getHeight();
pageDoc.setFontSize(7);
pageDoc.setFont("helvetica", "italic");
pageDoc.setTextColor(140, 140, 140);
pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14);
pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10);
pageDoc.text(
t("doctorReport.footerSource", {
timestamp: formatters.dateTime(new Date()),
}),
margin,
pageHeight - 6,
);
};
const totalPages = doc.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
addFooter(doc);
}
return doc;
}
@@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import { readFileSync, unlinkSync } from "node:fs";
import { createHash } from "node:crypto";
import { deflateRawSync } from "node:zlib";
import {
parseBoundary,
streamMultipartToDisk,
} from "../stream-to-disk";
import { extractExportXml } from "@/lib/import/unzip-export-xml";
const BOUNDARY = "----HealthLogTestBoundary";
@@ -142,3 +145,315 @@ describe("streamMultipartToDisk", () => {
).rejects.toThrow(/boundary/);
});
});
/**
* Deterministic pseudo-random binary that contains the byte sequences a
* multipart parser is most likely to mis-handle: bare CRLF (0x0D 0x0A),
* "--" runs (0x2D 0x2D), and partial boundary-marker prefixes. None of
* these is the full boundary marker (the sender guarantees the marker
* cannot appear in the content), so a correct parser must copy every
* byte through verbatim.
*/
function makeAdversarialBinary(n: number): Buffer {
const b = Buffer.alloc(n);
let s = 0x12345678 >>> 0;
for (let i = 0; i < n; i++) {
s = (s * 1664525 + 1013904223) >>> 0;
b[i] = (s >>> 16) & 0xff;
}
const partialMarker = Buffer.from(`\r\n--${BOUNDARY.slice(0, 12)}`);
for (let off = 100; off + partialMarker.length < n; off += 991) {
partialMarker.copy(b, off);
}
for (let off = 37; off + 1 < n; off += 257) {
b[off] = 0x0d;
b[off + 1] = 0x0a;
}
return b;
}
/** Build a single-entry deflate ZIP wrapping the supplied member. */
function buildZip(name: string, data: Buffer): Buffer {
const nameBuf = Buffer.from(name, "utf8");
const compressed = deflateRawSync(data);
const table: number[] = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
let crc = 0 ^ -1;
for (let i = 0; i < data.length; i++) {
crc = (crc >>> 8) ^ table[(crc ^ data[i]) & 0xff];
}
const crc32 = (crc ^ -1) >>> 0;
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(8, 8);
local.writeUInt32LE(crc32, 14);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
const cdh = Buffer.alloc(46);
cdh.writeUInt32LE(0x02014b50, 0);
cdh.writeUInt16LE(20, 4);
cdh.writeUInt16LE(20, 6);
cdh.writeUInt16LE(8, 10);
cdh.writeUInt32LE(crc32, 16);
cdh.writeUInt32LE(compressed.length, 20);
cdh.writeUInt32LE(data.length, 24);
cdh.writeUInt16LE(nameBuf.length, 28);
const localFull = Buffer.concat([local, nameBuf, compressed]);
const cdhFull = Buffer.concat([cdh, nameBuf]);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(1, 8);
eocd.writeUInt16LE(1, 10);
eocd.writeUInt32LE(cdhFull.length, 12);
eocd.writeUInt32LE(localFull.length, 16);
return Buffer.concat([localFull, cdhFull, eocd]);
}
/** Feed `buf` to a ReadableStream in the repeating chunk sizes given. */
function fixedChunkStream(
buf: Buffer,
sizes: number[],
): ReadableStream<Uint8Array> {
let i = 0;
let si = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i >= buf.length) {
controller.close();
return;
}
const size = sizes[si % sizes.length];
si++;
const end = Math.min(buf.length, i + size);
controller.enqueue(new Uint8Array(buf.subarray(i, end)));
i = end;
},
});
}
/** Split `buf` at the explicit byte offsets given. */
function splitAtStream(
buf: Buffer,
points: number[],
): ReadableStream<Uint8Array> {
const segments: Buffer[] = [];
let prev = 0;
for (const p of points) {
segments.push(buf.subarray(prev, p));
prev = p;
}
segments.push(buf.subarray(prev));
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i >= segments.length) {
controller.close();
return;
}
controller.enqueue(new Uint8Array(segments[i]));
i++;
},
});
}
describe("streamMultipartToDisk — large multi-chunk binary round-trip", () => {
// Regression for the cross-chunk boundary-suffix loss: a chunk that
// ended exactly on the opening boundary marker dropped the parser back
// into a fresh marker search and swallowed the whole part body, so a
// multi-chunk upload landed on disk truncated. The downstream ZIP
// reader then failed with "Could not locate ZIP End-Of-Central-Directory
// record". A tiny single-chunk upload never tripped it.
it("writes a multi-MB binary byte-exact under coarse chunkings", async () => {
// A few-MB payload exercises many chunks at realistic network sizes —
// this is the shape the wild 40 MB upload took.
const fileContent = makeAdversarialBinary(3 * 1024 * 1024 + 777);
const expectedSha = createHash("sha256")
.update(fileContent)
.digest("hex");
const body = buildMultipartBody("export.zip", fileContent, {
userId: "u-1",
});
const markerLen = `--${BOUNDARY}`.length;
const sepLen = `\r\n--${BOUNDARY}`.length;
const chunkings: number[][] = [
[body.length], // single chunk (the trivially-passing case)
[4096],
[16384, markerLen, 1, 2, 3], // realistic chunks then tiny tails
// A repeating size aligned so chunks routinely end on / inside the
// boundary marker — the exact window the bug lived in.
[markerLen, 8192],
[sepLen - 1, 8192], // ends one byte short of the full separator
[1024, 64, 5000, 13, 8192], // odd repeating mix
];
for (const sizes of chunkings) {
const result = await streamMultipartToDisk(
fixedChunkStream(body, sizes),
`multipart/form-data; boundary=${BOUNDARY}`,
{ maxBytes: 1.5 * 1024 * 1024 * 1024, fieldName: "file" },
);
try {
expect(result.bytes, `bytes for chunking ${sizes}`).toBe(
fileContent.length,
);
expect(result.sha256, `sha for chunking ${sizes}`).toBe(expectedSha);
const written = readFileSync(result.filePath);
expect(
written.equals(fileContent),
`byte-exact for chunking ${sizes}`,
).toBe(true);
expect(result.textFields.userId).toBe("u-1");
} finally {
try {
unlinkSync(result.filePath);
} catch {
/* ignore */
}
}
}
}, 20_000);
it("writes byte-exact under fine (1/3/7-byte) chunkings", async () => {
// The pathological 1-byte feed is the cleanest reproduction of the
// boundary-suffix-loss bug; keep the payload small so the per-chunk
// file writes stay fast while still spanning thousands of chunks.
const fileContent = makeAdversarialBinary(16 * 1024 + 17);
const expectedSha = createHash("sha256")
.update(fileContent)
.digest("hex");
const body = buildMultipartBody("export.zip", fileContent, {
userId: "u-1",
});
for (const sizes of [[1], [3], [7], [1, 3, 7, 2, 5, 13]]) {
const result = await streamMultipartToDisk(
fixedChunkStream(body, sizes),
`multipart/form-data; boundary=${BOUNDARY}`,
{ maxBytes: 1024 * 1024, fieldName: "file" },
);
try {
expect(result.bytes, `bytes for chunking ${sizes}`).toBe(
fileContent.length,
);
expect(result.sha256, `sha for chunking ${sizes}`).toBe(expectedSha);
const written = readFileSync(result.filePath);
expect(
written.equals(fileContent),
`byte-exact for chunking ${sizes}`,
).toBe(true);
} finally {
try {
unlinkSync(result.filePath);
} catch {
/* ignore */
}
}
}
}, 20_000);
it("survives a chunk split at every offset (single + adjacent)", async () => {
// Small body, exhaustive split coverage — the failing case in the
// wild was a chunk boundary landing in a specific window; assert no
// offset can corrupt the captured bytes.
const fileContent = Buffer.concat([
Buffer.from("AAAA\r\n--BBBB"),
Buffer.from(`\r\n--${BOUNDARY.slice(0, 12)}`),
Buffer.from("CCCC\r\nDDDD\r\n--"),
Buffer.from([0x0d, 0x0a, 0x2d, 0x2d, 0x00, 0xff]),
]);
const expectedSha = createHash("sha256")
.update(fileContent)
.digest("hex");
const body = buildMultipartBody("export.zip", fileContent, {
userId: "u-1",
});
for (let p = 1; p < body.length; p++) {
for (const points of [[p], [p, Math.min(p + 1, body.length)]]) {
const result = await streamMultipartToDisk(
splitAtStream(body, points),
`multipart/form-data; boundary=${BOUNDARY}`,
{ maxBytes: 1024, fieldName: "file" },
);
try {
expect(result.sha256, `sha at split ${points}`).toBe(expectedSha);
const written = readFileSync(result.filePath);
expect(
written.equals(fileContent),
`byte-exact at split ${points}`,
).toBe(true);
} finally {
try {
unlinkSync(result.filePath);
} catch {
/* ignore */
}
}
}
}
});
it("a streamed export.zip stays a valid archive end-to-end", async () => {
// Build a real deflate ZIP whose member contains binary-flavoured
// XML, stream it through awkward chunkings, and confirm the file on
// disk is still extractable — i.e. the EOCD record survives.
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<HealthData locale="en_US">\n`;
let s = 42 >>> 0;
for (let i = 0; i < 8000; i++) {
s = (s * 1103515245 + 12345) >>> 0;
xml += `<Record type="HKQuantityTypeIdentifierStepCount" value="${s % 1000}"/>\n`;
}
xml += `</HealthData>\n`;
const xmlBuf = Buffer.from(xml, "utf8");
const zip = buildZip("apple_health_export/export.xml", xmlBuf);
const expectedSha = createHash("sha256").update(zip).digest("hex");
const body = buildMultipartBody("export.zip", zip, { userId: "u-1" });
const markerLen = `--${BOUNDARY}`.length;
for (const sizes of [[7], [markerLen, 257], [1, 3, 7, 64, 13, 257]]) {
const result = await streamMultipartToDisk(
fixedChunkStream(body, sizes),
`multipart/form-data; boundary=${BOUNDARY}`,
{ maxBytes: 1.5 * 1024 * 1024 * 1024, fieldName: "file" },
);
let xmlPath: string | null = null;
try {
expect(result.sha256, `zip sha for chunking ${sizes}`).toBe(
expectedSha,
);
const written = readFileSync(result.filePath);
expect(written.equals(zip), `zip byte-exact for ${sizes}`).toBe(true);
// The load-bearing assertion: the saved file is still a parseable
// archive (EOCD intact) and the member round-trips.
const extracted = extractExportXml(result.filePath);
xmlPath = extracted.xmlPath;
expect(readFileSync(extracted.xmlPath).equals(xmlBuf)).toBe(true);
} finally {
try {
unlinkSync(result.filePath);
} catch {
/* ignore */
}
if (xmlPath) {
try {
unlinkSync(xmlPath);
} catch {
/* ignore */
}
}
}
}
}, 20_000);
});
+62 -28
View File
@@ -103,12 +103,21 @@ export async function streamMultipartToDisk(
// boundary length so we never accumulate more than necessary.
let buffer = Buffer.alloc(0);
// Current parser state:
// "preamble" — before the first boundary
// "headers" — inside a part's header block
// "body-file" — inside the captured file's body
// "body-text" — inside a text field's body
// "epilogue" — after the closing boundary
type ParserState = "preamble" | "headers" | "body-file" | "body-text" | "epilogue";
// "preamble" — before the first boundary marker
// "after-boundary" — a boundary marker was just consumed; awaiting
// its two-byte suffix ("\r\n" for another part or
// "--" for the closing boundary)
// "headers" — inside a part's header block
// "body-file" — inside the captured file's body
// "body-text" — inside a text field's body
// "epilogue" — after the closing boundary
type ParserState =
| "preamble"
| "after-boundary"
| "headers"
| "body-file"
| "body-text"
| "epilogue";
let state: ParserState = "preamble";
let currentFieldName: string | null = null;
let currentTextBuffer: Buffer[] = [];
@@ -151,19 +160,42 @@ export async function streamMultipartToDisk(
// search for the boundary marker, emit bytes up to it.
const processBuffer = async (): Promise<void> => {
while (true) {
if (state === "epilogue") {
// Everything after the closing boundary is discarded.
buffer = Buffer.alloc(0);
return;
}
if (state === "preamble") {
const idx = buffer.indexOf(boundaryMarker);
if (idx === -1) {
// Need more bytes to find a boundary; retain a small tail
// so a boundary split across chunks is still detectable.
if (buffer.length > boundaryMarker.length) {
buffer = buffer.slice(buffer.length - boundaryMarker.length);
// Need more bytes to find a boundary; retain a tail of
// `boundaryMarker.length - 1` bytes so a marker split across
// chunks is still detectable on the next read. Retaining the
// full marker length could drop a byte that completes the
// marker, so keep one fewer.
const keep = Math.min(buffer.length, boundaryMarker.length - 1);
if (buffer.length > keep) {
buffer = buffer.slice(buffer.length - keep);
}
return;
}
// Drop everything up to and including the boundary marker.
// Drop everything up to and including the boundary marker and
// hand off to "after-boundary" to classify the two-byte suffix.
// Doing this in its own state means a chunk boundary landing
// immediately after the marker cannot reset us into a fresh
// marker search (which would silently swallow the part body).
buffer = buffer.slice(idx + boundaryMarker.length);
// After the first boundary we expect "\r\n" or "--" (closing).
state = "after-boundary";
continue;
}
if (state === "after-boundary") {
// A boundary marker was just consumed. Wait until we have the
// two-byte suffix before deciding: "\r\n" introduces another
// part, "--" closes the multipart body. Never search for a new
// marker here — that is the bug class this state exists to
// prevent.
if (buffer.length < 2) return;
if (buffer[0] === 0x2d && buffer[1] === 0x2d) {
state = "epilogue";
@@ -174,7 +206,20 @@ export async function streamMultipartToDisk(
state = "headers";
continue;
}
return;
// RFC 2046 allows linear whitespace ("transport padding")
// between the marker and its CRLF. Skip a leading run of SP/HT
// and re-test; if neither suffix is present yet, the chunk is
// malformed.
if (buffer[0] === 0x20 || buffer[0] === 0x09) {
let i = 0;
while (i < buffer.length && (buffer[i] === 0x20 || buffer[i] === 0x09)) {
i++;
}
if (i >= buffer.length) return; // need more bytes
buffer = buffer.slice(i);
continue;
}
throw new Error("Malformed multipart boundary suffix");
}
if (state === "headers") {
@@ -243,21 +288,10 @@ export async function streamMultipartToDisk(
await closeFileSink();
}
buffer = buffer.slice(idx + sep.length);
// After the boundary we expect "\r\n" or "--".
if (buffer.length < 2) {
state = "preamble";
return;
}
if (buffer[0] === 0x2d && buffer[1] === 0x2d) {
state = "epilogue";
return;
}
if (buffer[0] === 0x0d && buffer[1] === 0x0a) {
buffer = buffer.slice(2);
state = "headers";
continue;
}
return;
// The boundary marker is consumed; classify its two-byte suffix
// in "after-boundary" so a chunk split landing right here cannot
// lose the next part.
state = "after-boundary";
}
};
+5 -1
View File
@@ -1273,9 +1273,13 @@ const complianceDisplay = z
minStableDoses: z.number().int().nonnegative(),
short: z.object({
rate: z.number().int().min(0).max(100),
taken: z.number().int().nonnegative(),
streak: z.number().int().nonnegative(),
}),
long: z.object({ rate: z.number().int().min(0).max(100) }),
long: z.object({
rate: z.number().int().min(0).max(100),
taken: z.number().int().nonnegative(),
}),
currentCycle: z
.object({
state: z
+12
View File
@@ -521,6 +521,18 @@ export const queryKeys = {
/** The caller's own custom symptoms (decrypted labels) the log-day sheet
* merges into the seeded chip grid. */
cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const,
/**
* v1.15.7 — in-flight Apple Health `export.zip` import. Keyed by the
* `jobId` returned from `POST /api/import/apple-health-export` so the
* Export & Import settings area can poll
* `GET /api/import/apple-health-export/[jobId]/status` with a
* `refetchInterval` while the job is running and stop on a terminal
* state. Each upload mints its own key, so a re-upload (or a second
* archive) never collides with a previous job's cached status.
*/
importJobStatus: (jobId: string) =>
["import", "apple-health", "status", jobId] as const,
};
/**
@@ -31,7 +31,7 @@
* 7. Legacy `daysOfWeek` fallback with `intervalWeeks > 1`.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -719,6 +719,21 @@ describe("legacy daysOfWeek fallback", () => {
// ────────────────────────────────────────────────────────────────────
describe("v1.8.2 per-med intake POST — source-agnostic slot collapse", () => {
// A taken-write must never snap onto a FUTURE slot, so the 07:00 slot
// these cases mint has to sit in the past relative to "now" — which it
// does not when the suite runs before 07:00 local. Pin the clock to
// local noon (kept on today via the real date) and fake only Date, so
// Prisma's real timers are untouched.
beforeEach(() => {
const noon = new Date();
noon.setHours(12, 0, 0, 0);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(noon);
});
afterEach(() => {
vi.useRealTimers();
});
it("updates a pre-existing pending REMINDER slot row instead of inserting a second WEB row", async () => {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
@@ -8,7 +8,7 @@
* - returns `duplicate` when an idempotencyKey is re-used
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -87,7 +87,10 @@ beforeEach(async () => {
const session = await prisma.session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
// Long expiry so the session survives the cases that pin the clock
// forward to local noon (a 60-minute expiry would read as expired
// under the faked time).
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
@@ -169,6 +172,25 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
describe("v1.8.2 — source-agnostic slot collapse", () => {
const TZ = "Europe/Berlin";
// A taken-write must never snap onto a FUTURE slot, so the 07:00
// taken-write cases below pin the clock to local noon (see
// `pinClockAfterMorningSlot`) so the slot sits in the past even when
// the suite runs before 07:00 local. Sibling cases that use a later
// slot or fixed dates keep real time, hence the per-test pin. This
// afterEach is a no-op for the cases that never faked.
afterEach(() => {
vi.useRealTimers();
});
// Pin only Date (leaving Prisma's real timers) to today's local noon,
// computed from the real date so it stays on the current day.
function pinClockAfterMorningSlot(): void {
const noon = new Date();
noon.setHours(12, 0, 0, 0);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(noon);
}
async function makeScheduledMed(timesOfDay: string[]): Promise<string> {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
@@ -192,6 +214,7 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
}
it("collapses an API taken-write onto a pre-existing pending REMINDER slot row (exact instant)", async () => {
pinClockAfterMorningSlot();
const prisma = getPrismaClient();
const medId = await makeScheduledMed(["07:00"]);
// The projector/worker minted this pending REMINDER row.
@@ -237,6 +260,7 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
});
it("collapses even when the write's scheduledFor drifts by 1 minute", async () => {
pinClockAfterMorningSlot();
const prisma = getPrismaClient();
const medId = await makeScheduledMed(["07:00"]);
const slot = localHmAsUtc(new Date(), TZ, 7, 0);