Commit Graph
3953 Commits
Author SHA1 Message Date
Marc-André Bombeck 862361a224 fix(recovery): publish why the recovery page is empty from the server
The page worked out its empty note on the client from the integrations
status, so every client had to repeat the rule. The analytics summaries
slice now carries `recoveryEmpty` (general, oura_permission or
oura_no_resilience, null when any recovery type has a reading), decided
once in resolveRecoveryEmpty from the gated summaries and the stored
Oura grant. The page renders that value only, and its block list is
typed to the server's recovery types so the two cannot drift.

Each reason now reads as one sentence in every locale, and every reason
offers the same way on, the integrations settings.
2026-10-20 05:01:18 +02:00
Marc-André Bombeck feb8058233 fix(medications): tally the cadence chips from the compliance ledger
`/api/medications/[id]/cadence` computed its adherence chip from a
private copy of the compliance ledger that ignored pause eras, courses
and auto-missed doses, and read only the chart's window of intakes. A
medication paused for a week read 77 % there and 100 % on the detail
page. The chips now tally `trailingComplianceLedger`, the ledger the
compliance payload builds, over the same window, with the medication's
pauses and courses; the copy is removed and a guard freezes the set of
files that build dose rows.
2026-10-20 04:37:44 +02:00
Marc-André Bombeck e355c6120f fix(workouts): one canonical-workout picker with one source order
Three pickers decided which of two overlapping workouts to keep: the
read-time picker the list, the detail page and the Coach use, a
write-time batch picker with its own 90-second window, and an unwired
field-merge picker. They used different source orders (the `steps`
ladder, or a separate workout default when no order was saved), so the
batch route could keep a row the list then ranked below another.

The batch route now runs the read-time picker over the rows it is about
to insert, with the ladder from `workoutSourceLadder`, and reports what
it hides as duplicates. A session HealthKit hands over twice under two
ids is stored once, as the list already showed it. The write-time and
the unwired picker are removed.
2026-10-20 04:37:39 +02:00
Marc-André Bombeck 2861075676 fix(mood): bound the mood series fallback to the rollup window
Without mood rollups the series walked every mood entry the account ever
logged, while the rollup path reads the trailing five years. The live
fallback now reads the same window.
2026-10-20 04:37:15 +02:00
Marc-André Bombeck 9ffcdea312 fix(insights): keep chronic conditions out of the symptom series fill
The symptom-severity series fills healthy days with zero across each
illness episode's span. A chronic condition has no end, so its span
covered the whole window and the correlation engine read a near-constant
run of zeros. Only episodes that can put the account into Rest Mode
(`REST_MODE_EPISODE_WHERE`) carry a span now.
2026-10-20 04:37:08 +02:00
Marc-André Bombeck ac86199808 fix(stats): one standard deviation with its kind named at the call site
About a dozen hand-rolled spreads each picked population or sample
variance without saying so, and the two surfaces a person compares
picked differently: the series API reported the population SD of a
blood-pressure series, the MCP tools and the AI payload the sample SD of
the same readings, about 22 % apart at three readings.

`lib/stats/spread` holds `variance` and `standardDeviation` with an
explicit `population | sample` kind and `READING_SPREAD`, the kind every
surface uses to report the spread of a person's readings. The series
route and the feature payload both report it; the spreads that feed a
verdict (the normal-swing checks, the changepoint threshold) and the
clinical glucose SD stay the sample estimate, now said out loud. A guard
fails on a new hand-rolled spread and on the two surfaces drifting apart.
2026-10-20 04:36:54 +02:00
Marc-André Bombeck 7535235b82 test(guards): give the new guards a floor on what they find
The zone-shift and single-Pearson guards now assert that their matchers
find the correct shapes before concluding nothing else matches, and the
dense-read guard lists the streak's per-day existence probe with the
reason it stays bounded.
2026-10-20 04:36:25 +02:00
Marc-André Bombeck b4333d65ff test: refuse raw control bytes in source files
Three files carried invisible control bytes in string literals, one of
them a production join key. They are escapes now, the same strings, and
a guard fails on any raw byte below 0x20 other than tab, newline and
carriage return.
2026-10-20 04:23:46 +02:00
Marc-André Bombeck b7e48e933c fix(sleep): read a night of any age and the whole series range
The night view read a year of stage rows whatever date it was asked
for, so an older night came back with no main session while a year of
rows sat in the heap; it now reads a day and a half either side of the
night, and the latest night from the latest asleep row. A day of naps
alone answers with its naps and main null.

The series cut "All" to a year for sleep without saying so. Postgres
now names the nights and the rows are reconstructed ninety nights at a
time, so the range reaches back as far as the history does.

The metric status read the latest 2190 stage rows, a few weeks on a
stage-rich watch, starting mid-night; it now reads the latest 365
nights the same way.
2026-10-20 04:23:10 +02:00
Marc-André Bombeck 3da72136ba refactor(measurements): move the list modes out of the route and page every night
The 1500-line list route carried the day-grouped list, the sleep list
and its SQL night page, each with its own copy of the window-total
paging. They now live in src/lib/measurements/list/ with one paging
helper, and the route dispatches.

The sleep list gives every page night a row. A night whose
reconstruction held no main sleep (a day of naps alone) used to be
dropped while meta.total still counted it, which left short pages; its
row now carries 0 minutes and its naps. The SQL night cut splices the
asleep stages from ASLEEP_STAGES and folds rows into hour buckets
before the window functions, which cannot move a session boundary and
sorts a handful of rows per night instead of every stage. A drill-down
reads its night the way the night view does.

groupBy=day refuses valueMin / valueMax with 422: a value range filters
single readings, and the rows promise whole days.

An integration test runs the SQL page and reconstructSleepSessions over
one set of awkward shapes and holds them to the same nights, and the
list, the night view and the series to one answer per day. The unit
test of the day-grouped branch now pins only the branch; what the rows
hold is pinned against Postgres.
2026-10-20 04:22:53 +02:00
Marc-André Bombeck 6328117bd2 fix(sleep): let a zero-minute granular row leave the bare night alone
A REM row of no length counted as the granular partition and stripped
the bare aggregate beside it, so a six-hour night read as no sleep while
the SQL page cut of the list counted it as a night.
2026-10-20 04:22:39 +02:00
Marc-André Bombeck b79bd8370d fix(heap): report again at 95 % and leave a report on an out-of-memory exit
The pressure report re-armed only after the heap fell under 70 % and
half an hour passed, so a heap that climbed to its limit soon after an
85 % report died with only that older report on record. A 95 % tier
now writes the same report at once, whatever the cooldown, and re-arms
under 85 %.

The image sets NODE_OPTIONS so Node writes its diagnostic report to
stderr when the heap runs out, with environment variables and network
interfaces excluded. The heap-diagnostics and scaling docs say how to
keep the flags when overriding NODE_OPTIONS.

Heap figures share one megabytes helper with the support report.
2026-10-20 03:42:26 +02:00
Marc-André Bombeck a22bf91311 fix(insights): report every correlation through the significance gate
The /insights envelope (weight x BP, mood x BP, mood x weight, mood x
pulse, BP medication continuity) and the AI feature payload computed
their coefficients with an ungated Pearson that banded |r| above five
pairs, so five days of noise sloping the right way read as a strong
correlation there while every other surface refused the same points.
Both now use `significantPearsonCorrelation` (n >= 20 and p < 0.05,
through the engine's exact p-value), and the ungated function is gone.
The allowlist guard that tracked its remaining callers becomes a guard
that there is one Pearson implementation.
2026-10-20 03:41:54 +02:00
Marc-André Bombeck 3cf67d05f9 fix(ui): let the read-failure primitives decide by the error
About forty compact rows and seventy cards still offered a retry on a
spent read budget and drew an error card on a refusal, while a third
primitive (ReadFailureRow) did the right thing at eight places.

QueryErrorRow and QueryErrorCard now take the query's `error` and read
it through readFailureKind, which moves to src/lib/api next to
ApiError: a refusal (403) draws the surface's refused state or nothing,
a spent budget (429) says so without a retry, an ended session (401)
says so and offers sign-in instead of going blank, and only a real
failure offers the retry. ReadFailureRow is gone and every site passes
its error; a guard test fails on a site that does not.

The coach read strip keeps its card in the error state and holds one
body height across loading, failure and a one-line read.
2026-10-20 03:38:57 +02:00
Marc-André Bombeck 04855b1a67 perf(day): read the day view's comparison history once while paging
Every day view compares its day with the two years before it to find
extremes, so stepping from one day to the next read the same two years
of readings again. The history is now cached per record, zone, source
ladder and readable type set, starting on a 90-day block boundary so
neighbouring days share one cell, and each day takes its own slice cut
at local day keys, which is exactly the window a live read would see.
Measurement writes sweep the cell with the rest of the analytics bucket.
A lookback floor inside the window and a window reaching past today
still read live, and a type the modules switch off is never read.
2026-10-20 03:36:44 +02:00
Marc-André Bombeck 0497685db0 fix(apple-health): count readings entered here before calling a type quiet (#1247)
Weight, blood pressure and blood glucose are written back to the phone
while their entry is bidirectional. A reading entered here goes to Apple
Health and never arrives back, so a person who keeps those readings here
saw the type go quiet after two weeks. The Apple Health status read now
adds `lastEnteredHereAt` to each written-back type, the newest reading
from any source other than Apple Health, and `stale` counts it; the card
shows it as "Last entered here". The value is resolved on the server so a
client renders it as sent.
2026-10-20 03:36:31 +02:00
Marc-André Bombeck 2b61762962 fix(integrations): round imported provider values through one normaliser
Fitbit, WHOOP, Oura, Polar and Withings each kept a private
`parseFloat(n.toFixed(2))`, which mis-rounds halves on the double below
the decimal. `importValue` keeps the same two places, rounds half away
from zero on the printed decimal and returns the nearest double, so a
value prints cleanly. A guard keeps `toFixed` and private `round2`
helpers out of the provider directories; the Google Health mapper is the
one listed exception until its next change.
2026-10-20 03:35:41 +02:00
Marc-André Bombeck e452dd62e2 fix(telegram): snooze a skipped dose to the end of the person's own day
The skip button set the rest-of-day snooze with `setHours(23, 59, …)` in
the process zone, which runs in UTC, so a skip after midnight UTC but
before local midnight east of it silenced the next local day's reminders
too, and one west of it ended the snooze hours early. The snooze now ends
at the last instant of the account's local day.
2026-10-20 03:35:36 +02:00
Marc-André Bombeck 1ee73908ae fix(jobs): run one heavy job at a time per process
Web and worker share one process and one heap limit, and the jobs that
hold a large working set (the Apple Health and Health Connect imports,
restore, the weekly and off-host backups, the restore drill, the rollup
backfill, the nightly status batch, the pre-generation walk and the
narrative walk) ran side by side whenever their schedules or an upload
met, so the peak was their sum. `heavyLane` queues them in the process,
first come first served. A waiter leaves the line when pg-boss expires it
and its retry takes the place; a one-shot whose input lives outside
pg-boss keeps its place. The runtime guard binds every heavy queue in the
lane, keeps `lockedPass` outside it, and allows the one-shot rule only
where the send options forbid a retry.
2026-10-20 03:35:23 +02:00
Marc-André Bombeck 6026a28fe4 fix(dashboard): key streak and compliance days in the person's own zone
`measured_at` and `scheduled_for` are naive UTC timestamps. The dashboard
streak and the medication-compliance backfill keyed them with a single
`AT TIME ZONE tz`, which reads the value as local time and shifts it the
wrong way: in Berlin a reading at 23:30 UTC counted for the day before
instead of the day after, so a streak could break or stretch near
midnight and the backfill recomputed the wrong day.

The streak's measurement days now come from `readActivityDayKeys`: one
existence probe per local day instead of a DISTINCT over a year of
readings. The DAY rollups cannot answer this because their buckets are
UTC days. The two compliance-rollup readers had no caller left and are
removed with the coverage probe that carried the same shift. A guard
fails on any single shift of a naive column into a bound zone.
2026-10-20 03:35:19 +02:00
Marc-André Bombeck d9e56482d9 perf(jobs): poll a queue only its cron feeds every 30 seconds
pg-boss polls each idle worker slot every two seconds, so the hundred-odd
bindings asked the database for work about fifty times a second while
most of them wait for one tick a day. `createAndWork` now gives a queue
that a registrar schedules and nothing else sends to a 30-second poll.
Scheduled queues that also take sends keep the default, because with one
job per fetch a slow poll would drain a burst at one job per interval;
the runtime guard holds that list to the send sites in the tree,
including sends through a variable.
2026-10-20 03:35:09 +02:00
Marc-André Bombeck 9c51dd0d30 perf(analytics): read the latest value per type with one probe per type
The latest canonical reading per type ran as `DISTINCT ON (type)` over
every live row of the account in five places (summaries slice warm and
cold path, comprehensive aggregator warm and cold path, targets). With no
skip scan in Postgres 16 that sorted the whole account to keep one row
per type. `readLatestCanonical` drives the read from the type list
instead: one index probe for each type's latest instant, then a closed
one-day window ranked by the source ladder. The closed window also keeps
the plan under `jit_above_cost`. The targets read reuses `readLatestEver`
with a floor.

The summaries slice was built inside three cache cells (the tile-strip
route, the thick analytics body, the dashboard snapshot). It now has one
cell, read through `readSummariesSlice`; composing callers read it
fresh-only so a refresh after a write cannot cache the pre-write slice
again, and the snapshot folds the water tile into a copy instead of the
cached object.
2026-10-20 03:35:03 +02:00
Marc-André Bombeck 0770ae3590 fix(jobs): retire the six per-metric status crons behind the nightly batch
The 02:00 batch builds every status card and falls each metric it cannot
cover back to that card's own path, so the 02:05 to 02:30 crons only
re-read cards the batch had just written. Their schedules and queues are
removed on an instance that still has them (`retireQueues`), and the
fresh-switch integration test drives the batch loop instead.
2026-10-20 03:34:51 +02:00
Marc-André Bombeck 87554c09f3 fix(google-health): key a waiting retry on when it is due
A slice that found nothing left but a parked collection queued the next
slice a day out under its slice number. A boot during that day ran a fresh
first slice, met the same wait and queued its own retry under a different
slice number, so two chains walked the same history. The waiting retry now
goes under a key named after the instant it is due, read from the stored
note, so every slice that meets the same wait names the same job and the
queue keeps one. A retry already due stays an ordinary next slice.
2026-10-20 03:34:46 +02:00
Marc-André Bombeck 7c23a49708 fix(google-health): annotate an ended backfill in one fixed shape
The ended note carried the whole map of ended collections, dates and all,
which duplicated the slice report and gave dashboards no stable shape. It
now names the collection that just ended, its type and reason, whether the
end is final, and counts the ends so far with their types. It stays meta:
the job names the event's action once the slice is over, which would
replace an action set here.
2026-10-20 03:34:45 +02:00
Marc-André Bombeck bfbd88b30b refactor(google-health): read and write backfill progress through one schema
The backfillProgress column was read by four hand-rolled walkers over
untyped records and written inline in the sync core. One module now owns
the column: a Zod schema reads it, lenient per entry so a malformed cursor
or end is dropped on its own, and one function writes it. The sync core
keeps only the bookkeeping of a running backfill.
2026-10-20 03:34:30 +02:00
Marc-André Bombeck 435099dc4f fix(sleep): decide nap versus main sleep in one place
The sleep score treated a short daytime session as a nap while the
nap convention always took the longest session as the night, so a day
with only an afternoon nap showed that nap as the night in the list,
the night view and the dashboard, and as no night in the score.

The rule and its constants now live in sleep-night.ts. Each session
carries `daytimeNap`, the main night is the longest main sleep, and a
day of naps alone has no main night. reconstructSleepNights picks its
main session by the same rule, and the score reads that session.

Sessions are filed under the day their last row ends on over every
writer, the rule the SQL page cut of the sleep list can also apply.

The sleep pillar's regularity floor now counts the midpoints it uses,
not the nights.
2026-10-20 01:26:16 +02:00
Marc-André Bombeck acdea22e2b fix(google-health): count only retries that end at the same edge
A collection parked after a walk without progress is walked again a day
later. Its attempt count went up on every later end without progress, even
when the retry had reached further back before it stalled again, so a
history that kept moving in short steps was declared ended after two
attempts. The count now carries the edge it was parked at and starts over
at one when the new end lies elsewhere.
2026-10-20 00:55:53 +02:00
Marc-André Bombeck 7be3714ac2 Merge pull request #1246 from robdegraaf/feat/i18n-nl 2026-10-20 00:14:49 +02:00
Marc-André Bombeck f05b2f71e7 Merge branch 'fix/v1436-oura' into release/v1.43.6 2026-10-19 20:20:29 +02:00
Marc-André Bombeck d833d39d7d Merge branch 'fix/v1436-gh' into release/v1.43.6 2026-10-19 20:20:28 +02:00
Marc-André Bombeck 3ff860591e Merge branch 'fix/v1436-ui' into release/v1.43.6 2026-10-19 20:20:28 +02:00
Marc-André Bombeck 7ccb7e0acc Merge branch 'fix/v1436-heap' into release/v1.43.6 2026-10-19 20:20:27 +02:00
Marc-André Bombeck e4c78e1d0c fix(google-health): give a stalled walk a second attempt before it ends
A walk that stopped for lack of progress ended its data type for good, so
a passing fault in Google's paging closed that history until a reconnect.
A walk that had written nothing yet was also noted as ending today.

The first end without progress now parks the data type with its edge: it
is not finished, not published, and is walked again a day later. Once
nothing else is left, the next slice is queued for when it is due, and
the marker is never stamped over a parked type. A second end without
progress is final; a second attempt that moves on is an ordinary walk
again. A walk that stalls before writing anything records no end and
fails the slice as stuck.

Refs #1220
2026-10-19 20:10:12 +02:00
Marc-André Bombeck bf84c0c11e fix(measurements): file sleep sessions by span, not by night label
The SQL page labels a sleep session by the last row of any writer; the
reconstruction labels it by its winning writer. A watch stopping before
midnight beside a phone whose in-bed span ran past it gave two days,
and matching the two by label dropped the night from the list while
the total still counted it. Each reconstructed session is now filed
under the page night whose span holds it and takes that night's label,
which the drill-down also asks by.

A sleep drill-down scans 36 hours either side of its day instead of the
whole history. Both day branches and the sleep list resolve the zone
through the same stored-zone resolver, so a drill-down always cuts the
days its list row summed. The list description states the real page
bounds: a day-sum page takes `limit` as asked, a sleep page caps at
1000 nights.

Refs #1245
2026-10-19 19:49:11 +02:00
Marc-André Bombeck 44d8181cb4 docs(openapi): publish the sleep score value
The derived envelope types `value` as an open record, so the sleep
score's `rhythmNights` and per-sub-score `gap` were on the wire but in
no schema. `SleepScoreValue` joins `WellnessScoreValue` in the forced
components slot, the envelope's `value` description names it, and a
contract test holds the published field set to the one the engine
emits, for a scored rhythm and for one withheld for too few nights.

Refs #1204
2026-10-19 18:56:06 +02:00
Marc-André Bombeck d08e2b4e6d fix(observability): keep heap samples out of the admin log ring and harden the snapshot directory
runtime.heap.sampled is written every minute while the heap is above
70 % of its limit, which turned the 500-entry ring behind the admin log
page over in hours and pushed out the events an operator opens it for.
emitEvent takes a skipMemoryRing option; the sample sets it and still
reaches stdout and Loki, while the pressure and snapshot lines go
everywhere as before.

The snapshot directory was /tmp/healthlog-heap-<pid>, a predictable name
(pid 1 in most containers) that mkdirSync accepted when it already
existed or was a symlink. It now comes from mkdtempSync and is checked
with lstat to be a real directory owned by the process user and closed
to others before the snapshot is written.
2026-10-19 18:46:43 +02:00
Marc-André Bombeck 8c745fca8d fix(sleep): treat a short night as the night, only a daytime nap leaves the clock
Keeping a nap-only day off the sleep rhythm used three hours asleep as
the line, which also took every shorter night off the clock: someone
who sleeps two and a half hours a night (insomnia, a newborn, split
sleep) lost consistency, timing, chronotype and the health score's
regularity altogether. A session now leaves the clock only when it is
short AND centred in the daytime, outside 20:00 to 10:00 local time.
A short night keeps its place, a long daytime sleep after a night
shift stays a main sleep, and the withheld-timing note now reads that
the latest day had only a nap.

Refs #1204
2026-10-19 18:30:06 +02:00
Marc-André Bombeck 9d6bfe2624 Merge branch 'fix/v1436-gh' into release/v1.43.6 2026-10-19 17:26:51 +02:00
Marc-André Bombeck f6b09f07cd feat(integrations): say where an imported Google Health history ends
The two status routes publish backfillEnded: each data type whose
backfill stopped short of the start of its history, with the instant it
reaches back to and whether the horizon or a walk that stopped moving
ended it. The integration card lists them in place of an import that
would otherwise read as still loading, in all seven locales.

Refs #1220
2026-10-19 17:25:57 +02:00
Marc-André Bombeck 72c259d85c fix(google-health): bound the backfill by progress and a horizon, not pages
A resumable collection counted the pages it had written across every
backfill attempt and stopped at its ceiling (10,000 for heart rate). A
watch that records heart rate every few seconds reaches that count two to
three years back, and since it never reset, every later slice stopped at
the same place, failed as stuck, and the backfill was never stamped
complete.

A walk is now stopped only when it stops moving: 20 pages in a row that
reach no further back than the pages before them end it. A history
backfill reads back at most GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS (20 when
unset). A data type that ends either way counts as finished, and where and
why it ended is kept in backfillProgress past the completion stamp. An
oldest-first list is read whole, as before. The slice chain's ceiling is
derived from sixty days of back-to-back slices instead of the old page
count. An account left at the old ceiling continues from its edge with
the next slice.

Refs #1220
2026-10-19 17:25:48 +02:00
Marc-André Bombeck 0e6e50fc7b Merge branch 'fix/v1436-ui' into release/v1.43.6 2026-10-19 17:04:31 +02:00
Marc-André Bombeck 362afe2333 Merge branch 'fix/v1436-heap' into release/v1.43.6 2026-10-19 17:04:30 +02:00
Marc-André Bombeck 09c93cd547 Merge branch 'fix/v1436-oura' into release/v1.43.6 2026-10-19 17:04:29 +02:00
Marc-André Bombeck cfd6219981 fix(measurements): page day sums and sleep nights in SQL
The day-grouped list read the newest `limit` raw rows (the web asked
for 5000), folded them into days and returned every day at once: one
page of thousands of rows, and every day older than the 5000th raw row
missing without a word. The sleep list did the same at a year.

Both now pick the page in Postgres. The day mode selects the page's
local days with the distinct-day total from `COUNT(*) OVER ()` in the
same query, then folds only those days into per-source groups in SQL
and lets the source ladder choose among the groups, so no raw row
reaches the process. The sleep mode cuts sessions with the same gap
rule as the reconstruction, pages wake days the same way and reads only
the page's stage rows; the 365-day cut and `meta.windowFrom` are gone.
A sleep page holds at most 1000 nights, echoed in `meta.limit`.

The web list pages both modes at 25 like the per-sample list. The
`partial` day marker is dropped from the contract; no day is cut any
more.

Refs #1245
2026-10-19 16:43:39 +02:00
Marc-André Bombeck 16024397c8 feat(observability): log heap samples and a one-time pressure report
runtime.heap.sampled records heap, old space, RSS, external memory and
the work in flight every five minutes, every minute at or above 70 % of
the heap limit. At 85 % runtime.heap.pressure lists the oldest requests
and jobs in flight and the routes most of them share, once, re-armed
only after the heap falls below 70 % and thirty minutes pass. A
heap-limit crash leaves no other record of what the process was doing.

HEALTHLOG_HEAP_SNAPSHOT=1 adds one heap snapshot at that moment into
HEALTHLOG_DIAG_DIR (0700 directory, 0600 file), off by default.
docs/ops/heap-diagnostics.md warns that a snapshot holds decrypted
health data and keys and says how to delete it.
2026-10-19 16:00:53 +02:00
Marc-André Bombeck 5923dac8aa feat(observability): keep a bounded register of requests and jobs in flight
Every HTTP request the server accepts is registered through Node's
http.server.request.start diagnostics channel and removed when its
response closes, finished or aborted, so pages, RSC payloads and server
actions are covered alongside API routes. Jobs register around their
handler in observeJob. The register holds strings only, cuts the query
string on entry, caps at 2000 entries and counts what it refuses; route
labels are scrubbed and collapsed only when a report is written.
2026-10-19 16:00:45 +02:00
Marc-André Bombeck d38ceb4beb fix(sleep): say why consistency or timing has no value
A rhythm sub-score below three nights with a main sleep was dropped from
the blend and its row read "no data", although the nights were there;
since a nap-only day no longer has a place on the clock, a ring worn
for only a few nights reaches that state more often. Each withheld
consistency or timing sub-score now carries the reason
(`few_nights`, `no_main_sleep`) and the score value carries how many
nights the rhythm is built from, so the row reads "not enough nights
yet (2 of 3)" or "latest night had no main sleep" instead. Neither is
ever shown as 0.

Refs #1204
2026-10-19 15:15:46 +02:00
Marc-André Bombeck 7b17ec819a chore: drop personal names from comments and test fixtures
Comments referred to the maintainer by name, and a test fixture carried a
full real name. Comments describe the behaviour; fixtures use placeholders.
2026-10-19 14:57:00 +02:00
Marc-André Bombeck 146fcac079 chore: write NUL as an escape instead of a raw byte
Raw NUL bytes in source files make grep and ripgrep treat the file as
binary and skip it, so sweeps silently miss it. The affected string and
regex literals use the escape now; behaviour is unchanged.
2026-10-19 14:57:00 +02:00