Commit Graph
802 Commits
Author SHA1 Message Date
Marc-André Bombeck 6037ebd3d6 fix(settings): let a saved AI API key be removed
Every personal provider form showed a stored key and offered no way to
take it back, since saving with an empty key field keeps the stored one.
The OpenAI, Anthropic, local model and gateway forms now carry a
confirmed "Remove API key" while a key is stored. It sends the existing
PATCH /api/user/ai-provider with that one key as null, which clears the
column and leaves the provider choice, model and the other keys alone.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 6a9543b4f8 fix(settings): confirm before ending a grant from the sessions card
After sign-out-everywhere the sessions card lists the access grants it
left standing, each with an end button that revoked on the tap. It is the
same act the access card confirms, permanent for the other account, so it
now asks the same question with the same disclosure, audit window
included, and the row names the account the way the access card does.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 9a8b95cfd0 fix(ui): let ConfirmButton's dialog wear the destructive style
ConfirmButton took a `destructive` prop and dropped it on the way to its
dialog, so every delete, revoke and remove built on it confirmed with a
neutral primary button while ConfirmDialog confirmed the same kind of act
in red. The prop is forwarded now, and the call sites whose act takes
something away pass it. A guard holds every confirmation whose slot names
a destroying act to the destructive style; resets stay neutral.
2026-10-21 12:02:13 +02:00
Marc-André Bombeck b4618f2ec3 Merge branch 'fix/v1439' into release/v1.43.9 2026-10-20 22:49:33 +02:00
Marc-André Bombeck 7759729aca fix(proxy): refuse Server Action calls the app never makes
HealthLog defines no Server Actions, so no bundle of it, current or
stale, sends a Next-Action header. The requests that do arrive come from
the nightly scan probing that surface, and Next answered each with an
unstructured "Failed to find Server Action" at error level. The proxy
now refuses them with a 404 and one structured warn line, and a guard
keeps "use server" out of the tree so the refusal stays safe.
2026-10-20 20:29:46 +02:00
Marc-André Bombeck 05ae8b1e99 perf(dashboard): count all-time readings from the DAY rollups
The summary's all-time aggregate grouped every live reading of the
account by type: one index entry per reading, millions on an account
with a watch, two to four seconds whenever they were not in memory and
the slowest read of the request on every cache miss. The count now sums
the DAY buckets that already hold a per-day count of every source,
counts live only the readings before the fold window and any type not
folded yet, and takes lastSeenAt from the newest reading the
latest-reading read already returns. Parity with the live count is
pinned against a real Postgres.
2026-10-20 20:27:32 +02:00
Marc-André Bombeck f01f53a1cc perf(rollups): refold the buckets of every provider sync in one pass
Every provider sync and the shared mutation tail refolded its touched
(type, day) buckets one at a time: an aggregate, a transaction and three
queue sends per day. They now hand the whole set to
recomputeBucketsForKeys, which leaves the DAY tier exactly as the per-key
hook did (pinned by a parity test against it) at the cost of one
aggregate per chunk of keys and one coarse job per run of buckets.
2026-10-20 20:20:00 +02:00
Marc-André Bombeck 567a564913 perf(auth): resolve the caller to a narrow user projection
The session lookup and the Bearer resolver loaded the whole users row on
every authenticated request, including the profile picture bytes, the
cached briefing text and every stored integration credential. Both now
select SESSION_USER_SELECT, a named allowlist of the small columns the
request path reads, and so does the owner load for a switched record.

The few routes that need a credential or a large column read it
themselves at the moment of use: the factor verifiers load the password
hash and TOTP secret, the profile response loads the insurance number,
and the snapshot read loads the cached briefing text. A structural guard
holds the lookups to the projection and keeps every Bytes and
secret-shaped column out of it.
2026-10-20 20:07:25 +02:00
Marc-André Bombeck c8649faddf test(ui): sharpen the page-title and empty-state guards
The H1 guard matched the raw text, so seven files sat on its allowlist
only for mentioning an H1 in a comment; it strips comments first now and
the entries are gone. The empty-state ratchet counted against a ceiling,
which let a new copy in wherever an old one left, and missed the no...Yet
keys: it freezes the remaining sites by file and key, recognises no...Yet
and nothing... keys, and the two it missed (the insight status card and
the screener card) render EmptyState.
2026-10-20 16:45:11 +02:00
Marc-André Bombeck a37d45ab3f fix(insights): take the crosstab units from the shared unit table
The cycle, mood-tag and mood-factor boards each kept a table of unit
words, and the German cycle board printed S/min where every other screen
says bpm. A board row now names the measurement it reads; MetricCrosstab
resolves the unit through measurementUnitLabel after the reader's unit
preference, and the callers use the same reading for their detail line.
A rating or score shows no unit word, as everywhere else. The boards' own
unit keys are gone from all bundles, and unit-label.test.ts refuses a
component that keeps a unit table again. The pair label wraps instead of
being cut off.
2026-10-20 16:16:13 +02:00
Marc-André Bombeck 5185edca74 fix(rate-limit): fail the shared write ceiling open and skip reads over POST
The shared write bucket threw on any SQL error, so a lock on rate_limits
answered every authenticated write with 500. It is a loop cutter, not an
access check: when the bucket cannot be read the write now goes ahead with
a warn signal. The login and other auth-surface limits call the limiter
directly and still fail closed. requireAuth now passes readOverPost to the
meter, the draft-only document assist declares it, and the three chat
routes, which meter their turns with buckets of their own, declare that
instead of being counted twice.
2026-10-20 16:09:28 +02:00
Marc-André Bombeck 2b81b4f23e fix(migrations): retire the measurements prefix index without waiting at boot
0416 ran DROP INDEX CONCURRENTLY at boot, which waits without bound for
every transaction on the table; a pg_dump or a backfill outlasted the
start period and the next boot stopped at P3009. The migration now tries
a plain drop with a two-second lock wait and tolerates the timeout, and
the nightly measurement maintenance drops what is left concurrently under
a bounded wait, retrying the next night. A fresh or shadow database takes
the lock at once, so the history still ends where the schema does. The
guard refuses a concurrent drop in a migration and ties every retired
index to a tolerant one.
2026-10-20 16:06:10 +02:00
Marc-André Bombeck ec2c067e60 fix(retention): keep a tombstone while a live record still points at it
The nightly purge nulled every non-cascading key into a purged row, so a
live visit lost the practitioner it was with, a live dose its visit, a
symptom its condition. Each purge entry now lists the keys that do not
cascade, and a tombstone stays while a live row holds one. The register
test derives those keys from the schema's relations, so a new nulling key
into a purged model fails until it is listed.
2026-10-20 16:01:13 +02:00
Marc-André Bombeck 4a0db28713 Merge branch 'fix/v1438-api' into release/v1.43.8 2026-10-20 15:39:39 +02:00
Marc-André Bombeck a934e948d8 fix(units): show every measurement unit in the reader's language
The vitals tiles printed the canonical storage token beside the value, so
a German screen read "15,4 breaths/min". The list view had a private map
for a few count types; every other path passed the token through.

useUnitDisplay().unitFor now resolves the token through one module
(lib/measurements/unit-label): word tokens map to bundle keys, "celsius"
to its symbol, kind markers (score, rating, count, event, level, ratio) to
nothing. The list, the day view and the vitals tiles all read it. A guard
walks every measurement type and every display source and refuses a unit
that is neither a shared symbol nor resolved through the bundle.
2026-10-20 15:07:11 +02:00
Marc-André Bombeck 534523c462 test(guards): let the deprecation registry name the route it retires 2026-10-20 14:13:44 +02:00
Marc-André Bombeck 1d9758e3e8 Merge branch 'fix/v1438-db' into release/v1.43.8 2026-10-20 14:09:44 +02:00
Marc-André Bombeck c402e23656 fix(mcp): say when a tool's list was cut
get_ecg_recordings stopped at 200, get_visits at 50, the booked
appointments in get_preventive_care at 25, and search at its per-source
scan, all without a word, so a model read the last row as the oldest there
is. capped() reads through listPage with the same semantics as the REST
list cap: one row past the limit, truncated true only when that row exists.

The ECG and visit results carry truncated and limit, the preventive-care
result carries appointmentsTruncated and appointmentsLimit, and search
carries truncated with the sources that were cut, on every page. Every
capped read orders by its id as a tiebreaker; list-page-guard now checks
capped() reads too, and accepts a distinct read ordered by its own key.
2026-10-20 14:06:43 +02:00
Marc-André Bombeck d0677adac9 perf(bundle): keep zod out of the documents and Coach client routes
Both routes reached the schema library through a constant: the list of
document kinds, the list and bulk limits, the attachment cap, the Coach
data clusters and preference defaults, the conversation title length,
the scope source list and the report leaf order. Each sat in a schema
module, so importing the number shipped the library. The constants now
live in zod-free modules (lib/documents/inbound-constants,
lib/ai/coach/prefs-constants, lib/ai/coach/scope-sources,
lib/report-selection/catalogue) and the schema modules re-export them
for server code; the Coach page checks its scope parameter against the
closed list instead of parsing it. A guard walks each route's static
client graph with the TypeScript parser and fails on a zod import.

Two guards follow earlier commits in this branch: the fetch-limit
allowlist names the shared document picker sheet, and the card density
test states a floor on the cards it finds.
2026-10-20 14:04:06 +02:00
Marc-André Bombeck 39d23dbb7d fix(api): meter every authenticated write where the caller is resolved
169 of 364 mutating handlers met no rate limit, every PATCH and DELETE on
an [id] among them. The shared per-account write bucket (300 a minute,
keyed on the acting account) was called by hand in a dozen creates; it is
now charged by the identity resolvers for any POST, PUT, PATCH or DELETE,
once per request, so a route is metered by resolving its caller.

The bulk ingest routes the native client syncs through in bursts
(measurements, workouts, intake, mood and cycle bulk, nutrients, the ECG
sweep) declare writeBucket: "own" and keep their dedicated ceilings, so a
large HealthKit backfill cannot lock the person out of logging a dose. A
read-over-POST stays free.

write-rate-limit-guard fails on any mutating route that neither resolves
its caller through a metering resolver nor calls a limiter, unless it is
on a short reasoned exception list; an opt-out must be on its own reviewed
list and call its own limiter.
2026-10-20 13:53:54 +02:00
Marc-André Bombeck 3e9594b3b8 test(guards): read whole calls in the Bearer scope guard
The guard's matchers stopped at the first closing parenthesis and knew one
spelling of a Bearer lookup. requireRecordAuth(need(), "x", { scope: X })
read as naming no scope, a findFirst or findUniqueOrThrow by tokenHash was
invisible, and the /mcp route sat on the allowlist only because a comment
named the resolver.

Calls are now parsed whole, comments stripped, with the first requireAuth
argument read as the scope and any later options ignored. A Bearer
resolution is the shared resolver, the MCP edge's wrapper, any apiToken
find whose arguments name tokenHash, or a raw SELECT by token_hash. The
stricter matcher found one resolver the guard never saw: the local stdio
MCP entrypoint, now on the allowlist with its reason. Planted cases prove
each matcher fires.
2026-10-20 13:53:54 +02:00
Marc-André Bombeck 3bae93256d chore(settings): retire the unused IntegrationStatusPill
Three status pills paint a connection state: HealthBadge on the shared
integration card, the notification channel badge, and
IntegrationStatusPill. The first two describe different state models
and stay. The third had no production caller since the integration
cards moved to HealthBadge; it goes with its test and the nine strings
only it used. formatRelative, which two cards still import, moves to its
own module.
2026-10-20 13:53:51 +02:00
Marc-André Bombeck 6885377f35 fix(ui): four hand-rolled page titles go through PageHeader
The insights sub-page shell (text-xl sm:text-2xl semibold), the
medication detail, the check-in screen and its result (semibold) each
read a step lighter than every other page title. They now render
through PageHeader, which gains a focusable heading with a visible ring
for screens that move focus to their title, and an accessory slot
beside the H1 that stays outside its accessible name. A structural test
lists where a different H1 is the point (heroes, sign-in, error and
prose pages, the first-run steps) and refuses any other.
2026-10-20 13:40:40 +02:00
Marc-André Bombeck e7ee416e19 refactor(ui): move 62 hand-written empty states onto EmptyState
The admin sections, the cycle, illness, medication, mood, records and
settings cards, the pickers and dialogs said nothing-here-yet in a
hand-written muted paragraph in five sizes and three alignments. List
and section states now render through EmptyState (plain, compact; the
three with a next step carry it as the action), and the six chart
bodies through ChartEmptyState at their former height. The thirteen
that stay are a field without a value or a hint inside an editor, not
a list state. The ratchet ceiling drops from 75 to 13.
2026-10-20 13:31:02 +02:00
Marc-André Bombeck 8db7c71b0e fix(api): one helper for list pages, exact cut signal, total order
listPage (src/lib/http/list-query.ts) reads one row past the limit and
cuts it back, on top of the existing list-cap signal rather than beside
it. Every capped list now gets its page there, and every read passed to
it orders by something ending in the row id, so rows that share a sort
value cannot repeat or vanish at a page boundary.

- practitioners, vaccinations and encounters were cut at their take
  with no signal; they now answer meta.truncated and accept offset, and
  their web readers walk the pages instead of stopping at the first
- symptoms/events reported hasMore on a page that was exactly full; it
  is now true only when a row past the page exists
- the document list's hand-rolled limit + 1 goes through the helper
- security-activity answers an out-of-range limit with the 422 its
  published schema already promised instead of clamping it

list-page-guard binds every apiListSuccess route to listPage, forbids
hand-cut lists and length-derived hasMore, and requires the id
tiebreaker in every listPage read.
2026-10-20 13:29:14 +02:00
Marc-André Bombeck fe021c0ffd test(ui): freeze the hand-written empty states with a ratchet
Seventy-five surfaces say nothing-here-yet in a hand-written muted
paragraph instead of EmptyState. The ratchet counts them, fails on a new
copy, and fails when a site moves over until its ceiling is lowered.
2026-10-20 13:28:09 +02:00
Marc-André Bombeck ee2bc8019c feat(ui): ScorePill for every grade chip
Confidence levels, the weekly score gain, target bands and lab range
flags were drawn by hand in two looks: a /15 wash without border in the
mood boards, a /10 wash with a /40 border on the correlation and trend
cards, and medium confidence was blue on one card and amber on the
next. They now render through one ScorePill (rounded, xs, soft wash,
tone success / info / warning / destructive / neutral, optional tabular
figures) with one confidence scale. The target band keeps its own
AA-tuned tokens in the shared anatomy. A structural test refuses a new
hand-rolled grade chip.
2026-10-20 13:24:00 +02:00
Marc-André Bombeck 03aec3ec12 perf(db): index every acting foreign key and drop prefix indexes
Twenty-one foreign keys with ON DELETE CASCADE or SET NULL had no index
leading with their column, so deleting a parent scanned the child table
once per row; medication_intake_events.medication_id was the one an
account delete paid for. Seventeen small-table indexes and the measurements
(user_id, type, measured_at) index repeated the leading columns of another
index and only cost writes.

Migration 0415 adds and drops the small-table ones in one DO block under a
bounded lock wait; 0416 drops the measurements index concurrently, in a
file of its own. An integration test holds the schema to both rules from
the catalog, each with a counter-check, and compares the plans of the
user-and-type query shapes on a seeded table with and without the dropped
index. The lock-timeout guard now also requires a CONCURRENTLY statement
to stand alone in its file.
2026-10-20 13:07:24 +02:00
Marc-André Bombeck 60be63bcd1 feat(ui): InlineAction for small text actions, with a 44 px touch floor
Thirty-three hand-rolled underlined text links and buttons (Open
schedule, Select all, View all, Retry, Learn more, the device-code copy
action, ...) carried three different focus rings, none on half of them,
and a 16 px target on a phone. They now render through one InlineAction
primitive: tone primary / muted / foreground, size xs / sm, a focus ring,
and min-h-11 on a coarse pointer so the desktop layout does not move. A
structural test refuses a new hand-rolled copy.
2026-10-20 13:03:15 +02:00
Marc-André Bombeck 41de0ca126 refactor(medications): drop the compliance rollup table that nothing reads
medication_compliance_rollups lost its last reader when compliance moved
to the schedule-anchored computation, yet every dose write still upserted
it, a boot backfill refolded it, and the wipe and backup plans carried it.

The per-dose hook keeps the one derived row that still hangs off a dose
day, the supplement nutrients, under dose-day-refresh. The pending-row
mints (today projection, reminder) no longer call it, since an untaken
dose adds no nutrients. The import job finishes without its per-day
recompute phase; its progress keeps touchedDays and rollupProcessed on
the wire, inert and documented as deprecated. The backfill queue is
retired on boot and migration 0414 drops the table under a bounded lock
wait.
2026-10-20 13:01:45 +02:00
Marc-André Bombeck 5a6c78efe5 fix(jobs): give the full rollup fold an expiry, a budget and a lock
The fold was declared short on the strength of its 60-second
statements, but four aggregates and four persist transactions of up
to 120 s come to about thirteen minutes at worst under the defaults,
and nothing bounds them once an operator lifts the statement timeout.
Past pg-boss's fifteen-minute default the retry folded the account a
second time. The send now carries an hour's expiry, the fold stops
between granularities on the job's budget, and the binding holds a
per-account lockedPass outside the lane. The runtime guard now requires
every heavy queue to be declared long.
2026-10-20 11:06:17 +02:00
Marc-André Bombeck 2be262d574 refactor(api): drop apiPostList, which no capped list reads
Every capped route is read with GET, so the POST reader had no caller
and failed the dead-code gate. The list-cap guard now binds a capped
route to apiGetList alone, which is the reader that exists.
2026-10-20 10:58:03 +02:00
Marc-André Bombeck b08d527045 test(auth): mint session fixtures in the shape the lookup accepts
The session time-zone test stored sessions under readable secrets that
the cookie shape check now refuses before the query, so it tested the
refusal instead of the expiry. A shared helper mints hls_ plus 32 hex
bytes, and the two route tests that set a session cookie use it too.
2026-10-20 10:56:55 +02:00
Marc-André Bombeck 4f02004146 test(guards): derive the rounding guard's directories from the connection keys
The guard kept its own provider list and its own comment stripper next
to the shared PROVIDER_DIRS and stripComments helpers, so a new
integration could slip past it. It now uses both, plus the file
importers, which are not a connection.
2026-10-20 10:55:42 +02:00
Marc-André Bombeck 9c89503019 fix(migrations): bound 0412's lock wait where it takes effect and retry a lock timeout on boot
migrate deploy runs a migration file statement by statement, so the
top-level SET LOCAL lock_timeout in 0412 did nothing: the migration
waited as long as the lock was held, with every write to measurements
queued behind it, and a file that failed half-way stayed half-applied.
0412 now takes its locks inside one DO block with a statement-scoped
timeout, which rolls back whole, after an idempotent CREATE OR REPLACE.

The entrypoint runs migrate deploy through scripts/migrate-deploy.mjs.
A 55P03 failure of a migration marked -- @retry-on-lock-timeout is
marked rolled back and retried after 15/30/60/120/240 s, read from
Prisma's report or, after P3009, from the stored row. Every other
failure stays fatal. A guard holds migrations to the shape that makes
the marker true, and 0411/0413 add their columns with IF NOT EXISTS so
a manual re-run is safe too.
2026-10-20 10:45:14 +02:00
Marc-André Bombeck e5b7eebe64 Merge branch 'fix/v1437-r2-jobs' into release/v1.43.7 2026-10-20 10:17:01 +02:00
Marc-André Bombeck 86eb743a69 Merge branch 'fix/v1437-r2-errors' into release/v1.43.7
# Conflicts:
#	src/components/charts/health-chart.tsx
#	src/components/insights/ecg-cross-link.tsx
#	src/components/insights/ecg-section.tsx
#	src/components/insights/rhythm-events-card.tsx
2026-10-20 10:17:01 +02:00
Marc-André Bombeck 923249dbf6 fix(ui): draw every failed read through a read-failure primitive
Around thirty surfaces drew a failed read their own way: a bare
destructive paragraph with no retry, a muted line saying the data was
still being learned, a skeleton that never ended, an error card nested
in a tile, or nothing at all. None of them told an ended session from a
refusal. They now use QueryErrorRow or QueryErrorCard with the query's
error and a retry, inset where a card already frames them, and keep
their card and title where the page would otherwise jump. Hydration and
caffeine now fail the same way.

The recovery page no longer points to Integrations while Oura's first
resilience level is simply on its way.

A guard reads every read hook's isError branch and refuses hand-rolled
markup there, and refuses a framed error row inside a card.
2026-10-20 10:10:49 +02:00
Marc-André Bombeck 3fbd16a0a6 fix(ui): let the chart error card decide from the error too
ChartErrorState took no error, so a chart whose read was refused or
whose session had ended offered a retry that could not help. It now
takes the error and branches the way QueryErrorCard and QueryErrorRow
do, the chunk boundary hands it the error it caught, and the error-prop
guard covers it.

QueryErrorRow gains an inset form for a failure inside a card body, and
its frame takes the md:p-6 step. QueryErrorCard drops its fixed px-6 for
the card's own inset and both icons move onto the size scale.
2026-10-20 10:10:31 +02:00
Marc-André Bombeck 985372f711 fix(insights): count failed fetches toward a bounded poll's ceiling
The status and advisor polls counted attempts on dataUpdateCount, which a
failed fetch does not move, while the last good payload stayed in place and
still said preparing. A request that kept failing polled an open page for
as long as it stayed open. Attempts now include the failed fetches.

The poll inventory moves from a comment into a guard held to every
refetchInterval in the tree; four polls were missing from the comment.
2026-10-20 10:06:11 +02:00
Marc-André Bombeck dc5120b59a feat(lists): read and show the list cap on every capped list
Twelve routes answer a capped list with meta.truncated / meta.limit on
the envelope, and the web client read only data, so no surface said a
list had stopped short. apiGetList / apiPostList are now the one client
reader of the cap and ListCapNotice the one line that shows it; every
capped list on the web reads its cap and shows the notice under the list,
the paged readers (side effects, illness episodes) stop on the route's
answer instead of a full-page guess, and the ECG list moves its five
readers onto one hook. The record activity feed sent its cap twice, in
data and on the envelope; it rides the envelope alone now. A guard
derives the capped routes from the tree and binds each to a client that
reads its cap.
2026-10-20 10:04:11 +02:00
Marc-André Bombeck be79603d53 chore(guards): give the linear-fit guard a match floor and keep SQL out of the i18n scan
The linear-fit guard floored only its walk; it now also requires the
implementation's call sites it expects. The per-type readings query
aliased its driving relation t, which the i18n call-site scanner read as a
translation call; it is mt now, as in readLatestEver.
2026-10-20 10:01:30 +02:00
Marc-André Bombeck 6e54f33901 refactor(i18n): ask the catalogue for a sentence in one helper
`t()` answers a missing key with the key, so "does this code have a
sentence" was a comparison written inline at every site that wanted a
code's sentence or a fallback, twenty-odd times in several spellings.
translateOrNull, translateCodeOrNull and translateCode in
src/lib/i18n/translate-code.ts now hold that probe, every site uses
them, and a guard refuses the inline comparison anywhere else.
2026-10-20 09:53:31 +02:00
Marc-André Bombeck f4262e2cc6 test(guards): see a squared two-argument distance as a hand-rolled spread 2026-10-20 09:51:13 +02:00
Marc-André Bombeck 5cbe52cada test(guards): count the allowed sites per file instead of exempting the file
An exempted file covered every bare formatter or clock read written into it
later. Each entry now states how many sites it holds, and a file with one
more fails like any other.
2026-10-20 09:51:12 +02:00
Marc-André Bombeck 9b05d8c215 test(guards): derive the provider directories from the connection keys 2026-10-20 09:51:12 +02:00
Marc-André Bombeck 5fdddaec0a test(guards): strip comments through the shared scanner 2026-10-20 09:51:12 +02:00
Marc-André Bombeck c300574a8e fix(api): read the error code before the status
A read-failure primitive decided from the status alone, so a 401 from a
step-up or re-proof gate would have read as an ended session and sent
the reader to sign-in. The code now decides first and the status is the
fallback for codes it does not know.

The two invitation routes answer an unknown name or e-mail with
sharing.invite.no_account. Both have a second 404 with a different next
step, and since every error now carries a code the clients match on it
instead of on the status. The status defaults move into a module the
client can import, and the contract text no longer says these arms
carry no code.
2026-10-20 09:48:15 +02:00
Marc-André Bombeck f0c20151a9 perf(reads): ask the latest reading per type with one probe per type
The briefing's last-seen read and the MCP search presence probe grouped
every live row of the account to learn the newest instant or whether a
type exists at all. Both now use readLatestEver, one index lookup per
type. The latest-per-type guard also catches the Prisma spelling: a
groupBy by type with nothing but _max, or no aggregate, and no window.
2026-10-20 09:45:51 +02:00
Marc-André Bombeck 05503e2b82 refactor(stats): one least-squares fit for every trend line
Six readers fitted a line by hand, four in the determinant form that
loses digits on a large x axis, and the two charts carried the same trend
function twice. linearFit / fitFromSums in lib/stats is now the one
implementation (centred sums, so a fit from points and one composed from
stored accumulators are the same arithmetic), trendLineByTime serves both
charts with the same two-decimal output, and a guard keeps hand-rolled
slopes out of the tree.
2026-10-20 09:43:47 +02:00