Commit Graph
191 Commits
Author SHA1 Message Date
Marc-André Bombeck 0ff1340ff4 fix(medications): never backfill days an edit reopens under auto-take
A start moved earlier, an end moved later or removed, or a medication
made active again reopened past slots, and the auto-take pass filled up
to a week of them as taken. The course window writer and the
re-activation paths now move the auto-take floor to the moment of the
edit.
2026-10-11 15:07:06 +02:00
Marc-André Bombeck a4cccc11ba fix(medications): keep an auto-taken rolling cadence moving
A rolling cadence yields only lastIntakeAt + N, so a slot the auto-take
pass never records (before the switch-on floor, past the catch-up
window, inside a pause, skipped or removed by the person) held it still
for good. Step the cadence forward by N past every such slot, keeping
its rhythm, and feed the stepped anchor to the card, dashboard and
schedule-tool next due so they show the slot the pass will record.
2026-10-11 15:00:37 +02:00
Marc-André Bombeck 195fa28cc5 feat(medications): publish the resolved reminder decision as remindersActive 2026-10-11 14:09:22 +02:00
Marc-André Bombeck c94e109bf0 feat(medications): record scheduled doses automatically 2026-10-11 13:46:16 +02:00
Marc-André Bombeck bb563b6506 Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
2026-10-08 10:32:11 +02:00
Marc-André Bombeck 27b94a6bf9 fix(safety): count drug names, class words and spelled counts in the dose screen
The dose-change class demanded a dosage-form noun next to every skip, stop
or extra imperative, and a drug name never counted. "Skip the amlodipine",
"then take an extra ramipril", "take one more tablet of ramipril", "Nimm
abends eine Tablette mehr", "Lass das Ramipril morgen weg" and "Stop your
blood thinner before the run" all passed.

A medication noun is now a dosage-form word, a drug-class word per locale
(blood thinner, Blutverdünner, anticoagulant, statin, insulin, ...), a
common generic-name stem (-pril, -sartan, -olol, -dipine, -formin, ...),
or one of the person's own medication names. The Coach loads the names
with the turn context and passes them to the reply screen and the trail
screen; a scheduled brand name is only known that way.

Add the extra / another / one more shape in en, de, fr, es, it, pl and ko,
spelled-out counts as a dose target, and stop / leave out / go without a
named medication. The number-free shapes use a two-word slot that a
preposition ends, so "take an extra minute to log your medication" and
"stop worrying about your medication" pass. A sentence that only names a
drug, a schedule description, a past change from the log, a negated
warning and a referral ("ask your doctor whether you should stop ...")
still pass.
2026-10-06 21:31:18 +02:00
Marc-André Bombeck 6bc764fa28 Write a medication PUT and its course in one transaction
The row update (name, schedule, oneShot) committed before the course
write. When a concurrent course made the course write refuse, the client
got the refusal while the rest was already written. The schedule replace,
the row update, the pause era and the course write now commit together,
so a refusal leaves the medication unchanged. The rollup recompute runs
after the commit.
2026-10-04 08:17:34 +02:00
Marc-André Bombeck 4317eb91ca Make the rolling course anchor opt-in
The rule that keeps an earlier course's intake from anchoring a rolling
cadence applied to every caller unless one opted out, which is how a
booster reminder picked it up. It now applies only when the context
declares rollingAnchor: "courseStart"; the four medication context
builders declare it, and every other caller keeps the previous
behaviour, where the last intake always anchors. A guard enumerates
every RecurrenceContext builder in src, requires each to be listed as a
medication or a non-medication builder, and checks the declaration on
both sides.
2026-10-04 00:42:54 +02:00
Marc-André Bombeck df85fd62c9 Drop the unused built-in category list export 2026-10-04 00:33:34 +02:00
Marc-André Bombeck b930ab80ff Keep booster and measurement reminders anchored on the last satisfaction
The course rule that stops an old intake from anchoring a rolling
cadence read a reminder's anchor date as a course start, so a booster
fourteen years past on a ten-year cadence came due later today instead
of staying overdue. Measurement and booster reminders now opt out with
rollingAnchor: "lastIntake"; the rule stays on medications.
2026-10-04 00:33:34 +02:00
Marc-André Bombeck 2c2151ae70 Keep the rolling anchor when a start only moved forward
An intake before startsOn stopped anchoring a rolling cadence, which
also moved the next dose of a single-course medication whose start had
merely been pushed forward a few days. The intake is now ignored only
when the dose it implies would fall before the course starts (a new
course after a gap); otherwise it anchors as before.
2026-10-03 23:42:29 +02:00
Marc-André Bombeck eada82f61e Derive a backfilled course in the account's time zone
The backfill and the restore of an older file read the creation day of
an end-only window in UTC while every reader of the window uses the
account's zone; both now use the account's zone (the default zone when
the stored one is unknown to Postgres). A window whose start lies after
its end, which the API refuses but older rows hold, becomes a one-day
course on the end day: every reader treated it as empty and ended, and
a swap would have invented expected doses. One shared rule
(courseFromWindow) serves the restore and the course writer.
2026-10-03 23:36:08 +02:00
Marc-André Bombeck 510d4b1d04 Write a medication's window and its course in one transaction
The PUT wrote startsOn/endsOn on the row and then rewrote the latest
course in a second transaction, so a failure between the two left the
row and its courses disagreeing. The window now reaches the row only
through the course writer, which writes both under one lock.
2026-10-03 23:29:12 +02:00
Marc-André Bombeck 9f865a9456 Show every course in the dose history
The history minted bands only from the latest course, so misses in an
earlier course counted in the rate and appeared nowhere in the history.
It now mints over the span of every course and keeps the slots on a
course day. The compliance ledger and the cadence missed tally drop the
gap slots before the reconstruction instead of after it, so a dose
logged between courses stays visible as an off-schedule take in both.
2026-10-03 23:24:23 +02:00
Marc-André Bombeck 8aa2e33435 Walk every course in the streak and the cadence timeline
The rate counted misses in earlier courses while the streak and the
cadence chart's missed tally minted only the latest course, so a miss
in a previous course never broke the streak. The cadence engine context
now carries the courses and expands over their span, keeping only course
days, the same rule as the compliance ledger.
2026-10-03 23:16:16 +02:00
Marc-André Bombeck 16c16000e7 Refuse a first course beside a medication taken continuously
A medication with no course rows and no window is running since its
creation; a course added beside it projected the medication into ending,
which stopped its reminders and dropped its history from compliance. The
create now refuses that (medications.course.currentOpen; End course
writes the first course). A window the rows never recorded is
materialised as the first course before the new one is checked.
2026-10-03 23:11:28 +02:00
Marc-André Bombeck a14174fe4d Let a medication have several courses
A medication taken now and then (#1024) keeps one row and gains course
rows: calendar spans, both ends inclusive, an optional encrypted note, no
schedule or dose of their own. Courses never overlap and every course but
the latest has ended, so the latest course is always the one the
medication's startsOn/endsOn project. src/lib/medications/courses.ts is
the only writer of those two columns for a medication with courses; it
writes under a per-medication advisory lock and re-projects in the same
transaction, so reminders, the next-due line and intakeActionable keep
reading the columns and stay right.

- Migration 0368 adds medication_courses and backfills one course per
  medication window (an end-only window starts on the creation day, or
  on its end day when that lies earlier); a medication without a window
  stays course-less. Idempotent, data-preserving.
- GET/POST /api/medications/{id}/courses and PATCH/DELETE
  .../courses/{courseId} (owner-only); create seeds the first course
  from a window; the medication PUT edits the latest course and refuses
  a window that would break the rules (medications.course.*).
- List, detail, create and update carry courses, courseCount,
  previousCourseEndedOn and canStartCourse, resolved on the server.
- Compliance expands over the span of all courses and drops the slots
  between them; every compliance read selects the courses. A rolling
  cadence is not anchored by an intake from a previous course.
- The card names the last course's end and the running course's number;
  the detail Lifecycle group lists the courses with start, edit and
  delete. The Coach compliance block, the MCP resource, the doctor
  report window and table, the clinician view and FHIR (one
  MedicationStatement per course with effectivePeriod and status) read
  the courses.
- Backup carries courses per medication and the restore writes them and
  re-projects; an older file derives the one course its window
  describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
2026-10-03 22:39:25 +02:00
Marc-André Bombeck 4cb8118802 Let a person name their own medication categories
A medication can be filed under a category the person named, next to the
built-in ones (#1041). The labels live in medication_category_labels,
encrypted at rest, capped at 20 per account; a medication filed under one
carries its custom:<uuid> key in the category column.

- GET/POST /api/medications/categories (POST idempotent, 30/min per actor,
  admitted at MANAGE like the cycle symptom vocabulary) and owner-only
  PATCH/DELETE /api/medications/categories/{key}. DELETE moves every
  medication filed under the category to OTHER in the same transaction
  and answers movedCount.
- category is widened to a built-in value or custom:<uuid> on the wire,
  the create and update bodies refuse a key that is not the caller's
  (422 medications.category.unknown), and list, detail, the insights
  medication tiles and the MCP medications resource carry categoryLabel.
  A key with no owned label reads as OTHER and is counted on the event.
- The wizard's class step lists the person's categories under the
  built-in rows with an inline add row; the list page gains a category
  filter (session only) and a manage sheet for rename, hide and delete.
- Backup carries customMedicationCategories (decrypted labels) and the
  restore writes them before the medications; wipe plan, encrypted
  column registry, rotation script, sharing guards and OpenAPI follow.
2026-10-03 21:38:03 +02:00
Marc-André Bombeck 383323b542 Merge remote-tracking branch 'origin/main' into fix/v1397-meta 2026-10-01 11:29:08 +02:00
Marc-André Bombeck 03eced171e Merge remote-tracking branch 'origin/main' into fix/v1397-meta 2026-10-01 11:25:06 +02:00
Marc-André Bombeck ada490c7e9 Merge remote-tracking branch 'origin/main' into fix/v1397-tz 2026-10-01 10:59:51 +02:00
Marc-André Bombeck b4577f5c31 test: give every scanning guard a floor on what it found
A guard that compares a sweep against an allowlist, or against "none of
these may exist", passes when the sweep comes back empty. walkSourceFiles
already refuses a short walk; this adds the same refusal for what a guard
does with the walk.

- requireFloor(label, found, floor) throws below the floor and refuses a
  floor of zero.
- scanSourceMatches(root, pattern, { fileFloor, matchFloor }) walks, matches
  and floors both the files read and the hits found.

Floors added, pinned at the current counts:

- coach_usage owner columns: the clauses it actually examined (6).
- measurement reference index: the foreign keys it extracted (2).
- job outcome fact inventory: job files (130, floor 120) and fact keys
  (399, floor 350).
- tone harness: assessment prompt modules (11).
- coach locale default: turn pipeline modules (15, floor 12).
- locale integrity: message bundles (7).
- settings card header coverage and settings anatomy: the walked cards.
- coach metric scope inventory: insight route sources.
- side-effect drift guard: both enums populated, since two empty keysets
  are equal.

The pill-mapper guard walked with globSync, which skips dot-prefixed
directories; it now walks through walkSourceFiles like the rest.
2026-10-01 10:52:48 +02:00
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.

The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.

Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:

- cycle calendar, insights, Coach snapshot and prediction refresh start
  their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
  early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
  zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
  projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening

49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
2026-10-01 09:59:57 +02:00
Marc-André Bombeck eae46ed535 fix(medications): preselect only a dose offered today, graded by the account's thresholds
The intake pickers chose their default from every active medication, so a
course that had ended but was not archived could be the preselected dose.
The default now reads the server's intakeActionable answer; the medications
page dialog still lists ended courses for back-filling a past dose, they are
just never the default.

Both pickers also graded "due" against the fixed 120 / 240-minute defaults
while the cards beside them used the account's own late and missed
thresholds. They read the same thresholds now.
2026-10-01 09:48:48 +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 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
Marc-André Bombeck 903f0a9a22 refactor(medications): drop the legacy today walker and its own zone solver
expandTodayIntakes had no caller left once the today projection moved onto
the recurrence engine and localHmAsUtc; it carried the last private
wall-clock solver in the medication code. The recurrence engine now takes
its offset and UTC-day helpers from the tz module instead of local copies.
2026-09-26 17:07:02 +02:00
Marc-André Bombeck 314e9c1aa3 refactor(tz): take the medication and reminder trees off the zone literal
The medication and measurement-reminder code now imports DEFAULT_TIMEZONE
from the tz module like every other fallback, so the lint rule no longer
exempts those paths.
2026-09-26 17:03:19 +02:00
Marc-André Bombeck 91571d4674 fix(medications): count the whole cadence in the supply runway
The runway estimate read FREQ=MONTHLY as one dose every 30 days whatever
its INTERVAL, so a quarterly injection with two doses left showed about 60
days of supply instead of 180. FREQ=DAILY;INTERVAL=2 counted as daily, a
yearly rule ignored INTERVAL and BYMONTH, and a monthly rule ignored extra
BYMONTHDAY values. A CYCLIC schedule was counted as if it never paused,
and a PRN schedule as if it were due every day. The rate now honours
INTERVAL for every frequency, counts the listed days and months, scales a
cyclic schedule by on / (on + off) and leaves PRN out. The low-stock job
selects the schedule type and cycle lengths so it reads the same rate.

Whole-dose counts no longer lose one to binary floating point: 2.4 mL at
0.8 mL per dose is 2.9999999999999996 in IEEE 754 and used to read as two
doses. The stock summary, both runway estimators, the list and detail
routes, the GLP-1 route, the Coach snapshot and the inventory readout now
floor through one helper that treats anything within 1e-9 of the next whole
number as that number.
2026-09-26 15:32:29 +02:00
Marc-André Bombeck 8a03654e8f fix(medications): read schedule days as calendar days, not in the user's zone
A medication's startsOn/endsOn are calendar dates stored as UTC midnight,
and the rrule library emits its day anchors the same way. The engine read
those midnights on the user's wall clock, which west of UTC is the evening
before. A FREQ=WEEKLY;BYDAY=MO plan therefore fired on Sundays in New York,
a daily course starting on the 14th produced a dose on the 13th, and an
evening dose on the last course day was cut off. One-shot doses, the first
dose of a rolling cadence and the legacy weekday walk (startsOn floor,
endsOn cap, every-N-weeks phase) carried the same shift.

The engine now keeps a calendar day as a calendar day end to end: startsOn
and rrule anchors are read from their UTC fields, instants (createdAt,
intake times) are converted to the local day they fall on, and only then is
the HH:mm materialised in the user's zone. Rolling cadences count calendar
days from the local day of the last intake, so a late-evening dose across a
DST change no longer slides a day. A nonexistent local time now always
moves forward, including in zones whose clocks skip midnight. RRULE anchors
with BYHOUR are collapsed per day so a twice-daily protocol cannot emit
duplicate slots.

CYCLIC on/off weeks are counted as seven-day blocks from the start day in
the local calendar. They used to snap to Sunday-rooted UTC weeks, so a
three-weeks-on course starting on a Wednesday lost its first days and the
off week began on the wrong day east and west of UTC.

Measurement reminders pass their anchor and course end as instants; the
adapter now converts them to the user's calendar day before handing them to
the engine.
2026-09-26 15:25:08 +02:00
Marc-André Bombeck 8700c17724 Merge branch 'feat/units-per-dose-mixed' into release/v1.39.1 2026-09-25 12:56:47 +02:00
Marc-André Bombeck 37c6b0e144 fix(medications): never mint a slot from before the live schedule era
The projector floored today's slots only on an empty archived era, the
reminder worker and the next-due reads on the newest revision boundary,
and the auto-miss pass on the creation instant alone. Switching tracking
back on and then moving a dose time the same afternoon therefore put the
morning dose on the card, and the next day's pass stamped it a miss; a
same-day times edit on any medication did the same.

One helper now names the live era start (the newest active revision's
boundary) and every path floors on it: the projector, the worker, the
doses card, the medication list, the MCP schedule tool and the auto-miss
pass, which leaves a placeholder minted after the switch for a slot
before it.
2026-09-25 10:03:08 +02:00
Marc-André Bombeck 1e040605be feat(medications): accept units per dose like 1.5 and let the editor enter them
A medication's units per dose, a schedule's override and a restored
backup now accept any value above 0 and at most 100 with at most four
decimal places, which is exactly what the Decimal(10,4) column stores.
Before, only whole numbers 1-100 and the fractions 1/4, 1/3, 1/2, 2/3,
3/4 passed, so one and a half tablets could not be recorded. A restore
now refuses a value no write route would accept.

The medication editor keeps its buttons and adds an Other field that
reads 1.5, 1,5, 1 1/2 and 1½, holds the step while the text does not
read, and previews the value it will save. The supply tab's packaging
dialog uses the same reader; it refused every non-whole value before,
so a medication at half a tablet could not have its packaging saved.
Cards and the supply tab show a whole number plus a fraction as 1½.

The rule, the input reader and the display format live in one module
without imports so the editor and the validators share them.

Refs #1034
2026-09-25 09:58:55 +02:00
Marc-André Bombeck b4c2706ec9 feat(medications): keep a medication as a record without tracking intake
A per-medication trackIntake flag (migration 0351, default on for every
existing and new row). With it off the dose, dates and schedule stay stored,
but nothing derives a due dose from them: the reminder worker, the manual
reminder check, the today projector, the auto-miss pass, the low-stock pass,
the doses card, the today list, the outstanding-dose badge, compliance and
its heatmap, the doctor report, the Coach, the BP-status gate, insights,
targets, achievements, the efficacy view and the MCP schedule tool all skip
it.

The medication wire serves such a medication with schedules: [] and the
stored rows in an additive recordedSchedules field, so a client that arms
local reminders from schedules stays silent without an update. Switching
tracking off or back on archives a schedule revision at that instant, the
untracked stretch with an empty payload, so it never counts as missed and
the projector does not put a dose from before the switch back on the card.

A structural guard freezes the set of schedule readers: each one filters on
the flag, derives from dueSchedules, shapes the wire with scheduleWireFields,
or is allowlisted with the reason it derives nothing due. The flag travels
through backup, restore and the medication CSV export.
2026-09-25 08:57:10 +02:00
Marc-André Bombeck 47c7c71802 fix(medications): read a pinned dose the same as the dose on its own
Automatic matching counts a dose taken up to an hour before a slot's
on-time window as on time. A dose pinned to the slot was judged from the
window's start instead, so the same dose read on time unpinned and late
once pinned. A pinned dose now uses the same early grace, and the
confirmation says so plainly: the dose counts as if it had been logged for
the slot, on time or late depending on when it was taken.

Refs #1028
2026-09-24 19:54:37 +02:00
Marc-André Bombeck eafef5adeb fix(medications): keep a recorded miss from before the medication's creation
The history view treated every auto-miss anchored before the medication's
creation as the today projector's placeholder and dropped it. That is true
only on the creation day. History that arrives from before the creation, a
restore of an older file for one, can carry real misses, and those vanished
from the history and the rates.

The creation stays the boundary of expected doses: an earlier one, the
first recorded dose or the course start date, would read every day between
it and the creation that no row names as missed, and an import carries
takes and skips only, so those misses would be invented. Before it, every
recorded take, skip and miss claims its slot. Only rows that record nothing
are dropped: a pending row, and an auto-miss within the day before the
creation, which is where the projector's placeholders sit.

Refs #1028
2026-09-24 19:53:42 +02:00
Marc-André Bombeck a6b8c61a82 fix(medications): count only a recorded dose as taken in the calendar
The adherence calendar counted every off-schedule ledger row as a dose taken
on time. An orphaned skip or auto-miss, a row on an instant that is no slot
of the schedule, carries no taken time, yet coloured its day as taken. Such
a row now counts as taken only with a taken time; an orphaned skip reads as
skipped, and an orphaned auto-miss counts nothing, as it already does in the
rate.

Refs #1028
2026-09-24 19:46:24 +02:00
Marc-André Bombeck fa86c9e46c fix(medications): say what attributing a dose to a slot counts as
The confirmation promised that an attributed dose always counts as taken
late. It counts as on time when it falls inside the slot's on-time window,
which also covers a dose taken shortly before the slot's clock time, and
as late otherwise. The copy now says so in every locale, and a test pins
the split it describes.

Refs #1028
2026-09-24 19:40:28 +02:00
Marc-André Bombeck fe7cccfd46 fix(medications): attribute doses to slots from before the medication existed
A medication added at 16:05 with doses at 09:00, 14:00 and 21:00 showed its
creation day wrongly in "Last 90 days". The 14:00 dose, recorded afterwards
on its own slot, read as an off-schedule take "due" at 21:00, and a 09:00
row with no time appeared as off-schedule too.

The history route minted slots only from the creation instant, so the 14:00
slot never existed for the dose to land on, while the write path, which
mints the whole day, had filed it there. The 09:00 row was the pending
placeholder the today projector mints for every slot of the day, which the
auto-miss pass stamped a day later; with no slot to bind to it surfaced as
an off-schedule row.

The ledger now mints slots over the requested window and takes the
creation instant as the floor of expected slots: a slot before it appears
only when a recorded dose claims it (a take by its window or its pin, or a
skip), and a pending or auto-missed placeholder on it is dropped. The first
archived schedule era also covers the stretch before the creation, so an
edited schedule behaves the same.

Every adherence surface tallies the same ledger with the same floor, so the
detail rates, the calendar and the ledger bar agree on that day. The doctor
report, the Coach and the adherence chips previously read the slots before
a medication existed as missed whenever their window started earlier; they
now read nothing there.

Refs #1028
2026-09-24 19:40:27 +02:00
muhdusama 24ff031f7a fix: preserve legacy compliance response contract 2026-09-21 07:17:13 +10:00
muhdusama 41d47c3184 fix: mark mirror adherence not applicable 2026-09-20 21:48:20 +10:00
Marc-André Bombeck 550a7b87d7 fix(medications): measure a row's nearest occurrence across sibling schedules
The nearest test enumerated only the schedule under test, and production
rows carry no schedule identity. On a medication composed of sibling
schedule rows (08:00 daily plus 12:00 daily, the wizard's compose shape) a
row snapped exactly to A's 08:00 was also asked about B's 12:00, and B's
own occurrences alone never showed that A's 08:00 is nearer: next-due, the
display due and the compliance cycle all answered tomorrow's 08:00 at 09:00
local, the same swallow one level up.

The neighbour set is now the union of every schedule's occurrences within
the radius, memoised per row, since it depends on the row alone and a
predicate asks about many slots. The rolling short-circuit still looks at
the schedule under test only. The era filter in the next-due predicate
compared a field no caller ever populated, so it is gone along with the
test that pinned it; the era floor lives in the overdue search's band
range and in the forward walk's start at now, as it always did in
production.

Tests: sibling schedules in both next-due and the compliance cycle, the
exact tie between two slots, the 25-hour fall-back day (a pair across the
repeated hour, and a row anchored in the old offset), and the ad-hoc
epsilon case now stands on the ad-hoc row alone.
2026-09-10 20:34:36 +02:00
Marc-André Bombeck 95140e5aa7 fix(medications): resolve one occurrence per intake row, the nearest
A taken, skipped or auto-missed row counted as resolving every occurrence
within six hours of its anchor. The radius was sized for the twice-daily
12 h gap, so on any schedule whose neighbouring doses sit closer than that
(19:49 and 23:59, 08:00 and 12:00, every four hours) the first dose's row
resolved the second occurrence too: the next-due walk jumped to tomorrow,
the card offered tomorrow's first dose, and a skip pressed next landed
there. The second dose could not be logged from the card at all.

Reproduced with a Europe/Berlin account on 19:49 + 23:59: the 19:49 take
left the 23:59 row pending and the skip was written to tomorrow's 19:49.
The compliance-journey e2e spec exposed the same thing in every run that
started after noon, when its twin slot is 23:59.

Rule, in slot-resolution.ts and shared by the list's next-due, the card's
overdue search and the compliance current-cycle descriptor: a row resolves
exactly one occurrence, the one nearest its anchor, and only within the
drift radius. An exact match short-circuits; a drifted anchor is measured
against every occurrence the schedule emits within the radius and loses to
any nearer one (ties go to the earlier occurrence), so the effective reach
is min(radius, half the gap to the neighbour) and a sibling can never be
swallowed. Rolling cadences skip the nearest test, their occurrences are
at least a day apart. Ad-hoc rows keep the one-minute epsilon, now in the
cycle descriptor as well, so the card's due slot and its cycle state agree.

The drift tolerance itself stays: it covers a row whose anchor sits off the
canonical instant, a pre-snap row or an instant that moved under it across
a DST shift, which is what the radius was introduced for in v1.15.10.
2026-09-10 20:17:38 +02:00
Marc-André Bombeck f9561395c7 fix(api): read every module a route reaches when listing the error codes
The catalogue guard read the route tree plus a hand-kept list of library
modules. The dose-history import builds its file-level codes in a module
that list never named, so `empty_file`, `missing_required_columns`,
`unreadable_json`, `json_not_an_array` and `json_carries_no_intake_time`
shipped on `POST /api/medications/intake/dose-history-import` while the
envelope's own description promised every code was enumerated below, and
the guard was green because it had not read the file.

The sweep now walks the import graph from every route under `src/app`:
1423 modules, resolved through the same `@/…` alias the app uses, with
the generated client never opened. A code is read where it is written,
however many hops from the route that answers with it, and nothing is
hand-listed any more. Seeding from the whole app tree rather than
`src/app/api` also reached the clinician share-link document route,
whose three codes were missing for the same reason.

A tuple of codes is read as a family, like the object form already was,
which is what makes the import's union visible; the constant holding it
is renamed to the `*_ERROR_CODES` spelling the sweep reads, since a list
named for anything else is as likely to be nutrients as error codes. The
two sync-ledger writers are cut out of the text first — `errorCode` on
`IntegrationStatus` is a different field with the same name.

Proven by breaking it both ways: dropping `empty_file` from the
catalogue turns the guard red, and a code planted in
`src/lib/analytics/compliance.ts`, a module no list ever named, turns it
red naming that file.
2026-09-10 08:31:44 +02:00
Marc-André Bombeck 4f2c90b66c Fold the side-effect tag through the shared helper
The three locale matchers were built in parallel and each grew its own
fold. Two of them are the same function: the side-effect tag fold and
foldForMatch agree character for character, so one now calls the other.

The third stays. foldLabel decomposes with NFKD, maps sharp s to ss and
joins with underscores; foldForMatch decomposes with NFD, leaves sharp s
alone and joins with spaces. That is a disagreement about who typed the
text, not an accident: one side of a lab comparison is a shipped label
retyped or prose a lab printed, where tolerating a different spelling
claims more than the fold can prove, while someone naming a metric to an
assistant is composing rather than reproducing. Both docblocks now say
so and name the other.
2026-08-23 14:58:51 +02:00
Marc-André Bombeck 5f8af25815 Merge branch 'fix/mcp-locale-aliases' into fix/locale-matching 2026-08-23 14:47:21 +02:00
Marc-André Bombeck bec4cb3dfb fix(mcp): let five of the six shipped languages name a metric
Three resolvers turned a user-authored word into an internal code by
comparing it against a hand-typed English list. The app ships six
languages, so five of them resolved nothing: `Fer` and `Żelazo` came back
as `unknown_nutrient` while the settings card rendered exactly those
words, and `poids` and `sommeil` reached no series at all.

Stop transcribing. `nutrients.names.*` and `measurements.type*` already
label these concepts in every bundle, so the match index is derived from
`locales` instead — a seventh language works the day its bundle lands,
with no edit at any call site. The nutrient tool's English labels now come
out of `messages/en.json` too, so the wire wording cannot drift from the
screen.

The MCP contract is unchanged in shape and stays protocol-level English.
What moves is which words are understood: `unknown_nutrient` /
`unknown_metric` still mean "could not place this word", distinct from a
placed name answering `no_data`. Nothing turns absence into data.

Two findings the sweep did not predict:

- `Puls` resolved to pulse-wave velocity, because the English
  display-name pass matches substrings and "pulse-wave velocity" contains
  it. An exact name now beats a loose English fragment.
- French and Italian use one phrase for body fat and fat mass, and three
  bundles use one for fat-free mass and lean body mass. A word two
  metrics claim resolves to neither; answering kilograms to someone who
  asked for a percentage is worse than saying the word was not
  recognised. The known set is pinned so a new collision surfaces.

The medication needles are the exception: they are drug names, with no
bundle to derive from. They fold to a normalised INN stem rather than to
a language, so `Metformin`, `Metformine`, `Metformina` and `Metforminum`
are one entry, and `Semaglutyd` and `Atorwastatyna` cost nothing to add.
A guard asserts no two molecules collapse onto one stem. Iron is the one
class with no stem to fold to — it is named for the element — so its word
list is hand-tabulated and says so.

Every English resolution that worked before returns the same answer.
2026-08-23 14:46:10 +02:00
Marc-André Bombeck 97d0c26ceb fix(glp1): match side-effect tags by key, not by an English/German word list
The GLP-1 side-effect chips in the mood form write the localised label into
the flat `MoodEntry.tags` column, and three readers matched that label against
a hand-written list of English and German words: the therapy timeline, the
Coach snapshot, and the doctor report's GLP-1 block. The app ships six locales.
Tap the nausea chip with the UI in French, Spanish, Italian or Polish and the
day never reached the timeline, the Coach grounded its answers on an empty
side-effect list, and a clinician read an empty table over a record that had
three months of symptoms. Nothing on any of those surfaces said the table was a
filter rather than the truth. The three lists had also drifted apart from each
other.

The chips are a closed set with stable keys, so matching the label was the
defect and the key is the fix. `glp1-side-effect-tags.ts` holds the catalogue —
seven keys, each with the i18n key its chip renders — and the mood form now
draws its strip from it, so the write side and the read side cannot diverge
again. `glp1-side-effect-tag-match.ts` builds the label-to-key index by reading
`messages/<locale>.json` for every entry in `locales`, rather than transcribing
the labels into a second list. Transcribing would have fixed today's four
locales and reopened the same hole on the day a seventh is added; deriving
means a new bundle is matchable as soon as it lands, with no edit in the
matcher. Comparison folds case, accents and separators, so the English and
German tags already in the database keep resolving and a straight apostrophe
matches the typographic one the French bundle uses.

All three readers now report the catalogue key instead of the stored string.
The same recorded symptom yields one key whatever language it was tagged in,
which also fixes the mixed-language output an account gets after switching UI
locale; the doctor-report PDF resolves the key through the label map so the
clinician reads it in the report's language.

A tag outside the catalogue is still never counted — a free-text mood tag must
not reach a doctor as an invented symptom, and the rest of somebody's day does
not belong on a therapy timeline. It is no longer dropped without trace: the
timeline route annotates the unresolved count on its wide event, so the
classification rate stays answerable while the tag text stays in the database.

The Coach's copy of the list also carried `vomiting`, `reflux` and `erbrechen`.
Those are removed. No chip writes them, so they only ever matched a hand-typed
tag in two of six languages; keeping them would have rebuilt the defect inside
its own fix. Restoring either symptom means giving it a chip and a label in all
six bundles.

The response schema for `GET /api/insights/glp1-timeline` now enumerates the
keys, and its description no longer documents the bilingual match as if it were
the contract.
2026-08-23 13:56:32 +02:00
Marc-André Bombeck 4b009c3a45 Publish the resolved per-slot dose units and a slot-aware runway on the medication wire
A medication whose slots carry different doses (a whole tablet in the
morning, a half at noon — #219) was consumed correctly but reported
wrongly: the wire shipped only the raw nullable per-slot column (clients
had to derive the inheritance), stockDosesRemaining divided by the
medication-level units alone, and no runway figure existed outside the
low-stock push.

- Every schedule now carries resolvedUnitsPerDose, resolved server-side
  exactly like the intake consumption resolver.
- stockDosesRemaining divides the units pool by the schedule-weighted
  average units per dose.
- runwayDays (whole days of supply under the slot-aware burn rate) rides
  the list AND detail payloads; the detail GET also gains the stock
  aggregates the list already had.
- The low-stock engine runs the same slot-aware math, so the push and
  the wire cannot disagree; the web cards, table, and detail Übersicht
  prefer the server figure over their client estimate.
- OpenAPI regenerated with the new fields.
2026-08-14 05:41:15 +02:00
Marc-André Bombeck 95baf58be7 Harden the guards that could go green for the wrong reason
The bearer MINT matcher demanded a literal apiToken.create( in the very
file that documents how a whitespace-wrapped call walked past its T1
sibling; it is whitespace-tolerant now, asserts a non-zero match count,
and the always-true filter beside it is gone. The T1 resolver matcher no
longer demands tokenHash first in the where object. The English-leak
guard pins a non-zero key floor per bundle so a broken walker cannot
pass as no-leaks-found. The two audit-row integration suites poll for
the fire-and-forget rows instead of sleeping a fixed span, with one
deliberately widened grace kept for the absence assertion. And the
fixture fuses with 2027 horizons (suggested-actions next-due, the
printed-expiry trio) are now-anchored so they can never age into
inverted assertions.
2026-08-14 02:33:07 +02:00