Commit Graph
7114 Commits
Author SHA1 Message Date
Marc-André Bombeck 85d11bc364 Merge branch 'main' into fix/v1396-small-fixes 2026-09-30 23:27:25 +02:00
Marc-André Bombeck a5d19bfe98 Merge pull request #1064 from MBombeck/fix/backup-summary-manifest
Answer the restore preview from counts kept with the backup
2026-09-30 23:25:26 +02:00
Marc-André Bombeck 3f6144a5b9 Merge pull request #1062 from MBombeck/feat/workouts-write-scope
Mint a workouts:write token for headless workout ingest
2026-09-30 23:25:09 +02:00
Marc-André Bombeck 70ce666d00 Merge pull request #1063 from MBombeck/fix/dev-deps-advisories
Raise the dev-only grpc-js, brace-expansion and undici 7 floors
2026-09-30 23:15:59 +02:00
Marc-André Bombeck 731e073284 fix(measurements): hour-bucket a CGM glucose series inside the 90-day window
A sensor reading every five minutes puts about 26 000 glucose readings in
a 90-day window, and the per-kind series sent every one of them raw. The
hourly bucketing that already applies to a dense heart-rate stream now
applies to glucose too, with the same 10 000-reading cap: the hour's mean
converted once to the user's unit, statistics over every raw reading.
A fingerstick meter stays far under the cap and keeps its raw points.

The doctor report keeps glucose raw inside 90 days. Its clinical panel is
computed from the raw readings of every source, while the day path works
from canonical-source day buckets, so the two do not give the same figures
when a meter and a sensor overlap.
2026-09-30 23:01:57 +02:00
Marc-André Bombeck ca10f2e5df fix(medications): dashboard quick-add offers only doses the server calls actionable
The dashboard and capture-picker intake form filtered the medication list
on `active && trackIntake`, so a course that had ended (still active,
nobody paused it) was offered for a dose while the cards, the table and
"take all" already followed the server's `intakeActionable`. The picker
now renders from that one answer as well.

When medications exist but none offers a dose today, the empty state no
longer claims there are no medications; it says nothing is due and
points to the medication history for a past dose.
2026-09-30 22:58:19 +02:00
Marc-André Bombeck a2fceb5585 fix(backups): answer the restore preview from counts kept with the copy
The restore dialog asked GET /api/admin/backups/{id}/summary for the
contents of a copy, and the route read the whole stored copy to answer:
every piece opened, every measurement parsed, the rest validated. On an
account of 1.8 million readings on slow hardware that took about two
minutes. The dialog stopped waiting after 15 s and said the contents
could not be read, and its automatic retry made the server read the
whole copy a second time.

The copy passes through the process once anyway while it is written, so
the preview is worked out there and kept on the row (DataBackup.preview,
migration 0364): the summarizeBackup counts, the schema version, and
the inner key ids with the sections they sit in and one sample value
each. Every creation path goes through storeBackupBlob (the weekly and
manual pass, and an upload), so every new copy carries one. The column
is plain JSON: counts and dates only, and the samples are ciphertext
that stands for what is inside the copy, which a key rotation must not
re-seal.

The route answers from it without opening the copy. Both verdicts are
taken again on every read, from what the preview keeps: whether this
release restores the schema version, and whether this server holds the
keys the copy needs. The pieces are still counted against the row, so
a copy that lost one is refused as the restore and the download refuse
it. A preview names the copy it describes and is ignored once the copy
changes; converting a single stored value into pieces carries it over.

A copy written before this has no preview. It is read whole once, as
before, and the result is kept on the row, so it is slow only the first
time; concurrent requests for the same copy share the one read.

Only a file that fails the schema answers 422 "Backup payload failed
schema validation". Read refusals keep their codes; anything else is
the server failing and answers 500.

The dialog no longer retries the preview, waits up to 55 s, and when it
stops waiting on an older copy it says the copy is still being read and
to open the dialog again in a few minutes.

Refs #1031
2026-09-30 22:51:02 +02:00
Marc-André Bombeck fab5cbccbf fix(deps): raise the dev-only grpc-js, brace-expansion and undici 7 floors
The dependency alerts flag development copies only: @grpc/grpc-js under the
test containers, brace-expansion on three major lines, and undici 7 next to
the app's own undici 8. None ships in the image.
2026-09-30 22:42:50 +02:00
Marc-André Bombeck c7be1a0c5f Merge branch 'main' into feat/workouts-write-scope 2026-09-30 22:35:44 +02:00
Marc-André Bombeck 95d3676821 Merge pull request #1061 from MBombeck/fix/deps-security-2026-09-30
Move next to 16.3.6 and raise three transitive floors
2026-09-30 22:35:26 +02:00
Marc-André Bombeck e7166cb3c6 feat(tokens): mint a workouts:write token for headless workout ingest
A bridge that pushes workouts from a watch vendor had no credential that
reached POST /api/workouts/batch: the route called requireAuth() with no
scope, so under the fail-closed default only a cookie session or a
wildcard token got through, and a measurements:write token was refused
403. The route's header still described the pre-fail-closed behaviour.

Add a separate workouts:write scope rather than widening
measurements:write, so a token already handed to a scale does not start
accepting workouts.

- POST /api/tokens/workouts mints it, mirroring the measurement and
  document mints: cookie session plus a fresh proof, a literal permission
  array, 10 mints a minute, at most 10 live tokens (409
  tokens.workouts.ceiling_reached), a one-year default lifetime, audited.
- POST /api/workouts/batch names the scope. Sessions and wildcard tokens
  behave as before. A scoped caller's workouts are stored as EXTERNAL,
  resolved from the credential, and a batch in which any entry names a
  source is refused 422 (workout.batch.source_not_permitted), the same
  rule the measurement batch applies. EXTERNAL is on no source ladder, so
  when the phone recorded the same session the phone's copy is shown.
- Settings > API offers a workout token card next to the measurement one,
  and the endpoint list shows the batch route.
- Guards: the mint site, the declared scope vocabulary, the recent-proof
  routes, and a new check that only the batch declares the scope.
- OpenAPI documents the mint, the batch's narrow-token behaviour and both
  error codes; docs/api/README.md lists the workout and document mints.

Refs #1054
2026-09-30 22:21:46 +02:00
Marc-André Bombeck 162ba62752 fix(deps): move next to 16.3.6 and raise the fast-uri, ip-address and dompurify floors
The production audit flags a critical advisory against next 16.2.0 to 16.3.5,
fixed in 16.3.6, and newer advisories against the transitive fast-uri (ajv),
ip-address (the MCP SDK's rate limiter) and dompurify (jspdf) copies. Next is
pinned to 16.3.6 rather than the newest patch so the install stays inside the
minimum release age without new exclusions.
2026-09-30 22:12:47 +02:00
Marc-André Bombeck ae6a5ac693 Merge pull request #1053 from MBombeck/release/v1.39.5
Release v1.39.5
v1.39.5
2026-09-28 01:17:57 +02:00
Marc-André Bombeck 3f3bb0579a chore(release): v1.39.5 — a dense heart-rate history within memory, and restores that hold less 2026-09-28 00:55:23 +02:00
Marc-André Bombeck 5bbcf28bdc Merge pull request #1052 from MBombeck/fix/1031-restore-memory
Bound restore memory and order it against the consolidation passes
2026-09-28 00:51:31 +02:00
Marc-André Bombeck add6d1fadc fix(restore): order the restore and the consolidation passes by one lock
A restore on a freshly booted instance failed its clearing step with a
deadlock (40P01) while the boot-time step consolidation was folding the
same account. A consolidation day updates or mints the day's total and
then soft-deletes the day's per-sample rows; the restore deletes every
reading of the account in one statement. When the restore reached a
day's samples before its total, each side held what the other needed
next.

The restore already took an advisory lock per account before its first
delete. Every day transaction of the passes built on runConsolidation
(step and mean consolidation, the per-sample cumulative drain, the dense
intraday retention) now takes the same lock in shared mode as its first
statement. A restore that starts mid-day waits for that day to commit;
a day that would start while a restore runs is refused at once, and the
pass leaves that account for its next run instead of folding rows the
restore is about to replace. The passes still share the lock with each
other.

A new integration test builds the lock order that deadlocked, with a
row trigger holding the consolidation on the day's total while the
restore starts; before this change the consolidation was cancelled with
a deadlock. A guard test requires every transaction in a pass on
runConsolidation to open with the shared lock.

Refs #1031
2026-09-28 00:34:11 +02:00
Marc-André Bombeck f2569b6fdf fix(restore): carry at most a hundred readings per insert statement
Prisma's debug logger keeps the arguments of its last hundred calls in
memory whether or not logging is on, and the client and the Postgres
adapter both log every statement with its parameters. A restore that
wrote 1 000 readings per statement therefore kept a copy of about the
last 100 000 readings alive for the whole measurement write: about
60 MB on 1.8 million Apple Health readings, next to a V8 heap limit of
524 MB in the default 1 GB container.

The bulk insert now splits whatever it is handed into statements of at
most 100 rows, which holds about 7 MB. The Apple Health export import
goes through the same helper and gets the same bound. The write costs a
little time: 94 s instead of 76 s for 1.8 million readings on a fast
host.

Refs #1031
2026-09-28 00:06:47 +02:00
Marc-André Bombeck 2eec103182 Merge pull request #1051 from MBombeck/fix/1023-dense-stream-memory
Keep a dense heart-rate stream out of memory
2026-09-27 21:47:18 +02:00
Marc-André Bombeck 6836830038 fix(doctor-report): report readings, not day buckets, for a dense type
A type served from day buckets (pulse or glucose past 90 days, and since
the previous change a dense pulse stream on any window) is drawn one point
per day, each the day's mean at the day's start. The summary line counted
those points as measurements, and the FHIR export stamped the last day's
mean as the latest observation: a value and a time nobody measured.

The day-bucket stats now carry when their newest reading was taken
(`latestAt`). `latestReading` returns that reading when it is there, and
the summary line counts each type's readings from its stats. Raw series
are unchanged: their stats count equals their points and their last point
is the newest reading.
2026-09-27 21:20:24 +02:00
Marc-André Bombeck 3458100ac4 test: give the report's pulse-window count a mock and a typed route call 2026-09-27 21:16:20 +02:00
Marc-André Bombeck 0e991c3640 fix(measurements): fold a dense pulse stream inside the 90-day raw window
Past 90 days every pulse reader already folded in SQL. Inside it, three
still read every raw heart-rate row, which on a watch that records a
reading a minute is 130 000 rows per read (#1023):

- The insights page's resting-pulse proxy, used when an account has no
  resting heart rate, walked the whole window to take one percentile per
  day and held the event loop for over a second per view. It now folds in
  Postgres (`readRestingPulseProxy`) with the same percentile, rounding,
  minimum-sample rule and day anchor.
- The per-kind series sent every reading: 14.7 MB for 90 days. A pulse
  window with more than 10 000 readings is now bucketed per local hour,
  mean plus low/high band, with stats still over the readings.
- The doctor report drew every reading into one sparkline. A pulse window
  over the same cap now takes the per-day path longer reports already use.

Sparse pulse (a cuff, hourly imports, a watch sampling every few minutes)
stays under the cap and keeps its raw shape.
2026-09-27 21:07:29 +02:00
Marc-André Bombeck 821c2eb884 test(google-health): pin a heart-rate walk past a thousand pages 2026-09-27 20:43:15 +02:00
Marc-André Bombeck e34741c077 fix(google-health): write a dense collection page by page during a backfill
A full-history backfill read every heart-rate point of an account into one
array, mapped it into a second, and wrote the lot in one call; the cycle's
deferred-rollup ledger then kept one entry per written reading. On a watch
that records a reading a minute that is over a million points, and the
backfill ran a 1 GB heap out of memory within seconds, restarting the
instance it shares with the app (#1023).

The metric walk now hands each page to its write before requesting the
next, and both the Google Health and Fitbit ledgers collapse to one key per
touched type-day as they record. The heart-rate walk's page ceiling rises
from 1000 to 10000: at one point a minute the old ceiling is under two
years, a longer history always ended truncated, and the backfill retried
the whole walk on every attempt and every boot without completing.
2026-09-27 20:42:54 +02:00
Marc-André Bombeck c2a1522634 Merge pull request #1049 from MBombeck/dependabot/npm_and_yarn/pg-boss-12.34.0
build(deps): bump pg-boss from 12.27.0 to 12.34.0
2026-09-27 20:14:27 +02:00
Marc-André Bombeck 1910a6f760 Merge pull request #1047 from MBombeck/dependabot/npm_and_yarn/types/node-26.6.2
build(deps-dev): bump @types/node from 26.5.1 to 26.6.2
2026-09-27 20:01:12 +02:00
Marc-André Bombeck b9af05cf8d Merge pull request #1046 from MBombeck/dependabot/npm_and_yarn/undici-8.11.2
build(deps): bump undici from 8.10.2 to 8.11.2
2026-09-27 19:52:25 +02:00
Marc-André Bombeck 7a21bb6787 Merge pull request #1045 from MBombeck/dependabot/npm_and_yarn/tanstack/react-query-5.103.2
build(deps): bump @tanstack/react-query from 5.102.8 to 5.103.2
2026-09-27 19:51:59 +02:00
Marc-André Bombeck 522b61fdec Merge pull request #1050 from MBombeck/dependabot/github_actions/codeql-action-9d88a12d2b
build(deps): bump the codeql-action group with 3 updates
2026-09-27 19:51:55 +02:00
Marc-André Bombeck 6f6c4ffff0 test(jobs): re-pin the pg-boss retention assumptions to 12.34.0 2026-09-27 19:51:46 +02:00
dependabot[bot] dfd0f2e6dd build(deps-dev): bump prettier from 3.9.6 to 3.9.9 (#1048)
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.6 to 3.9.9.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.9)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.9.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-27 16:35:23 +00:00
dependabot[bot] bdad4f3dcd build(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.38.0 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

Updates `github/codeql-action/analyze` from 4.38.0 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

Updates `github/codeql-action/upload-sarif` from 4.38.0 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-27 15:55:47 +00:00
dependabot[bot] 891db1753e build(deps): bump pg-boss from 12.27.0 to 12.34.0
Bumps [pg-boss](https://github.com/timgit/pg-boss) from 12.27.0 to 12.34.0.
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.27.0...12.34.0)

---
updated-dependencies:
- dependency-name: pg-boss
  dependency-version: 12.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-27 15:55:34 +00:00
dependabot[bot] ddf685e2c9 build(deps-dev): bump @types/node from 26.5.1 to 26.6.2
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.5.1 to 26.6.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.6.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-27 15:54:38 +00:00
dependabot[bot] 5a5356fc14 build(deps): bump undici from 8.10.2 to 8.11.2
Bumps [undici](https://github.com/nodejs/undici) from 8.10.2 to 8.11.2.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.2...v8.11.2)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.11.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-27 15:54:10 +00:00
dependabot[bot] c5ed9e2296 build(deps): bump @tanstack/react-query from 5.102.8 to 5.103.2
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.102.8 to 5.103.2.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.103.2/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.103.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-27 15:53:43 +00:00
Marc-André Bombeck dbcbf752b1 Merge pull request #1044 from MBombeck/release/v1.39.4
Release v1.39.4
v1.39.4
2026-09-27 16:18:04 +02:00
Marc-André Bombeck 7d82a32fa9 fix(coach): keep the result table's scroll area reachable from the keyboard 2026-09-27 15:59:42 +02:00
Marc-André Bombeck 98f2875f8e test(coach): strip step markup until stable 2026-09-27 15:45:14 +02:00
Marc-André Bombeck 5ab157a908 chore(release): v1.39.4 — a Coach you can see working, encrypted routes and contacts, and restores that finish on large accounts 2026-09-27 15:12:37 +02:00
Marc-André Bombeck 833fb0b465 fix(workouts): report no route when its track cannot be read 2026-09-27 15:11:43 +02:00
Marc-André Bombeck f98f8eaa24 Merge branch 'fix/chart-all-range-meaning' into release/v1.39.4 2026-09-27 14:22:57 +02:00
Marc-André Bombeck 206da07e8c Merge branch 'fix/meds-ended-and-mental-health' into release/v1.39.4 2026-09-27 14:18:09 +02:00
Marc-André Bombeck 6f37cbdb23 fix(backup): carry each medication's category through backup and restore
The clinical category a person picks for a medication lives in a side table
created at runtime, outside the schema. The backup builder and the restore
only walk schema models, so no backup ever carried it: after a restore every
medication read as "Other".

Both backup formats now carry the category inside its medication, and the
restore writes it back in the same transaction as the medication. A file
from before this change restores as before; a category this server does not
know reads as Other instead of refusing the file.

The backup plan gains a list for tables created with raw SQL, and a check
that every such table in the source either maps to a schema model or has a
verdict there, so the next one cannot drop out of backups unnoticed.
2026-09-27 14:17:42 +02:00
Marc-André Bombeck 7b5ea8b353 fix(charts): name a folded point of steps as a daily average
A week or month point of a cumulative metric is the average daily total,
which keeps the axis comparable with the 7, 30 and 90 day tabs. The
caption said "Weekly avg", which reads as an average week's total. Steps,
energy, distance, flights and daylight now say "Daily avg per week" or
"per month" on the caption and in the tooltip, in all seven languages;
level metrics keep the plain average.
2026-09-27 14:08:50 +02:00
Marc-André Bombeck b45a668cf4 fix(charts): serve the All range's coarse buckets as their average day
The All range is read from the WEEK or MONTH rollup tier once a history is
long enough, from DAY rows for a short one, and from the live table on a
coverage miss. The chart folds every path into the average day of each
week or month, but a tier row carried a cumulative metric's whole total
and a level's reading-weighted mean. A month of steps therefore read about
thirty times higher for a long history than for a short one, under a
caption that said average.

A tier row now carries the average day of its bucket on every tier: a
cumulative metric's total over the days that hold a reading, a level's
mean of its daily means. The rollup fold counts the bucket's days and adds
up their means for it. Only the web chart's series reads (the chart
endpoint and the dashboard batch) use these rows.
2026-09-27 14:08:49 +02:00
Marc-André Bombeck e94e9b94a8 fix(medications): keep a medication's category when it is edited on the web
The add and edit wizard had no row for the thyroid, digestive, skin and
sleep-aid categories, so such a medication opened on "Other" and saving any
change, even a new dose time, rewrote its category to OTHER. The wizard
now offers a row for every category the app knows, translated in all seven
languages.

An edit also sends the category and treatment class only when the category
row was changed. A stored value the wizard cannot show, such as one a newer
server or another client wrote, now survives an unrelated edit.
2026-09-27 14:01:00 +02:00
Marc-André Bombeck c7e53176c1 Merge branch 'fix/v1394-review-correctness' into release/v1.39.4 2026-09-27 14:00:04 +02:00
Marc-André Bombeck a224875ed9 feat(medications): add mental health as a medication category
Antidepressants, anxiolytics, mood stabilisers and ADHD medication had no
category of their own and ended up under "Other". MENTAL_HEALTH joins the
clinical category list: the add and edit wizard offers it as its own row
with a brain icon, the card badge names it, and it is translated in all
seven languages.

The category is a text value, so no migration is needed. Clients that
read the category as a string show the new value; an app that does not
know it yet falls back to its own label for it.
2026-09-27 13:51:39 +02:00
Marc-André Bombeck 65c5cbda5b build: re-measure the aggregate client budget for v1.39.4
The release branch alone measured 3699 KB gz against the 3700 cap; the
review fixes added 1.3 KB gz across the whole build, nearly all of it new
copy in the seven lazy locale catalogs. The four eager routes measured
the same on both builds and stay inside their ceilings. The aggregate cap
moves to 3724, the same headroom as the earlier re-measurements.
2026-09-27 13:49:59 +02:00
Marc-André Bombeck 8295dfe9b6 fix(medications): stop offering a dose once the course has ended
A medication whose end date had passed still showed taken and skip in the
table and on its card, because the action row only looked at whether the
medication was active. The schedule engine already stops minting doses
after the end date, so reminders, the doses card and the today list were
quiet, but the medication itself kept inviting a dose that is no longer
part of the course.

The server now resolves one rule and publishes it on the list and detail
reads: `courseStatus` (UPCOMING, CURRENT or ENDED, in calendar days on the
user's clock, both ends inclusive) and `intakeActionable` (active, intake
tracked, course includes today). The table and both card variants render
their actions from it, the take-all flow skips such a medication, the card
and table name an ended course with an "Ended" badge, and no overdue
escalation is raised for it. A course that ends today keeps its actions
for the whole local day.

The detail header's "Ended" status now follows the same field. It compared
the end date's UTC midnight with the clock and so called the whole last
course day ended. Logging a past dose from the history is unchanged.
2026-09-27 13:49:21 +02:00