Commit Graph
9713 Commits
Author SHA1 Message Date
Marc-André Bombeck 2480a0138d feat(cycle): add the calendar-feed credential table
A record's cycle calendar subscription is a URL credential: one row per
feed, only the HMAC of the raw hlf_ token stored, and at most one live
feed per record through a partial unique index on user_id where
revoked_at is null. Both foreign keys cascade, so deleting the record
or the carrier who minted the feed ends it.

The row is a credential everywhere else too. It stays out of the
backup (a restore would bring back a revoked link), Delete All Data
takes it on both sides, a credential remediation revokes the feeds on
the record and the ones the account minted elsewhere, and a
managed-profile handover claim revokes any feed a guardian minted.

backupVerdict moves beside the plan into its own module so the plan
stays the lists and their reasons.
2026-11-06 18:03:27 +01:00
Marc-André Bombeck c0470df14c test(labs): tie the category fields to their readers
A per-envelope guard walks the category leaves of LabOverviewResponse,
customPanels on ListBiomarkersResponse and adoptedPanel on
BiomarkerMergeResult out of the Zod schemas and ties each to the web
reader that renders it, so a new leaf without a reader fails. The
resolved-field guard gains a rule that bans the category ingredients
(catalogKeyForName, resolveMarkerCategory, summariseCategory,
categoriseOverview, ALIAS_PANEL, buildLocalisedLabelIndex) from client
code: the category, its order and its status are read, never rebuilt.

Refs #1349
2026-11-06 17:57:28 +01:00
Marc-André Bombeck c96e0e0f78 chore(demo): seed lab readings without a draw label as their panel
A draw label such as "Annual blood panel" is a panel the labs list
would show as one category holding every marker, so the demo list
would stay flat with categories turned on. Without a panel each marker
lands in its catalog category by name, both before and after the boot
backfill links the rows.
2026-11-06 17:54:02 +01:00
Marc-André Bombeck 65f1a3db91 feat(labs): keep a marker's panel when it is merged into one without
A merge into a target without a panel now takes over the first panel
a source carries, in the order the sources were given, so the merged
marker keeps its category. The result reports it as adoptedPanel, on
a dry run as well, and the merge preview says so before the person
confirms. A target with its own panel keeps it. The ledger does not
record panels; a merge has no undo.

The integration suite also proves the clear path end to end: an
explicit null on PUT clears panel, context and both bounds.

Refs #1349
2026-11-06 17:53:56 +01:00
Marc-André Bombeck 910f28dcfe feat(labs): suggest panels in the marker form and let an edit clear fields
The panel field offers the catalog panels in the page language and the
person's own panels, which GET /api/biomarkers now lists as
customPanels (one spelling per folded form). The field stays free
text; a catalog panel typed in any language lands in the same category.

An edit used to omit an emptied panel, context or bound, so the stored
value survived the save. The form now sends null for a field that held
a value and was emptied. A new marker still omits empty fields, and a
field that was empty when the edit began stays out of the body, so a
context the list could not decrypt is never wiped by an unrelated save.

Refs #1349
2026-11-06 17:51:34 +01:00
Marc-André Bombeck 92638ec089 feat(labs): show the labs list under server-resolved categories
GET /api/labs/overview now answers categories and grouped next to the
markers. A marker's category is its panel: a catalog panel in any
shipped language folds to one key, any other panel becomes the
person's own category under an opaque hash key, and a marker without
a panel falls back to the catalog panel of its name. ALIAS_PANEL
places the ten analytes that have names but no seed. Each category
carries its label in the caller's language, its members, counts per
range status and one combined status (above wins over below; no
alarm tone). grouped is true only when grouping adds something.

The list renders this as given: with groupBy set to category it shows
a disclosure header per category in both views, the person's sort
applies inside each category, and groupsOpen decides which start open.
A category opened or closed by hand stays so in this browser until the
choice changes. Settings > Arrangement > Labs gets both choices. The
overview query key carries the locale, since the labels are localised.

Refs #1349
2026-11-06 17:49:38 +01:00
Marc-André Bombeck e2170115c7 feat(settings): carry category choices in the labs list layout
The list layout gains two choices that only the labs list reads:
groupBy (none or category, default none) and groupsOpen (expanded,
flagged or collapsed, default expanded). The resolver keeps a valid
stored value and drops anything else to the default, the PUT merges
both field by field, and the hook exposes a setter for each. A
browser value from before the server column never held them, so the
legacy parse fills them from the defaults.
2026-11-06 17:44:38 +01:00
Marc-André Bombeck 2e14b2d8b5 fix(documents): wait out the upload route's busy answer instead of failing
The inbound route admits two uploads per person (four per process) and
answers the next with 429 `uploadBusy` and `Retry-After: 1`. The vault
queue sent three at once, so a multi-file drop provoked that answer
itself, and the card read it as the hourly limit: "Try again in a few
minutes".

The queue now runs at `DOCUMENT_UPLOAD_PER_USER_CONCURRENCY`, moved to
`inbound-constants.ts` so client and route share one number. A busy
answer is classified on its own, carries its Retry-After, and is retried
for the same file (Retry-After clamped to 1-10 s plus jitter, at most six
attempts within 30 s) while the row stays in its uploading state. Only a
spent budget shows an error, with copy that says the server was busy.
Unmounting aborts any pending wait. The Coach's new-file attach uses the
same retry. The hourly-limit copy now names the hour.
2026-11-06 12:35:25 +01:00
Marc-André Bombeck 53e5b47e0d fix(today): centre the score's meta lines under the ring
From md up the hero's score column sat on the trailing edge, so
"steady for 4 weeks" and its siblings ended at the ring's right edge
instead of standing under its centre. Below md the lines keep their
full-width row flush with the dial.
2026-11-06 11:26:04 +01:00
Marc-André Bombeck 0882ebd79f fix(timeline): keep a focused point's card when another point loses the pointer
A pointer leaving any point target closed whichever card was open, and a
blur did the same. With the pointer resting on one line's point and the
keyboard on another (the focus scrolls the chart, so the resting pointer
leaves the first point), the focused point lost its card and focusing it
again fired nothing. A point now releases only its own card, and hands it
back to the focused point when there is one.

The lab-marker journey drove into this when the menu click left the
pointer over a value point. It now sets that up on purpose and asserts the
card survives the pointer leaving, in place of the retry that could never
reopen a card on an already focused target.
2026-11-05 17:13:40 +01:00
Marc-André Bombeck 7b4c67b372 feat(workouts): attach a route to a stored workout that has none
The workout batch kept `route` only for entries it inserted. An entry
matching a stored workout reported `duplicate` and its track was dropped,
so turning on `storeWorkoutRoutes` only helped workouts recorded from then
on.

A matching entry that carries a route now attaches it when the stored
workout has no track and the account keeps tracks, the same way the
heart-rate series enrichment works: the planner probes for a stored
track, the write seals it like an insert does, and the entry reports
`enriched` (once, also when it attaches series and track together). The
climb derived from the track fills an empty `elevationM`, as it would on
insert. A workout that already has a track keeps it, and with routes off
nothing is written.

Track rows now go through `createManyAndReturn` on the unique
`workoutId`, so a track that lands between the probe and the write wins
and this entry settles as a duplicate instead of failing or adding a
second row.
2026-11-05 16:04:08 +01:00
Marc-André Bombeck da3d29424d fix(visits): cap the window before a visit at the longest report
The window before a booked visit started at the last matching visit
however far back it lay, while the health-report export covers 730 days
and fell back to its 90-day default for anything longer. The card then
offered a report the export did not build. The window now stops at the
export's limit, read from one shared constant, and preparation.capped says
when it did; the web says "The last two years" for it.

The export clamps an over-long explicit range to the last 730 days before
its end instead of falling back to 90 days, so a custom range or a linked
window cannot shrink silently either, and its day count forgives the hour
a clock change adds, so two years of local days count as 730.
2026-11-05 14:12:09 +01:00
Marc-André Bombeck f39598f2e7 feat(coach): name a read past the lookback limit on the trail
A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
2026-11-05 14:12:09 +01:00
Marc-André Bombeck 2e28d4ed4c fix(query): refetch a query whose first load was in flight at invalidation
TanStack restarts an in-flight fetch on invalidation only when the query
already holds data. A query still on its first load gets the in-flight
promise back, and that fetch's success clears the invalidated flag, so an
answer read before the write sticks with nothing scheduled to correct it.

The vault hit this when an upload settled just as a typed search switched
the list to a fresh key: the list stayed on "No matching documents" while
the stored row existed. invalidateKeys now waits for such a first load to
end and invalidates that query once more.
2026-11-05 13:05:57 +01:00
Marc-André Bombeck 63f9d2f60e test(integration): let the server time out a statement before the test does
A statement-level hang in the integration suite surfaced only as a
60-second test timeout, because statement_timeout sat at the same 60 s.
At 45 s the server cancels first, and its error names the statement and
the lock or wait it was stuck on.
2026-11-05 12:01:57 +01:00
Marc-André Bombeck ef61c7d5d1 fix(labs): keep the printed range on a marker minted from a text result
A report that prints a text result such as "SEE NOTE" with a numeric
range beside it minted the marker without bounds: the document commit,
the scan commit, the free-text lab write and the CSV import each passed
null bounds for a qualitative reading, and the CSV row reader and the
scan review payload dropped the range before that. The marker's first
number then had nothing to be judged against.

Every mint path now passes the printed window. A range that is text
itself yields no bounds, an existing marker keeps its own, a transposed
window is not minted, and the reading itself still records no window.
The scan review shows the range on a text row that mints a new marker.

Refs #1313
2026-11-05 10:59:52 +01:00
Marc-André Bombeck e9886ee554 fix(security): let the app frame its own blob previews
A blob: document inherits the policy of the page that minted it, and
WebKit applies the inherited frame-ancestors to the blob frame's own
parent. With 'none', Safari on macOS and iOS refused the PDF preview
iframe and showed an empty box; Chromium does not apply it there and
rendered the preview. 'self' keeps every foreign origin out.

Refs #1311
2026-11-05 00:52:02 +01:00
Marc-André Bombeck 6800ff2620 fix(i18n): translate the stray word in the Russian connect-source hint 2026-11-05 00:00:03 +01:00
Marc-André Bombeck e9ae024035 fix(onboarding): connect a Fitbit answer through Google Health
Fitbit shut its classic Web API down on 2026-10-30 and Fitbit data now
arrives through Google Health, yet answering "Fitbit" still ended the setup
on the classic card, read the classic connection's status and named it.

Fitbit stays an answer, because people still own the devices. Its task now
opens the Google Health card, reads that connection, is named after it, and
says on every tile that Fitbit data comes through Google Health. The
question carries the same hint under the Fitbit choice, and the checklist
names the connection the same way.

The onboarding-source guard keeps Google Health out of the questions, now
with a reason a test holds: the Fitbit answer is the way there.
2026-11-04 23:59:18 +01:00
Marc-André Bombeck 341061de5c feat(measurements): store resting energy as its own metric
HealthKit's basalEnergyBurned sat in the deferred set, so the batch route
refused it per entry and acceptedTypes left it out. It now maps to a new
BASAL_ENERGY_BURNED type in kcal (migration 0493), beside active energy
and never added to it.

It is cumulative like active energy: summed per day on the read path and
in the rollups, drained into one daily row, imported from export.zip,
listed and charted with its own label in every locale, part of the report
selection and the backup, and readable over MCP. It carries no personal
record direction and feeds no score, insight or Coach snapshot.

Health Connect is left unmapped on purpose: its basal metabolic rate is a
rate at one instant, not a day's energy. No other integration reports a
resting figure of its own.

Refs #1340
2026-11-04 23:45:55 +01:00
Marc-André Bombeck 4894f5be5b test(labs): seed the marker behind the lab line in the GET contract
The contract walk had no fixture for `/api/biomarkers/{id}/series`, so
the new route was reported instead of parsed. It now takes the seeded
biomarker, which already carries a lab value, and is checked against its
published schema like `/api/biomarkers/{id}`.

Refs #1342
2026-11-04 22:07:34 +01:00
Marc-André Bombeck 27a1cd6c2f feat(integrations): list imported cycle days in the details
The details of the Garmin and Oura cards listed measurement types and
workouts only, so cycle days a wearable imported appeared nowhere, and a
bug report could not say whether any had arrived. The status envelope now
carries a `CYCLE_DAYS` entry per cycle-importing source with the newest
day and the count, and the freshness list renders it. It counts as a
sparse type, so a month between periods never reads as a dead pipe.

Refs #1333
2026-11-04 22:03:18 +01:00
Marc-André Bombeck afdb8163ce fix(onboarding): offer Garmin as a source
The setup question about where readings come from listed every other
connection on Settings and not Garmin, so the flow could neither record it
nor end on connecting it. Garmin is now a Q4 source and a browser
connectable one: the key is pasted on its card, so nothing can be missing
up front.

A guard now pins that every connection the health read covers is either an
onboarding source or named with the reason it is not.

Refs #1333
2026-11-04 22:03:08 +01:00
Marc-André Bombeck 148af765c9 fix(cycle): open cycles from Garmin and Oura period days
Both wearables wrote their period days with a bare create and so skipped
the seam every other cycle writer goes through: a first bleeding day never
opened its cycle and no day was attributed to one. An account whose
periods came from a watch or a ring held day-logs only, with no cycle
start, no lengths and no forecast.

The create now goes through one shared helper that keeps the
create-only-if-absent rule and then runs the same attribution and
`ensureCycleForBleedingDay` inference as the shared upsert. The hourly
legs and the newest-first Garmin backfill both use it; a finished Garmin
period also stamps its last day on a cycle that has no period end yet.

Rows imported before this change are repaired by a one-shot job sent from
boot discovery while candidates exist. It opens or attributes cycles
oldest first per account, touches no day-log content, and converges to an
empty candidate set.

Refs #1333
2026-11-04 22:02:48 +01:00
Marc-André Bombeck a35b77c147 feat(labs): serve one marker's lab line under the labs module
GET /api/biomarkers/{id}/series answers the line labSeries on
/api/timeline draws for that marker, gated on the labs module instead of
the opt-in timeline. The window rules move into resolveTimelineWindow and
the shared query rules into withWindowRules, so both routes cut the same
window and read the same loader; all starts at the marker's first reading.

Refs #1342
2026-11-04 21:24:12 +01:00
Marc-André Bombeck a79fc4168a feat(sync): let a client start the delta feed at the present
GET /api/sync/changes?start=now answers an empty page whose cursor sits at
serverNow in every domain, in the same opaque format the feed hands out.
A client that only needs what changes from now on, such as the Apple
Health write-back, pages forward from it instead of draining the whole
history first. start and cursor together are refused with 422.

Refs #1339
2026-11-04 21:24:12 +01:00
Marc-André Bombeck 59434cd463 feat(healthkit): list the accepted sample types on the integration read
GET /api/integrations/healthkit carries acceptedTypes, the quantity and
category identifiers the batch route maps today. The list is derived from
APPLE_HEALTH_TYPE_MAP alone, so a deferred type is never in it and the
client no longer needs a release to follow a mapping change.

Refs #1340
2026-11-04 21:18:30 +01:00
Marc-André Bombeck 66533497de fix(auth): carry purgeAt on the native OIDC deletion-pending redirect
The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.

Refs #1341
2026-11-04 21:18:30 +01:00
Marc-André Bombeck a812fb699f fix(mental-health): read the country from the region subtag for every language
A tag such as en-BE, fr-CH or en-AT fell through to the international
crisis set because region detection hung on the language. The region
subtag now decides first for every language; bare language codes keep the
combined sets, and the web's folded pt-BR bundle stays ambiguous.

Refs #1338
2026-11-04 21:02:02 +01:00
Marc-André Bombeck d3401b5b89 refactor(records): move the life circumstances field primitives into their own file
The draft state for the typed fields pushed the form past its size
baseline. The primitives own only their input state and read nothing from
the form, so they move out and the baselines tighten.
2026-11-04 17:59:54 +01:00
Marc-André Bombeck 41f90bac3f chore(deps): move @hono/node-server to 2.x and lift systeminformation
The serveStatic double decode has no fix on the 1.x line, and the MCP
SDK accepts 2.x. The app uses the web-standard transport and never loads
it. systeminformation sits under the shadcn CLI at dev time.
2026-11-04 16:56:20 +01:00
Marc-André Bombeck d98e16557b chore(deps): keep Node majors out of the image PRs
Refs #1334
2026-11-04 16:54:29 +01:00
Marc-André Bombeck 40200b55a7 fix(records): let the smoking history fields take keystrokes
The quit month only accepted text that was already a whole YYYY-MM
month and the start year clamped every keystroke into 1900..now, so
neither could be typed into. Both keep a draft while typing and commit a
valid value. Dependabot also stops proposing Node majors for the image.

Refs #1334
2026-11-04 16:54:14 +01:00
dependabot[bot] 25770912ce build(deps): bump @tanstack/react-query (#1336)
Bumps the production-weekly group with 1 update: [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query).


Updates `@tanstack/react-query` from 5.105.0 to 5.105.1
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.105.1/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.105.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 14:39:22 +00:00
Marc-André Bombeck 90865eb485 Merge release/v1.47.0 v1.47.0 2026-11-04 15:04:24 +01:00
Marc-André Bombeck 27b054d8c4 fix(image): take scheme-less health-check URLs, anchor the compose pin, say which tools left 2026-11-04 14:32:58 +01:00
Marc-André Bombeck fb143a2d47 docs(release): screenshots for v1.47.0 2026-11-04 14:21:49 +01:00
Marc-André Bombeck bc4b9f4350 chore(release): v1.47.0 2026-11-04 14:13:49 +01:00
Marc-André Bombeck 3149805592 Merge branch 'fix/v147-debian-slim' into release/v1.47.0 2026-11-04 13:15:49 +01:00
Marc-André Bombeck 849fe4b2c6 build: stop tracing the musl canvas prebuilts the glibc image never loads 2026-11-04 13:15:49 +01:00
Marc-André Bombeck e547a77683 docs: describe the image as the official Node 22 Debian slim image
The unreleased changelog entry said the image moved to Wolfi. It now
names the official Node image, why (Raspberry Pi 4 rendering, Node
security releases arriving with the image), that the image carries no
package manager, and that psql is no longer in the app image. The README
and the getting-started prerequisites say the same.
2026-11-04 13:14:04 +01:00
Marc-André Bombeck 09d932ce2a ci: fail the container scan only on findings with a fix
The Debian base carries HIGH advisories the distribution has not fixed
(util-linux, ncurses, systemd, acl, sqlite, perl-base), which no change
here can remove. The blocking image scan now runs with --ignore-unfixed,
so a finding with a published fix still fails the job, and a separate
step before it lists every HIGH and CRITICAL finding without failing.
The scheduled scan's critical gate follows the same rule; its SARIF
report keeps everything.
2026-11-04 13:14:04 +01:00
Marc-André Bombeck b6b693717c build: run the image on the official Node 22 Debian slim image
The runner moves from Wolfi to node:22-trixie-slim, the same image and
digest as the dependency and build stages. Wolfi's free repository keeps
only its newest Node stream current, so nodejs-22 stayed at 22.23.2 and
missed two releases with HIGH fixes; the official image carries 22.23.4.

The runner removes npm, npx, corepack and yarn before anything else and
fails the build if one is left. The Prisma CLI install moves to its own
prisma-cli stage on the same base and is copied over, so npm never runs
in the runner. pg_dump and pg_restore 18.6 come from a pgclient stage on
postgres:18.6-trixie, pinned by digest, with the libpq they link; the
Kerberos, LDAP and SASL libraries come from apt. The PGDG package would
bring Perl and the wrapper scripts, and its repository drops a pinned
minor once the next one is out.

The native ABI and glibc check between builder and runner stays as a
guard. runtime-package-pins now holds every stage to a digest, the
runner to the builder's image, the runner free of package managers, and
the pgclient minor to the bundled database's. Dependabot ignores a
PostgreSQL major in the Dockerfile as it does in the compose file.
2026-11-04 13:13:56 +01:00
Marc-André Bombeck 5dd6453773 fix(garmin): compare the cached token's key in memory, drop an unused export 2026-11-04 12:02:46 +01:00
Marc-André Bombeck de47cb7b31 test(analytics): list the Garmin and training types in the parity goldens 2026-11-04 11:15:03 +01:00
Marc-André Bombeck 8a89d5cfc3 Merge branch 'feat/v147-garmin' into release/v1.47.0 2026-11-04 10:58:59 +01:00
Marc-André Bombeck 2348073eeb docs(garmin): describe the service choice, key validity and skipped data
The Garmin page says the key goes only to the service picked, how to
correct a key's validity from the card, that a refused key is retried
daily, that weigh-ins reach back past the first watch, that removed
nights disappear, and where data Garmin does not provide is listed. The
changelog entry names the service choice.
2026-11-04 10:56:52 +01:00
Marc-André Bombeck 83c06051fe fix(garmin): read weigh-ins in both shapes, keep refused resources out of the way
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live
service sends a samplePk and grams. A weigh-in now takes its id from
samplePk or else its own GMT instant, reads each mass by magnitude (above
1000 is grams), and is written whole or not at all: a missing weight, a
missing instant or one implausible value drops every row of it, counted
on the wide event by reason.

Refused resources: a lasting 4xx on one resource (a cycle summary for an
account that tracks no cycle) is noted once on the progress column,
counts as done for the backfill, is skipped by the hourly sync and is
listed in the card's details. It no longer holds the backfill open or
turns the connection red. The cycle leg never parks the connection,
whatever it is refused with.

Backfill: weigh-ins walk back to the start of Garmin Connect on their
own, so history before the first watch day and a scale-only account
arrive. Progress and the completion stamp are written only while the
connection the slice started on is still the stored one.

Key: connect sends the key to the one service the person picks (Garmin
Connect or Garmin Connect China), never to both. A parked key is asked
about again once a day with a single token exchange and reconnected when
Garmin accepts it. The key's end is read from the token's own iat and
exp, an estimate the key outlived is cleared after an accepted exchange,
and the validity can be corrected from the card's menu (PATCH
/api/garmin/key-expiry, with the day the key was created) without
pasting it again.

Sync: an incremental run reaches back to the last successful sync, up to
a year, so an outage longer than the 30-day window leaves no gap; a night
Garmin no longer reports (deleted, or turned into a nap) is retired.

Card: connect errors are translated from meta.errorCode in every locale.

Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and
ACUTE_TRAINING_LOAD get source ladders of their own (the device or the
bridge first, then the hand-entered sources in their usual order), join
the ranked types and the provenance picker, and the recovery-empty
description names them.
2026-11-04 10:56:52 +01:00
Marc-André Bombeck 73f16e5389 docs(garmin): document the Personal API integration
The Garmin page now explains how to create a key, what syncs and where
it lands, how key expiry is handled, and what the Personal API does not
carry. The Home Assistant guide shows how to push Training Readiness and
the acute training load, the Apple Health and Google Health pages point
at the direct integration, and the changelog records the release notes.
2026-11-04 10:14:17 +01:00
Marc-André Bombeck 13e232c836 test(garmin): pin the key exchange, the mapping and a sync against a fake Garmin
Unit tests cover the token cache, the single re-exchange on a refused
token, the key's end read from a capped token, the 429 wait, the range
chunking, and every resource's mapping over made-up records shaped from
Garmin's document. The card test pins the shared anatomy, the menu and
the expiry sentences. The integration test runs the sync, the backfill,
connect and disconnect against a fake Garmin served over HTTP and the
real schema: rows per resource under GARMIN, no second row on a re-sync,
a refused key parked after one retry, a 429 that stops the run, a
history walked to its start across slices, and a purge on disconnect
that keeps the imported history.
2026-11-04 10:14:17 +01:00