A medication arrived as up to four kinds of item (its own span, its
courses, its dose changes, its pauses) and was drawn item by item, so a
medication with a course read as two bars on two rows. Every item of one
medication now carries the medication's id as `group`, and the lane draws
one row per group: the courses are the stretches it was taken, each dose
change cuts them into pieces labelled with their dose, and a pause is a
gap in the same bar. The selection bar and the phone chronicle list one
entry per medication with the dose in the text.
The value series cap of six is gone. The response carries
`availableSeries`, every series the record can show, and the picker is
one compact "Values (n)" button whose menu lists them. Past the fifth
line the five data colours repeat; every line is named at its left.
Choosing a range opens a popover anchored to the "Range" segment (a sheet
on a phone) with "Apply", so the chart no longer moves; the URL keeps the
range as before. The selection bar sits on the card under a hairline
instead of a grey box, the legend explains the dashed stroke and the
hollow point, and no timeline text joins its parts with a middle dot or
calls an open span "ongoing".
In the phone's bottom sheet the date sits halfway down the screen, and a
month of finger-sized cells below it ran off the bottom, Today button and
all. There the calendar now opens as a bottom sheet of its own and hands
focus back to the date when it closes. The docked column and the side
sheet keep the popover.
The conversations panel's fold and unfold buttons carry a title instead
of a tooltip: focus lands on them when the panel opens or folds, and a
tooltip opened by that focus took the next Escape for itself.
Sleep is stored one row per stage, and the timeline read it like any
other type: the live path took the mean of a day's rows and the long
path the rolled-up month of them, so IN_BED, AWAKE and every CORE
fragment counted as a night, and a quarter of seven-hour nights read as
under two hours.
The sleep series now reconstructs nights the way the sleep page and the
day view do (CORE + DEEP + REM of the night's winning writer, keyed by
its wake day) and averages the bucket's nights, on every window length;
the rollups are not read for it. The stage rows are read a year at a
time with a day's margin on either side, so no read holds a decade of
rows and no night is cut where one read ends. A long window is cached
per record like the other long series.
The day view gains a scores section below the values: the health score as
recorded that day, the readiness the nightly job persisted for the night
that ended that morning, a device's own recovery, the sleep score of that
night and strain (computed, or the device's day strain on its 0 to 21
scale). Each tile carries the person's usual range over the 30 days before
and opens the score's own page; a score the day does not hold has no tile,
and a day without any has no section.
Every value comes from where its page reads its history: the stored health
score record, the persisted RECOVERY_SCORE and STRAIN_SCORE rows read on
the day they describe, the canonical recovery ladder, and the sleep score
computed over one bounded read of the 30 nights up to the day. The stored
score rows leave the values list of the day they are stamped on, so a
readiness no longer appears a day early among the readings.
Scores follow their own module (recovery, sleep; the health score is core)
and map to the whole record for sharing: a scoped grant sees them named as
not shared, a whole-record grant sees them. get_day sends them to the
Coach and the MCP endpoint by name and number, and the Coach exclusions
take out every score built on excluded data.
A running cycle now states its cycle day and phase ("Cycle day 12,
follicular phase"), computed the way the cycle calendar does. Only logged
cycles count, the next logged start closes a cycle, and an open cycle
stops counting past its typical length plus the grace window instead of
reading a day count nobody recorded.
New conversation leaves the conversations panel's header and floats in
the conversation itself: a round 56 px button with a plus, the floating
Coach button's face, named "New conversation". It sits in the composer's
row, so it always belongs to the chat column, never to the list or a
docked day beside it. Where the column is wide it takes the bottom right
corner beside the composer; where the composer fills the width (a phone,
the drawer, a narrow column) it rises above the composer's edge. The
drawer shows it once there is a conversation to leave, in place of the
plus in its header. An empty conversation shows nothing either way.
The docked conversations panel folds like the day: its header opens with
a fold control, and folded it keeps a 48 px edge whose button opens it
again (aria-expanded, focus moved between the two, Escape inside folds
it). The top bar's toggle remains for the sheet below 1280 px. On /coach
the day docks right of the panel. Below 1600 px only one of the two is
open: a day opening folds the list without touching the remembered
choice, and opening the list folds the day to its edge. From 1600 px both
stay open. Switching or starting a conversation keeps an open day in the
URL.
The empty conversation drops everything under the composer: the daily
seeded opener and the scope pill are gone, and with them the only reader
of GET /api/insights/coach/seeded-question and of the
coach.ambientSuggestions preference. The route, its OpenAPI entry, the
preference and its Settings switch go too.
The generic webhook body carried title, message, eventType and priority
only. Discord refuses a body without content (400, code 50006 "Cannot
send an empty message") and a Slack incoming webhook one without text,
so the test button and every reminder failed against the targets the
settings card names.
The generic body now appends content (cut to Discord's 2000 characters)
and text, both the masked title and message on two lines, plus
allowed_mentions with an empty parse list so Discord resolves no
mentions. The four original fields keep their values and order for
Home Assistant and n8n rules. The Gotify body is unchanged. The card
names what works and shows a hint for the generic format.
The written date in the day's header is now the way to any other day. A
click (or Enter, or Space) opens a month calendar beside it: the day on
screen is selected, every day that holds anything carries a dot read from
GET /api/day/index for the month shown, nothing past today can be picked,
and a Today button sits under the grid. Picking a day swaps it in like a
step, without a new history entry; Escape closes only the calendar and
focus returns to the date. The header keeps its one line and gains no
icon; the arrows stay. The same popover serves the docked column and both
sheets, with 40 px cells for a finger.
On /coach the day now docks beside the conversation like everywhere else,
right of the conversations, instead of opening as a sheet. A neighbouring
panel can fold the docked day to its edge without moving focus
(yieldDay).
The day's count line reads "3 values, 2 entries".
Every bundle value that chained facts with " · " now reads as a phrase,
a comma list or a parenthesis, in all seven locales, and the em-dash
separators in the day, documents, correlation, sharing, environment,
vaccination and Apple Health import strings become sentences or clauses.
The day view's running list names the record first, with the dose in
the name and a life event's category as a chip, and puts a quiet day
count below: "Day 4, since 31 December" instead of a dot chain. Document
meta, location periods, vaccination rows and mood crosstab rows drop
their composed dots too.
A guard walks every bundle and fails on any " · " outside a reasoned
allowlist, and on an allowlist entry that no longer needs its place.
A key built from a template, t(`timeline.zoom.${zoom}`), is invisible to
the call-site coverage guard, so a code without its message would ship as
the raw key and the gate stays green. The timeline and the day view now
word every code family (lanes, zooms, legends, readiness rows, life event
categories, precisions and errors, document kinds, notables, cycle flow,
allergy severity, screener instruments and bands, sports, functional
impact) through maps typed over the code's own union, so a new code does
not compile without its key.
Three checks hold it: one resolves every map value in all seven bundles
and pins the band and sport maps to their sources, one forbids template
keys in both trees (the one existence-checked readiness lookup is named),
and one renders the whole timeline page at every zoom and every bucket in
two languages and fails on any message key in the visible text.
The service worker serves the locale catalog's boot script cache-first,
and its URL carried the package version. Every build of one release
therefore shared one URL: an instance updated within a release (a beta,
a redeploy) kept the catalog of its first build, and every message added
since read as its raw key ("timeline.zoom.range", "timeline.selection.week")
or fell back to English.
next.config.ts now hashes the message catalogs at build time and the
layout keys the script by that hash, so a changed catalog is a new URL.
The vault runs its months side by side by default now, so a month is
marked above its first document and no longer has a heading row. The
grid check reads the arrangement and the month marker instead of an h2.
The dashboard's blood pressure chart passed `yAxisUnit="Hg"` beside
`unit="mmHg"`, a shortening carried since the first release, so every
tick read "154 Hg". The override is gone; "mmHg" is four characters and
fits the axis gutter like "bpm" and "mg/dL" do.
A unit with a space of its own ("/ 5") is now joined with no-break
spaces as well, so Recharts cannot wrap it either. A guard fails on any
`yAxisUnit` that is a cut-down form of the `unit` beside it.
The timeline and the day view showed what the server sends as it was sent:
a life event's category as FAMILY, a visit's kind as PREVENTIVE, a
document's kind as DISCHARGE_LETTER, a trip and a cycle under their
sentinel labels, a lab day as a bare "5", a mood as SUPER_GUT, a
screener as PHQ9 with its band code. A pause read as "Ramipril" at its
start and "Ramipril ended" at its end.
Every surface that shows a timeline item (lane labels, hover titles,
the screen-reader table, the selection bar, the phone chronicle) now
reads it through one wording, item-words.ts: a code goes through the
bundle's existing labels, a lab day reads "5 lab values", and a code
the bundle does not know is left out rather than shown. The day view's
title and second line do the same per event kind (mood, screener and
band, sport, severity, visit kind or planned, dose number, intensity,
functional impact, document kind), and a medication that starts, ends,
pauses or resumes says so, in the day view and in the visit
preparation alike.
A pause reads "Ramipril paused" at its start and "Ramipril resumed ·
after 11 days" at its end, on the lanes, in the selection bar and in
the chronicle.
A guard renders a record with every item kind, every category and every
kind code on each surface, in two languages, and fails on any upper-case
code or sentinel in the visible text. The wire schema now says which
fields carry codes.
West of UTC the local midnight of the boundary month falls hours after the
month's UTC bucket start, so the first rolled-up month went missing and the
first quarter was averaged over two of its months. The rollup window is now
compared by UTC date at both ends.
The fold leftovers were told from a person's deletions by syncVersion = 1,
but raw samples are raised in place before a fold takes them (the collision
merge, a changed Health Connect value). The repair then recomputed windows
without those samples and could overwrite a correct mean, also in windows
one run had folded whole.
A tombstone of fold age now counts as a leftover when it carries
syncVersion = 1 or shares its deletion instant with such a tombstone of the
same day; anything else of that age is ambiguous and leaves its window as
stored. Only a window taken in two parts (fold runs at least an hour apart,
or a run and later live samples) is recomputed.
Once the repair has been through an account, the purge may remove class-A
leftovers inside the horizon, so the folds' re-fold of a stored day or hour
leaves it as stored, and the purge takes the fold lock per account. The
fold-lock transactions get an explicit timeout and maxWait, since the lock
wait counts against Prisma's five-second default.
- Place a day at the home only from homeSince on, through the same
resolver the nightly fetch uses; a home without homeSince places no day.
- End a run at the first request the feed refuses (429, 5xx, network)
with status error, no follow-up and no pg-boss retry.
- Fetch the weather of days without a row before their air quality, and
keep the weather rows when the air-quality request fails, so no paid
request is asked for twice.
- Create no row for a day the archive answer leaves out.
- Re-read the module, the account switch and the operator switch before
every range.
- Check the history's own ceilings in the budget's locked transaction
together with the charge, instead of reading usage first.
- Read the days with entries once per chain and hand them to the
follow-up in its payload.
geoLookup and geoProviderChosen on the public /api/version added one
more way to tell instances apart. They now live on /api/admin/status
(requireAdmin, cookie only) as geo { lookup, providerChosen,
providerHost }, which both admin status views read. offlineGeoEnabled
and geoProviderHost stay public as before.
The note for records whose findings predate the seasonally adjusted
engine compared against a fixed calendar date, so findings an instance
kept writing under the earlier engine until its deploy did not count.
The first discovery sync of the new engine now stamps
AppSettings.correlationSeasonalEngineSince before it writes; without a
stamp every stored discovery pattern counts as an earlier finding.
A restore from a portable export reset each pattern's createdAt to the
restore day and dropped the note for good. createdAt now rides every
export, and a file without it falls back to lastComputedAt.
A tab outlives a logout, another login and a switch into a managed
profile. A delete re-sent in another scope met a 404 that read as
"already gone" and dropped the entry, or tripped the record fence and
toasted a failure for a row the person could not see. Entries now carry
their owner, only the current owner's are re-sent, and a session end
clears the journal with the rest of the session state.
A bucket that holds a mean but no lane entry is not empty; the means
say what it holds. The bar opened on such a bucket by default and called
it empty beside its own values.
The past-days backfill placed every day without a row at the current home,
also before the home was set. Weather and air of a place the person was not
at would flow into correlations, the Coach and the day view as fact. A day
before homeSince is now filled only when a location period covers it; with
no homeSince stored the home still covers every day. Days without a place
are not counted in the progress.
Beside All, 12 months and 3 months the timeline takes a range of the
person's own choosing, from and to, through the app's date field. Each
change is clamped softly: never past today, never before the record's
first entry, never inverted, never longer than fifteen years. On a phone,
where the chronicle has no zoom control, a range button opens the same
fields with a way back to the whole record. A range with nothing in it
keeps its fields instead of the empty-record notice.
The zoom and the range live in the URL (zoom, from, to), so Back and a
bookmark return to them; a parameter that does not read falls back to
All. The API accepts zoom=range with both ends, at most fifteen years,
and picks the bucket by its length: quarters from two years, months from
120 days, weeks from 42 days and single days below, a new day bucket
whose dashed bridge spans at most three missing days. A range borrows
the grid and the keyboard steps of the fixed zoom nearest its length.
The selection bar heads a day bucket with that day.
Each value line takes the colour its type carries in the measurement
list (a --chart-N token); where two chosen lines would share one, or a
type has none, the later line in the menu's order takes the first free
data token, and a sixth line with none left falls back to the
foreground. The swatch beside the name, the line, the dashed bridge,
filled and hollow points and a ring on the selected bucket all inherit
one colour from their group. The menu, the selection bar and the phone
chronicle mark each value with the same dot. The colour map moves to an
icon-free module so the timeline does not pull the list's icons.
The selection bar is a cross-section of one bucket and now says which:
its head names the quarter, month or week in full, its entries are the
ones that touch that bucket, and each mean keeps its readings count.
Values go through the day view's formatter, so sleep reads as a
duration and the unit preference applies; the server sends canonical
units, which the old formatter took for display units. Before anyone
picks a day the bar opens on the newest bucket it has something for in
the requested window (documents, which it never lists, no longer count),
and a short line says how to pick another.
The round button floated over the list's bottom right and covered the
last conversation, which the list offset with a pb-20 reserve. It is now
the first control of the panel header, left of the title, in the docked
panel and the phone sheet alike: 28 px beside a fine pointer, the 44 px
floor on touch, with its accessible name and a tooltip. The reserve goes
with it, and the header packs its touch-sized buttons tighter so the
title keeps its word at 390 px.
A background job fetches air quality, pollen and UV for every earlier
local day with a reading, mood entry, dose, symptom or workout: after the
update (boot and nightly discovery), when the home is set and when air
quality is switched on. A day with a weather row gets its air quality; a
day without one gets both, at the covering location period or else at the
home, also before the home was set. Days the source does not reach (before
2013 in Europe, before August 2022 elsewhere) stay empty and are not
counted.
Pending days are grouped by coarse location and cut into ranges of up to
90 days, joining days at most 14 apart, newest first. A run holds itself to
300 of the account's 400 daily calls and half of each instance window,
stores its progress on the user (migration 0391) and sends a follow-up while
it makes progress; a budget stop waits for the next discovery. The
Location & weather card shows the progress.
The air-quality client now asks pollen and dust from 2021 and skips the
request outside Europe before CAMS global starts.
Since correlations are tested on seasonally adjusted residuals with an
effective sample size, an account with history sees far fewer
relationships, which reads as a defect without a word about it.
The correlations response now carries findingsBeforeSeasonalAdjustment:
true when the record kept a discovery pattern first stored before the
change, one indexed lookup on the pattern store. An account created since
can never qualify. The mood "What stands out" card shows a short note
for those records, closable, remembered per viewer like the AI setup
hint. Copy in all seven locales; OpenAPI regenerated.
Deleting a conversation hides the row and sends the DELETE only after
the six-second undo window. The pending delete lived in a timer alone,
so a reload, a closed tab or an installed app sent to the background
inside the window dropped it without a request, and the conversation
came back. When the window did run out, the hide filter was released
while the paginated history still held the row, so it showed again
until the refetch landed.
Every surface that deletes a conversation (the Coach panel, the
conversations page, the settings memory card) shares one hook, and the
fix lives there:
- a scheduled delete commits early when the page is hidden, on
pagehide and on unmount, and the request is sent with keepalive
- a committed row leaves every list cache, head list and every history
search variant, before anything else changes, and stays hidden
- deletes not yet confirmed are kept in sessionStorage; the next page
hides them and sends them again, so its list read cannot bring the
row back before the old page's request lands; a 404 counts as gone
- a failed delete restores the lists and says so instead of the row
quietly returning
- Undo after the commit reports false, so the rail does not reopen a
thread that is gone
A find-then-delete pair let two deletes of the same conversation both
find the row, and the slower one failed with a 500. A client that
re-sends a delete it could not confirm now gets 404 for the second
request, which it reads as already gone.
The integration test covers the thread, its messages and its document
links going with it, the document itself staying, the list no longer
carrying it, a foreign account getting 404, and the concurrent pair.