The manual-era boundary anchors on the medication's creation instead of
the current moment, so a backfilled era can only describe time before
the app was tracking.
The schedule tab shows its eras as a collapsible timeline — current
plan since its date, predecessors with their times and span — and a
past era can be added manually for old history, with validation against
overlaps and the live plan. Invite creation chooses one to fifty uses
and the redemption list scrolls.
Open follow-up questions now run as a guided in-chat sequence — a quiet
card offers to start, the coach asks one question at a time with
visible progress, answers can be adopted into the matching self-context
field, skipping keeps a question pending and a closing summary says
what was taken over. Proactive nudges learn three new deterministic
signals — weight drifting from its target band, a run of short nights,
an active user falling silent — address the focus the user asked to
watch, and gain per-group toggles and a frequency preference.
The self-context adopt runs read-modify-write inside one transaction,
the admin shell renders nothing until the role is confirmed, and the
json-body helper merges Headers instances correctly. The compose image
reference accepts a pinned digest through an env var, and a promote
script resolves the published digest, verifies its signature and hands
the operator the exact pinning call — the documented path away from a
moving latest tag.
The measurements page consumed its add parameter during render, which
crashed server rendering now that every route renders dynamically with
real search params. Compliance day buckets anchored to the current
wall-clock minus twelve hours, so before local noon the current day was
missing from every bucket series — they anchor on local noon now. The
wizard spec accepts the localized start date its summary deliberately
shows, and the generated contract picks up the version bump.
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
An intake can carry the dose that was actually taken — editable in the
quick-add and log dialogs, shown as a quiet deviation line in the
history. The measurements and mood lists refresh by pull on touch, the
insight sub-pages gain the same customize entry as the overview, and an
answered coach follow-up offers to adopt the answer into the matching
self-context field.
Published manifests are now cosign-signed keyless with a documented
digest-pinning path, a lefthook pre-commit runs gitleaks over staged
changes, responses opt into a credentialless embedder policy, the
deploy webhook can require a fresh timestamp, and a monthly drill
decrypts and parses the newest offhost backup so a broken key or format
surfaces before an emergency does.
A dose past its slot time but still inside its catch-up window now stays
on the medication card as overdue and takeable instead of yielding to
the next occurrence — the next-due field can therefore carry a past open
slot, flagged with nextDueOverdue. Y-axis padding no longer dips below
zero for series that cannot be negative, so a steps chart stops
suggesting minus nine hundred steps.
Editing a schedule's times no longer rewrites the past. Each material
change archives the outgoing cadence as a dated revision, and every
band consumer — history, compliance, write attribution, the auto-miss
cutoff — mints past periods from the schedule that was valid at the
time, so a dose era taken at its then-correct times stays on time
forever. The repair script can infer historical eras from consistent
old anchors behind an explicit flag. The welcome tour scrolls its
target into view, flips above it when space below runs out and falls
back to a centred sheet on any viewport, so no step can strand its
buttons below the fold. The avatar menu drops the about entry — that
lives in settings.
The composer starts as a single line with send beside it and grows only
when the text does; the shift-enter hint and the inline disclaimer are
gone. A typing indicator bubbles before the first token. The context
rail hides by default behind a toggle and stops scrolling sideways,
conversations open in the full view instead of a broken side drawer,
and the coach preferences move into Settings where the rest of the
configuration lives — context toggles and the exclusion list now derive
from one source so they can no longer contradict each other. A floating
bubble appears only while a coach-initiated nudge is unread. Clearly
stated allergies and conditions are remembered immediately through a
deterministic extractor instead of waiting for the twenty-turn
summary pass.
The route-level loading state now renders the same structured tile and
chart silhouettes the page uses, so the first paint no longer flashes a
grid of bare cards before the skeletons arrive. The chart reveal cap
drops to 1.2 seconds, the QR library loads only when an invite is
minted, and list filtering moves onto a shared pill-rail filter bar —
active filters read as removable chips, date ranges live in a popover,
and the measurements and mood lists speak it first.
The detail page gains a dedicated API tab, the full-edit button becomes
a lifecycle row that opens the editor, the danger zone drops its red
wash in favour of the shared card chrome, and the card menu's edit
entry leads to the detail page it describes. The inventory tab finally
records stock — packs and pens with optional expiry, plus corrections —
instead of pointing at an API. The card's next-dose line reads from the
canonical band model instead of a stale schedule window, which also
showed old dose times after a schedule change; the repair script learns
to reconcile drifted windows and tombstone pending rows parked on
abandoned anchors.
Every standalone intake anchors its slot on the take time since
v1.15.19, so guarding the nightly dedup on that shape excluded ordinary
drift duplicates from collapsing — the original point of the sweep.
Releasing a pinned dose now persists the user's choice on the row, the
dedup and the repair script key their exclusion on that flag, the read
model reports a released row as deliberately off-schedule instead of
re-snapping it within the anchor epsilon, and the attributed badge only
shows on rows actually bound to a slot. A few fixture names and stray
comments also drop personal references.
A deliberately detached ad-hoc intake could be folded back into its slot
cluster by the nightly dedup, and a pinned row could lose its slot to a
sibling on the sync-version tiebreak — pins now outrank every other row
and detached rows never join a cluster, mirrored into the operator
repair script. Pinning onto an already-served slot no longer overwrites
the existing record silently; the API refuses with a clear error while
an identical re-post stays idempotent. The idempotency cache also
refuses to store invite-token-shaped responses.
Invites move into their own admin section with a real ledger: status
badges, expiry, uses, a redemption history of who joined when (new
redemption table, revocation is now soft so history survives), and an
honest one-time link/QR moment. The assistant self-context grows
structured fields for conditions, allergies and focus — encrypted like
the free text — merges age and gender from the profile into one fenced
prompt block, and asks up to three targeted follow-up questions that
surface as tappable chips above the coach composer. A fourth nudge
trigger gently suggests a check-up when the self-context is stale and
the coach is actually in use.
Users can now tell the assistant about themselves in an encrypted
free-text profile that feeds the coach and briefing prompts as clearly
fenced context, with the coach allowed one targeted follow-up question
when the picture is incomplete. Operators on closed registrations can
mint expiring invite links with a QR code from the admin area; a valid
invite opens registration exactly as many times as it allows and records
who redeemed it. A nightly pass watches three deterministic signals —
low adherence, blood pressure above its target band, a falling recovery
trend — and sends at most one coach nudge per week through the existing
notification channels, gated behind the same opt-outs as every other
assistant surface.
An off-schedule row now tells the user when the dose had been due, and
its row menu offers to attribute it to that slot when the slot is still
unserved. A pinned intake is a deliberate decision with a visible badge,
it always counts as taken late — never on time — and the pin can be
released again. The read model binds pinned rows by their slot anchor so
a pin outside the catch-up window survives reloads, and the suggestion
respects the schedule's own windows instead of a hard-coded tolerance.
The clock format followed the UI language with no way out — an English
interface forced AM/PM everywhere. A new profile preference (automatic,
24-hour, 12-hour) feeds every formatter through the shared locale
context, persists on the user row and rides the profile endpoints. The
insight pill list in settings gains the same eye toggle the overview
tiles have, so ordering and visibility live in one place; the page-side
'manage detail pages' block folds into a link there. Includes the
locale bundles for both features and the regenerated API contract.
The auto-miss cron stamped every pending row missed a flat day after
its slot even though weekly schedules stay takeable for days, touched
tombstones because it never filtered deleted rows, and a verdict stuck
even after the dose was taken. The cron now derives its cutoff from the
schedule's window reach, skips deleted rows and bumps the sync counter,
and a take clears the auto-miss flag. Editing a schedule tombstones the
open pending rows of the old anchors and reconciles a stale window onto
the new times. The bulk write path attributes takes through the same
band engine as the single-intake routes and accepts forceSlotInstant
per entry. Configured dose windows now reach every compliance call site
through one shared schedule select, and the card window pill takes a
timezone, treats a degenerate window as a point and counts dose times
instead of schedule rows.
The medication list fired one compliance request per card every 30
seconds, and each request scanned the medication's full intake history
and expanded the recurrence calendar five to six times. The endpoint now
mints bands once per request, serves the sub-windows from that single
ledger, bounds the event read to a year of selected columns and sits
behind a write-invalidated 15-minute server cache; the cards poll at
five minutes. The dashboard drops two redundant checklist queries by
reading counts from the snapshot, the BMI chart derives from the weight
series instead of refetching it, the detail page no longer serialises
behind the auth probe and seeds its query from the list row. Expensive
analytics reads gain a shared per-user rate limit, and the service
worker stops serving cross-version shells, enables navigation preload
and trims caches in one pass.
Raising the middleware body cap to 512 MB for archive imports removed
the implicit ceiling that protected JSON routes, so any bulk endpoint
would buffer an arbitrarily large body before validation ran. Every
JSON body read under /api now carries an explicit byte cap sized to its
legitimate payload (64 KB for single-record routes up to 16 MB for the
measurement import), webhooks cap before signature work, and oversized
bodies return 413.
The project licence changes from AGPL-3.0 to the PolyForm Noncommercial
License 1.0.0 for this and future versions: free to use, modify and
self-host for noncommercial purposes; commercial use needs a separate
arrangement. Releases up to and including v1.15.18 were published under
AGPL-3.0 and remain available under that licence. LICENSE carries the
verbatim PolyForm text, and the SPDX identifier is updated in
package.json, the version endpoint, the OpenAPI info block, the image
labels and the README.
A take whose slot the legacy resolver rejected fell back to a standalone
insert that kept the client's scheduledFor, parking a second live row on
an anchor the reminder projector had already minted — the partial unique
index permits that because it spans source. Both intake write routes now
probe the slot source-agnostically and converge onto the existing row
before inserting, and a standalone insert anchors on the take time per
the ad-hoc contract. The compliance rollup counts distinct slots instead
of rows, which also corrects historical duplicates on the next recompute,
and the slot dedup pass runs nightly instead of only at worker boot.
An intake edit accepted any takenAt value, so a date typo could park a
dose a month away from its slot and poison the today feed, the rollup
and the rolling next-due anchor at once. The update schema now rejects
future timestamps (with clock-skew tolerance) and values older than five
years, the route rejects edits before the medication's start date, and
the edit dialog caps the picker at now, shows the scheduled slot and
warns when the new time sits more than 48 hours from it. The dialog's
note field is gone — it was never persisted and silently discarded
input.
Blood-pressure fixtures in the in-target integration suite were copied
from live readings; they are now synthetic with the same in/out split so
every assertion holds. Hard-coded operator hostnames move behind env vars
(DEPLOY_LOGS_URL, HEALTHLOG_PROD_URL), the codex client identifies via
the public repository URL, the OpenAPI servers entry points at a neutral
example host, and remaining workflow, docs and comment references to the
operator's instance or working notes are generalised. The compose
whitelist also gains DEPLOY_WEBHOOK_SECRET, which was missing and made
the deploy webhook unusable on compose installs.
These tests passed in Europe/Berlin but failed under UTC (or against the
shipped engine) because they leant on the host clock:
- intake route test: the mocked user had no timezone, so slot resolution
fell back to the host TZ and the 07:00 schedule slot only matched in
Berlin. Pin the user to Europe/Berlin.
- health-score: 30 daily takes seeded at an unaligned now-i*DAY shifted
their Berlin hour with the runner TZ, dropping them out of the
08:00-10:00 on-time band. Anchor each take to a fixed 07:30 UTC.
- sleep-stages: the night sat on the trailing-30-day window edge for a
fixed calendar instant. Anchor it to now-5d.
- weekly-compliance: not a timezone issue. The dose-window engine treats
a weekly dose inside the 4-day overdue tail as still-takeable, not
missed, so skipping the most-recent Monday no longer lowers the rate.
Skip an older, settled-miss Monday instead; the gap assertion stands.
Accept an optional doseWindows array on the schedule create/update routes:
each entry pins an explicit on-time range [start,end] to one of the
schedule's times-of-day, defaulting to the symmetric grace band when
absent. Reject malformed ranges (bad HH:mm, a timeOfDay outside the
schedule, an inverted span) with a 422 so the persisted window can never
feed the band minter a degenerate range.
Regenerates the OpenAPI contract for the additive field (iOS-safe).
Thread the persisted `doseWindows` through the shared band minter so the
on-time band for a dose time uses its explicit `[start, end]` range
(minted DST-correctly on the slot's local day) instead of the symmetric
±1h default; the late tail stays cadence-derived. A `normaliseDoseWindows`
helper coerces the stored JSON into a clean entry list, dropping anything
malformed so the read/write paths never defend against an arbitrary shape.
Every band consumer reads the same field: the dose-history endpoint, the
compliance %, the cadence timeline (card last/next dose), and the
write/edit slot attribution all resolve through one minter, so the
history view and the rate can never disagree. The write/edit and
health-score schedule selects now pull the column.
Convert the bulk-delete hard deleteMany to a soft-delete updateMany that sets
deletedAt and bumps syncVersion, matching the single-event DELETE handler so a
multi-select removal surfaces each row as a tombstone on the sync delta feed
to clients offline at delete time. The where clause now scopes deletedAt:null
so a re-post of already-removed ids counts zero.
GET /api/medications/[id]/dose-history?from=&to= returns the full per-slot
ledger over the window: every expected slot with a status (taken on-time / late
/ skipped / missed / upcoming) plus every off-schedule intake tagged ad-hoc,
built from the same band minter the compliance % consumes so the history view
and the rate can never disagree. Ownership-scoped, rate-limited, reads only
non-tombstoned rows, instants serialised as ISO strings. Additive — iOS-safe.
This is what the medication Verlauf tab renders.
Editing a takenAt or a status toggle now re-runs window-band attribution and
sets scheduledFor to the matched slot (the take's own time on a miss), instead
of leaving the slot stale. A slot move tombstones the source row and routes the
corrected dose through the shared canonical-slot upsert so the write is
P2002-safe rather than bare-updating into an occupied slot. Both edit surfaces
(the per-event PUT and the status-toggle POST) accept forceSlotInstant to pin
an off-window edit onto a named real slot, rejecting a non-slot with 422.
The per-medication intake POST resolves a taken dose's slot through the band
attributor instead of the ±6h snap: a take inside a slot's configurable dose
window snaps to that slot (canonical upsert), a take outside every window
records as a standalone ad-hoc row, and a PRN medication always inserts
standalone. A skip still binds to its slot by the canonical scheduledFor snap.
Add an optional forceSlotInstant to the intake body so the UI's late-take
"diesem Slot zuordnen?" nudge can pin an off-window take onto a chosen slot;
the server validates the instant is a real slot of the medication on its day
and rejects a non-slot with 422. The dose-safety future-slot guard stays.
The medication compliance percentage and the history view drew their
per-dose attribution from four different matchers — the ±12h pairDoses
proximity used by calculateCompliance, the deriveDoseStatus bands (open
dose only), the 3h+120min classifyIntakeTiming heatmap heuristic, and a
raw takenAt-IS-NOT-NULL per-day rollup. They could and did disagree: a
dose could read "taken late" in the rate while the history called it an
off-schedule ad-hoc take.
Route the rate through the shared cadence-aware band minter +
reconstructDoseHistory so the percentage is a tally over the SAME ledger
the history view renders. The same intake can no longer be taken in the %
and ad-hoc in the ledger.
- calculateCompliance: when a medication context is supplied, the
count fields (taken / skipped / missed / rate) come from
tallyComplianceFromLedger. Numerator = taken_on_time + taken_late (a
late dose still counts as taken — the taking-vs-timing split is kept
available); denominator = taken + missed; skipped, ad-hoc, upcoming and
whole PRN groups are excluded; rate capped at 100. The streak still
walks the cadence timeline. Context-less pure-math callers keep the
legacy timeline tally byte-stable.
- complianceChips + missedDoses: adherence rate + missed count come from
the same ledger when an engine context is threaded.
- /api/medications/[id]/compliance heatmap: the per-day timing split and
missed marks are bucketed from the one ledger instead of the per-day
classifyIntakeTiming + expectedSlotCountForDay walk, so the cells agree
with the headline rate.
- medication_compliance_rollups stays a raw cross-medication daily
coverage count, documented as distinct from the band-attributed % (the
band minter is per-cadence and cannot run inside a single SQL tally).
Downstream readers (health score, insight compliance status) keep calling
calculateCompliance; their numbers shift to the honest band-attributed
values. Some currently-counted off-time takes (e.g. an 11:29 take of a
07:00 dose) now read ad-hoc with the slot missed rather than taken-late —
the configurable per-dose window is the lever to widen the on-time band.
Tests: the irregular twice-daily, single-time-late heatmap, and legacy
parity fixtures are updated with rationale for the intended number
changes; new tally tests pin the taking/timing split, the skip / ad-hoc /
upcoming / PRN exclusions, auto-missed=missed, the 100% cap, rolling
retrospective families, and the cross-consistency invariant.
Add `POST /api/measurements/bulk-delete` and `POST /api/mood-entries/bulk-delete`,
mirroring the bulk-insert conventions (`withIdempotency`, per-user rate
limit, response envelope). Body is `{ ids: string[] }`, capped at 200.
Soft-delete only, ownership-scoped: a single `updateMany` pins `userId` +
`deletedAt: null` and sets `deletedAt` + bumps `syncVersion`, matching the
single-DELETE iOS tombstone contract. A forged or foreign id is a silent
no-op — the returned `deleted` count reflects owned rows only, never a 404
existence leak.
Side effects fan out efficiently: measurements collapse the deleted rows to
the unique `(type, day)` set before recomputing rollups (one recompute per
type-day, not per row) and invalidate the affected status-insight caches;
mood collapses to the unique day set for its rollup recompute. Both bust the
per-user read caches.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a source filter to both management lists. Mood gains an optional
`source` param (validated against the mood source set); measurements gain
`sourceEq` (validated against MeasurementSource) — the `source` key on the
measurements list is the rollup-tier opt-in, so the filter rides a distinct
param. Both thread into the list `where` alongside the pinned `userId` +
`deletedAt: null`.
Migration 0136 adds two additive indexes — `(userId, source, measuredAt)`
on measurements and `(userId, source, moodLoggedAt)` on mood — so a source
filter + recency sort is an index range scan rather than a userId scan plus
in-memory sort at 100-200k rows per user. Pure `CREATE INDEX IF NOT EXISTS`,
no backfill.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every surface that applied a resting-pulse target judged the raw PULSE
series against a resting band. Apple Health maps all heart-rate samples
— including workout HR — onto PULSE, and the resting figure onto a
separate RESTING_HEART_RATE type. A single workout emits hundreds of
expected-high samples, so the resting-target evaluation read a large
share "outside target" and tanked the in-target % / consistency even
when the clean resting series averaged a healthy figure.
Judge the resting band against a resting series. A shared helper prefers
RESTING_HEART_RATE; when the user has none it derives a proxy from the
low percentile of each day's PULSE samples, so a dense workout burst
sitting in the upper tail cannot drag the resting estimate up. Applied
to the resting-pulse tile + its consistency strip, the pulse in-target %
in the assessment snapshot, and the dashboard / sub-page classification.
Raw heart rate, where it is merely charted, no longer carries the
resting-band overlay. The doctor report is unchanged — it reports raw
heart-rate stats factually, with no resting-target judgment to mislead.
The BP pillar value equalled the binary all-time in-target rate: the
share of paired readings where both systolic and diastolic sat at or
below the age-band ceiling. A borderline stage-1 profile (diastolic a
few mmHg over the ceiling on most readings) collapsed the pillar to the
teens even though the reading is "a bit high", not catastrophic — and
improvement never surfaced because the rate is all-time and binary.
Replace the binary rate AS THE SCORE with a smooth clinical-proximity
grade. A recency-weighted representative reading (45-day half-life) is
mapped per axis onto a 0-100 curve anchored on the same age-band ceiling
the rest of the app uses — optimal ≈ 100, at the ceiling ≈ 85, then
piecewise-linear down through the over-target bands; hypotension is
penalised symmetrically below the clinical floors. The pillar takes the
worse of the two axes, so an elevated diastolic counts without
binary-zeroing the result. A 134/87 profile against a 129/79 ceiling now
reads in the mid-50s.
The in-target rate stays available as a secondary "X% of readings in
range" stat. The healthScore.components.bp shape is unchanged — only the
value moves.
- Layout PUT accepts version 1 as well as 2 (live iOS client still PUTs
version 1); the serializer normalises to the canonical v2 blob, so a v1
body never 422s. Mirrored in the OpenAPI schema + description; yaml regen.
- Add the Weight tile to the inline tile editor so it can be hidden /
reordered like every other Vitals grid tile.
- Replace the non-transitive Vitals tile-drag comparator with a pure,
total-order rebuild helper (reorder the Vitals subset, substitute it back
into the full list in original-order slots, densify).
- Gated-off section rows keep a disabled eye toggle in the same position
with the gate hint as a caption below the title, so the row never jumps.
- Cap the disclosed Vitals tile list height (max-h-[60vh] scroll,
overscroll-contain) so a tile drag does not fight the page scroll.
- Gate edit mode until the layout GET settles so a save can never flush
defaults over a real saved layout.
- Drop the duplicate top-right "Done" affordance: hide the customize toggle
while editing; the in-card Done / Reset own save and reset.
- Restore focus on enter (edit-card heading) and on close (customize toggle).
Tests: route regression for a version-1 PUT persisting a resolved v2 layout;
unit coverage for the new Vitals rebuild helper (order, non-Vitals stability,
per-tile visibility, no mutation).
Add an additive, optional `sections` array to the persisted insights
layout alongside the existing per-metric `tiles`, with the same
merge/dedupe/dense-order semantics. v1 blobs resolve forward to a valid
v2 layout with every section default-visible; the iOS `tiles` contract is
unchanged and a PUT may omit either array (defaults fill in).
The card's next dose did not move on after a dose was logged: the server's
next-due and the open-cycle descriptor both picked the next slot purely by
clock time, ignoring whether that slot was already taken, skipped, or missed.
For a twice-daily med whose remaining slots today were all logged, the card
kept surfacing a present/past resolved slot instead of advancing to the next
genuinely-open one.
Skip resolved slots in both paths: computeNextDueAt and buildCurrentCycle now
walk occurrences forward and return the first slot no intake has resolved
(taken / skipped / auto-missed), so a med whose slots today are all logged
advances to tomorrow's first open slot. The list and detail routes read the
acted-on slots near now and thread them in; both walks are bounded so a
logged-ahead history cannot spin. The card's compliance/next-dose queries
already ride the ["medications"] invalidation prefix, so logging a dose
refreshes the card promptly once the server value advances.
The dashboard compliance tile set scheduled = COUNT(*) of logged intake
rows, so its rate (taken / scheduled) sat at ~100% across every window
regardless of real adherence — every logged row was both numerator and
denominator, and the 7/30/90 windows never diverged.
Anchor scheduled to the canonical recurrence engine's expected-dose count
per day, summed across active medications, so days the schedule expected a
dose the user missed pull the rate down and the windows genuinely diverge
with partial adherence. taken is capped per day at the expected count so a
duplicate log can't push a day above 100%. The response shape stays
{date, scheduled, taken} for the existing chart consumer.
Regenerate the OpenAPI contract for the new expected/missed count fields.
Thread the auto_missed flag through the cadence timeline so a forgotten
dose pairs to a missed slot (against the rate) rather than a neutral skip;
a deliberate user skip stays excluded. This unifies the skip semantics
across every compliance surface: user skip = excluded, forgotten = missed.
Add a per-dose grace/miss window model the card will render:
deriveDoseStatus derives on_time_window / overdue / missed / taken_on_time
/ taken_late / skipped / upcoming from the target instant, cadence family,
and now. Daily/intraday uses minute-scale windows (on-time +/-60min,
overdue to +240min, capped at the next dose); weekly/rolling uses the
clinical 4-day rule (on-time +/-1 day, overdue to +4 days). Defaults are
centralised named constants for later per-medication configurability.
Surface taken / expected / missed counts on each compliance-display row so
the card can show the trustworthy 'taken / expected . rate%' triple, and
pin a single now through the per-medication compliance route so the two
windows can't straddle a day boundary on a slow request.
- Phase education card no longer carries its own log-today button (the
top-level log entry covers it).
- Predicted-period calendar days render as a soft dashed fill instead of an
underline, so they read as period rather than an unfinished dash.
- The derived batch route now attaches the deterministic per-score assessment
for the five scores so the dashboard grid (and iOS) paints the explanation
without a second per-id round-trip; guarded so it never fails the grid.
- Add CYCLE_FERTILE_SOON to the cycle-purge push_attempts eventType list so a
data reset leaves no dated reproductive-intent delivery trace.
- Rate-limit the custom-symptom mutations (POST create, PATCH, DELETE) under a
shared cycle:symptom:custom:<userId> bucket (30/min), mirroring the insights
route, so a session can't flood the encrypted-label catalogue.
- Re-run the active-count cap inside PATCH when a symptom reactivates
(hidden → active); a hidden row doesn't count toward the cap, so without the
recheck a user could exceed it by hiding then re-enabling rows.
- Gate the log-day "limit reached" copy on the cap errorCode rather than any
create failure, so a transient/validation error no longer masquerades as the
cap being hit; thread the returned key so a new custom symptom auto-selects.
- Move the history-chart bar fill alphas into named phase-tokens
(HISTORY_REST_OPACITY / HISTORY_PERIOD_OPACITY) so bar intensity lives in one
token source alongside FLOW_OPACITY.
- Strengthen the confirmed-ovulation calendar marker (ring 1.5px → 2px, fill
alpha 0.16 → 0.22) so it stays distinct from the predicted dot on small light
screens; keeps its data-attr + aria naming.
- Raise the phase-education chip border from /10 to /15 for legible contrast on
the tinted tile.
Mirror the v1.13 custom-mood-tag CRUD for cycle symptoms. A user can mint
their own symptoms beyond the 15 seeded ones, picked from a label + icon
popover via a dashed ghost-chip in the log-day sheet's symptom grid.
- POST/GET /api/cycle/symptoms/custom and PATCH/DELETE on /[key]: mint a
custom:<uuid> key, encrypt the free-text label at rest (AES-256-GCM),
per-user cap, owner-scoped, gated behind requireCycleEnabled. Soft-hide by
default, hard-purge on ?purge=true.
- The day-log write already resolves a symptom key against the catalogue plus
the caller's own rows, so a custom: key links by construction.
- The log-day sheet fetches the user's customs and merges them under a new
custom category in the chip grid; the ghost-chip + popover reuse the chip
styling and the Lucide icon set.
- Seed the global custom symptom category (migration 0134).
- The purge route (DELETE /api/cycle/all) now drops the user's custom symptom
rows too, so no intent-revealing label survives a purge.
- The label is never logged: only the icon + counts reach annotate/auditLog.
- i18n: cycle.symptom.custom.* across all six locales.
The comprehensive endpoint paired sys+dia on the 5-minute window only, so
readings whose sys/dia timestamps drift past 5 minutes on Withings /
Apple-Health imports were dropped, skewing the in-target share the Home
Health Score renders. Route it through computeBpInTargetPct, which adds the
same-Berlin-day pairing fallback and shares one definition with the
dashboard tile, insight cards, AI snapshot and targets endpoint. Band
(ESH-2023 ceiling + 90/50 hypotension floor) and the 90-day window are
unchanged.
A taken intake whose write named (or snapped near) a slot still in the
future was attributed to that slot and rendered as taken before its time —
a late morning dose collapsing onto the evening slot of a twice-daily med.
The slot resolver now takes an isTakenWrite flag and a now reference: a
taken write skips any candidate slot more than a small clock-skew grace
into the future, so it snaps to a current/past slot or keeps its own row.
Pending sync echoes are unaffected and still map to future slots.
The Web Push subscribe route validated the endpoint with a bare URL check
while the hardened schema (https + isPublicUrl) sat orphaned, so an
authenticated user could register an endpoint pointing at an internal host
and the dispatcher — which dials it through web-push's own fetch, bypassing
safeFetch — would reach it. Route the subscribe input through the hardened
schema and re-check isPublicUrl at egress time in the sender to cover any
row stored before the fix.
Also cap GET /api/cycle/insights with a per-user rate limit; it runs the
365-day FDR phase contrast, the heaviest read in the cycle vertical.
Add the cycle-native counterpart to the phase×vitals crosstab: for each
logged symptom, where in the cycle it clusters. A pure aggregator tallies
each symptom's occurrences across the phase-labelled days (the same phase
map the calendar uses), surfaces only symptoms logged on at least three
phased days, and reports per-phase counts + the dominant phase + its
share. The insights tab renders it as a proportional phase-hued bar with
the dominant phase named in words (never colour-only), observational and
never causal.
Also correct the phase-crosstab OpenAPI row enum, which had drifted
behind the runtime (mood + glucose channels were missing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>