Optimize performance across server, web and iOS #24

Merged
kubus merged 49 commits from t3code/perf-optimize-all into main 2026-10-01 12:25:26 +00:00
Owner

Performance pass across the server, web frontend and iOS app. Every item was measured, independently verified, implemented as its own commit (49, bisectable, each typechecks), and code-reviewed.

Results (seeded steady-state DB: 225k observations, 6k listings; back-to-back on the same host)

before after
/api/dashboard 254 ms · 452 KB 15 ms · 55 KB on the wire
/api/listings (500 rows) 251 ms · 443 KB 16 ms · 54 KB
listing detail 291 ms 1.8 ms
4 concurrent dashboard requests 1017 ms 72 ms
analytics 30 d / 180 d 113 / 663 ms 87 / 487 ms
scan, 100 listings (grouped, AI off) 98 ms 14 ms
scan, 300 listings (AI on) 357 ms 141 ms; worst event-loop stall 256 → 54 ms
readiness / connector history at real cadence volume 45 / 367 ms 0.15 / 0.17 ms
daily prune ~400–900 ms ~14–70 ms
main JS download 241 KB, revalidated each load 66 KB br, cached immutable
startup / idle RSS / RSS after load 425 ms · 158 MB · 577 MB 212 ms · 90 MB · 354 MB
/api/export peak memory +1.3–1.7 GB ~+65 MB (streamed, byte-identical)

Server

  • Exact query rewrites driven from watch_listings with indexed observation seeks (watch stats, baselines, analytics, watch analytics, listing-detail history); each has an old-SQL oracle test.
  • migrations/030_perf_indexes.sql: observations(listing_id), connector_runs_recent, partial scans_unfinished; drops three unread indexes.
  • Scan path: no rewrites of unchanged AI-cache rows (≈470 KB WAL/scan → 0), one relevance flush per pass, bounded worker pool for Jev calls, per-bucket score stats, WAL checkpoints on a worker thread.
  • Behavior change (approved): standing deals that cannot alert and have a fresh (< 6 h) verdict are not re-verified each scan; anything alertable is still verified immediately.
  • OLX: compressed offers-API responses; verification uses the API description instead of downloading the offer page (standing OLX deals whose text differs are re-verified once after deploy).
  • Shared local Chromium with idle close; marketplace CDN images answered with a 1×1 GIF in renders.
  • Lazy-loaded playwright-core and MCP SDK; stable server chunk names.
  • br/gzip compression for /api/* (SSE and /mcp excluded); precompressed static assets; immutable caching for hashed assets.
  • /api/dashboard?top=N compact response for widgets (default response unchanged).
  • MCP (approved): compact JSON; scout_dashboard returns 20 listings plus listingsTruncated.

Web

  • One EventSource per tab (fixes the 3-tab HTTP/1.1 connection hang), released while hidden and reconciled on return.
  • Server events refetch only what the visible view shows; triage patches rows locally with one trailing reconcile; Logs appends streamed lines.
  • Memoized rows and search results, module-level formatters, preloaded route chunks (first route visit ~300 → ~15 ms), parallel boot requests.

iOS

  • Widgets: ?top=12 (463 KB → 10 KB), reload only when drawn content changes (176 → 10 reloads in a 2 h simulation), reuse the app's fresh snapshot, download only drawn photos.
  • App: screens reload only for relevant events and not on every re-appearance, one refresh per foreground, parallel detail loads, downsampled and cached images, ISO 8601 parsing ~45× faster.

Before merging

  • The iOS app and widget targets were not compiled (no Xcode on the dev host). ScoutKit builds and passes on Linux (69 tests), all changed Swift files parse, and app logic was type-checked against stubs, but please run the iOS workflow (workflow_dispatch on the GitHub mirror) or build in Xcode.
  • Run on the live DB (via /api/debug/query); the rewritten queries rely on it returning 0:
    SELECT COUNT(*) FROM observations o LEFT JOIN watch_listings wl ON wl.id = o.watch_listing_id WHERE wl.id IS NULL OR wl.watch_id <> o.watch_id OR wl.listing_id <> o.listing_id
  • Commit-message benchmark numbers were measured on a9c5a85; #18/#19 don't touch the measured paths.

Checks on the tip: typecheck ✓ · npm test 321/321 ✓ · build ✓ · npm audit --omit=dev 0 ✓ · ScoutKit 69/69 ✓ · smoke tests with auth off and on ✓.

🤖 Generated with Claude Code

Performance pass across the server, web frontend and iOS app. Every item was measured, independently verified, implemented as its own commit (49, bisectable, each typechecks), and code-reviewed. ## Results (seeded steady-state DB: 225k observations, 6k listings; back-to-back on the same host) | | before | after | |---|---|---| | `/api/dashboard` | 254 ms · 452 KB | 15 ms · 55 KB on the wire | | `/api/listings` (500 rows) | 251 ms · 443 KB | 16 ms · 54 KB | | listing detail | 291 ms | 1.8 ms | | 4 concurrent dashboard requests | 1017 ms | 72 ms | | analytics 30 d / 180 d | 113 / 663 ms | 87 / 487 ms | | scan, 100 listings (grouped, AI off) | 98 ms | 14 ms | | scan, 300 listings (AI on) | 357 ms | 141 ms; worst event-loop stall 256 → 54 ms | | readiness / connector history at real cadence volume | 45 / 367 ms | 0.15 / 0.17 ms | | daily prune | ~400–900 ms | ~14–70 ms | | main JS download | 241 KB, revalidated each load | 66 KB br, cached immutable | | startup / idle RSS / RSS after load | 425 ms · 158 MB · 577 MB | 212 ms · 90 MB · 354 MB | | `/api/export` peak memory | +1.3–1.7 GB | ~+65 MB (streamed, byte-identical) | ## Server - Exact query rewrites driven from `watch_listings` with indexed observation seeks (watch stats, baselines, analytics, watch analytics, listing-detail history); each has an old-SQL oracle test. - `migrations/030_perf_indexes.sql`: `observations(listing_id)`, `connector_runs_recent`, partial `scans_unfinished`; drops three unread indexes. - Scan path: no rewrites of unchanged AI-cache rows (≈470 KB WAL/scan → 0), one relevance flush per pass, bounded worker pool for Jev calls, per-bucket score stats, WAL checkpoints on a worker thread. - **Behavior change (approved):** standing deals that cannot alert and have a fresh (< 6 h) verdict are not re-verified each scan; anything alertable is still verified immediately. - OLX: compressed offers-API responses; verification uses the API description instead of downloading the offer page (standing OLX deals whose text differs are re-verified once after deploy). - Shared local Chromium with idle close; marketplace CDN images answered with a 1×1 GIF in renders. - Lazy-loaded playwright-core and MCP SDK; stable server chunk names. - br/gzip compression for `/api/*` (SSE and `/mcp` excluded); precompressed static assets; immutable caching for hashed assets. - `/api/dashboard?top=N` compact response for widgets (default response unchanged). - **MCP (approved):** compact JSON; `scout_dashboard` returns 20 listings plus `listingsTruncated`. ## Web - One EventSource per tab (fixes the 3-tab HTTP/1.1 connection hang), released while hidden and reconciled on return. - Server events refetch only what the visible view shows; triage patches rows locally with one trailing reconcile; Logs appends streamed lines. - Memoized rows and search results, module-level formatters, preloaded route chunks (first route visit ~300 → ~15 ms), parallel boot requests. ## iOS - Widgets: `?top=12` (463 KB → 10 KB), reload only when drawn content changes (176 → 10 reloads in a 2 h simulation), reuse the app's fresh snapshot, download only drawn photos. - App: screens reload only for relevant events and not on every re-appearance, one refresh per foreground, parallel detail loads, downsampled and cached images, ISO 8601 parsing ~45× faster. ## Before merging - **The iOS app and widget targets were not compiled** (no Xcode on the dev host). ScoutKit builds and passes on Linux (69 tests), all changed Swift files parse, and app logic was type-checked against stubs, but please run the iOS workflow (`workflow_dispatch` on the GitHub mirror) or build in Xcode. - Run on the live DB (via `/api/debug/query`); the rewritten queries rely on it returning 0: `SELECT COUNT(*) FROM observations o LEFT JOIN watch_listings wl ON wl.id = o.watch_listing_id WHERE wl.id IS NULL OR wl.watch_id <> o.watch_id OR wl.listing_id <> o.listing_id` - Commit-message benchmark numbers were measured on a9c5a85; #18/#19 don't touch the measured paths. Checks on the tip: typecheck ✓ · `npm test` 321/321 ✓ · build ✓ · `npm audit --omit=dev` 0 ✓ · ScoutKit 69/69 ✓ · smoke tests with auth off and on ✓. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
watches() aggregated every observation row (225k at steady state) through
observations_watch_time with per-row listing, watch and relevance lookups and
two temp b-trees. The stats now run once per watch_listings association with
listing-level filters applied per association and the earliest in-range
observation read by an ordered LIMIT-1 probe of observations_watch_listing
(MATERIALIZED CTE, HAVING keeps empty variants out). Exact because every
observation's watch_listing_id is its (watch_id, listing_id) association. A
new test compares the query against the previous SQL as an oracle, and the
audit equivalence harness reported JSON-identical dashboard, watches,
listings, listingDetail and analytics output across scans, edits, prune and
watch deletion.

bench.ts on baseline.db, warm median before -> after:
dashboard 256 -> 16 ms, watches.active 258 -> 11 ms, watches.all 292 -> 11 ms,
listings.web-default 258 -> 16 ms, listings.ios-default 247 -> 12 ms,
listingDetail.busy 302 -> 18 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
listingDetail called allWatches() and used only one watch's readiness and
variant groups, so every detail open (web drawer, iOS, MCP, the variant
change response) built stats, typicals and deal counts for every watch.
watches() now takes an optional watch id that restricts the watch row, the
stats CTE, the typical and the deal-count queries through separate fixed
statement shapes with the id bound as a parameter. listingDetail and the
POST /api/watches response use the new watchById(). Tests assert watchById
equals the matching allWatches() entry (including an archived watch) and
that listing detail still carries the watch's variant groups.

bench.ts on baseline.db, warm median (after BE-READ-1) before -> after:
listingDetail.busy 18.2 -> 12.1 ms, listingDetail.busy-nowatch 18.3 -> 9.9 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The history statement filtered with '(? IS NULL OR watch_id = ?)', which kept
SQLite from using observations_watch_listing: every listing detail ran a full
SCAN of observations plus a temp b-tree. It is now split into
'watch_id = ? AND listing_id = ?' (index seek) and, for listings with no
association, 'watch_id IN (SELECT id FROM watches)', which is exact because
observations.watch_id is an enforced foreign key. A test compares both forms
against the previous query.

bench.ts on baseline.db, warm median (after BE-READ-1 and M1) before -> after:
listingDetail.busy 12.1 -> 2.1 ms, listingDetail.busy-nowatch 9.9 -> 2.6 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
analytics() started from a full SCAN of observations and, per in-window row,
looked up the watch, listing and association and ran the relevance subquery.
The FROM clause now starts from watches -> watch_listings -> listings and
reaches observations through a range seek on observations_watch_listing
(watch_id, listing_id, observed_at). The SELECT list and GROUP BY are
unchanged, so rows (including order and the bare columns taken from the
MAX(observed_at) row) are identical; the old query already inner-joined the
association. A test checks rows and order against the previous SQL for
several day/watch/marketplace filters. The JS single pass and
setReturnArrays parts of the finding are left for later.

bench.ts on baseline.db, warm median before -> after:
analytics.30d 119 -> 84 ms, analytics.180d 656 -> 501 ms,
analytics.olx-30d 60 -> 39 ms, analytics.watch-30d 14.8 -> 12.2 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
watchAnalytics ran the same filtered observation scan twice (a COUNT/MIN/MAX
bounds pass and the daily GROUP BY), evaluating relevance and shipping per
observation. It now runs one daily statement driven from the watch's
associations with range seeks on observations_watch_listing, takes the total
from per-group COUNT(*) (not a min/max aggregate, so the bare price and
typical columns still come from the MAX(observed_at) row), and reads the
first/last observation with ordered LIMIT-1 probes only when rows exist. A
test compares daily rows and bounds against the previous SQL for every watch
and range, including an empty window; the audit equivalence harness
reported identical output for every watch at 7/30/180 days across scans,
edits, prune and deletion.

bench.ts on baseline.db, warm median before -> after:
watchAnalytics.30d 14.7 -> 9.3 ms, watchAnalytics.180d 96.9 -> 53.6 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The daily prune deletes expired listings only when no observation still
references them, plus the ON DELETE CASCADE child lookup. With no index
leading with observations.listing_id, each candidate listing scanned the
whole observation index twice. The new migration 030_perf_indexes adds
observations(listing_id) (listing_id never changes, so trailing-row updates
do not maintain it) and drops observations_scan and
market_price_observations_scan: scan_id is never filtered or ordered on (it
is only read as IS NULL on the row, and it is not a foreign key), and
explain.ts over the full workload showed no plan using either index. The
migration list/count assertions in tests are updated.

pruneRetention, checkpointed copy, 5 trials inside BEGIN/ROLLBACK, median:
baseline.db 392 -> 13.6 ms (listings delete 382 -> 4.3 ms);
baseline-cadence.db 649 -> 77 ms (listings delete 609 -> 7.1 ms).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
At the volume the configured cadence produces (~560k connector_runs, ~550k
scans), the connector history page sorted the whole table, readiness walked
the full scans status index, and every dashboard and readiness probe counted
connector_runs per source.

- Migration 030 adds connector_runs_recent (started_at DESC, id DESC) and a
  partial scans_unfinished index, and drops connector_runs_finished_at. grep
  and EXPLAIN QUERY PLAN over the full workload showed that only
  connectorRunsPage's COUNT(*) used it (as the smallest index), and that
  count now comes from the cache; the prune DELETE ranges on started_at and
  now uses connector_runs_recent.
- Per-source counts are cached: recordRun (the only INSERT, always in
  autocommit) increments them, or drops the cache if it ever runs inside an
  open transaction; the prune DELETE (the only DELETE) drops the cache; a
  commit from another connection changes PRAGMA data_version and forces a
  recount. connectorRunsPage takes its total from the same counts.
- readiness() calls getConnectors(false), which skips the counts.
- The startup recovery UPDATE adds a redundant status IN (...) so it can use
  the partial index.
Tests cover counts after inserts, a rolled-back insert, a second-connection
insert and prune, plus the new plans.

bench.ts on baseline-cadence.db, warm median (cold) before -> after:
connectors 22.5 -> 0.04 ms, readiness 46.7 (65) -> 0.15 (11.5) ms,
connectorRunsPage 410 -> 0.17 ms, dashboard 39.2 -> 16.3 ms.
pruneRetention (cadence, checkpointed, median of 5): 77 -> 67.5 ms
(connector_runs delete 33 -> 2.4 ms).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The iOS widget downloads the full dashboard (500 feed rows, every watch and
connector) to show six deals. GET /api/dashboard?top=N (N an integer from 1
to 50) now returns the same stats, lastScan and lastScanTime computed over
the same 500-row feed, listings reduced to the top N rows after the widget's
own filter and stable order (WidgetSnapshot.make: hidden, AI-filtered and
passed rows dropped; deal strength desc, belowTypical asc, observedAt desc),
and empty watches and connectors (getConnectors is skipped). Every key stays
present. A missing, non-integer or out-of-range top returns the full
dashboard unchanged; the MCP tool is unchanged. Tests compare the compact
form against a reference implementation of the widget order and the full
response, and cover the query parser.

baseline.db, 20 calls: dashboard 15.1 ms, 451 KB body, 1.16 ms stringify;
dashboard?top=12 15.1 ms, 10.8 KB body, 0.04 ms stringify.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
watchBaselines ran a ROW_NUMBER() window and a MIN(observed_at) GROUP BY
over every observation of the watch on each source scan, so its cost grew
with the 180-day history. It now walks the watch's watch_listings rows and
takes two LIMIT-1 seeks per association into observations_watch_listing
(still pinned with INDEXED BY) for the newest and oldest in-bounds
observation, keeping the relevance and shipping predicates. Bucketing stays
in JS with the same 400 cap, non-positive price skip and newest-first
order; firstObservedAt is the minimum per variant.

A new service test compares it with the old SQL as the oracle across price
bounds, shipping_only, ai_relevance off, irrelevant relevance rows, variant
groups, a >400-listing bucket and zero prices. On baseline.db the result
equals the original implementation for all 20 watch/permutation cases.

Measured (baseline.db, warm medians): watchBaselines iphone-13 81.4 -> 3.4
ms, rtx-3080 73.3 -> 2.7, gtx-1660 61.7 -> 2.2, kindle 32.7 -> 0.9;
bounded 20-69 -> 1.5-9.3 ms. Bench phase per scan: grouped 79-108 -> 3.5-4.1
ms, ungrouped 42 -> 1.5 ms; scan.grouped.ai-off.100 92.5 -> 16.5 ms,
scan.ungrouped.ai-off.100 77.5 -> 17.0 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
scoreDeal re-filtered the bucket and recomputed its median and MAD (two
sorts of up to 400 prices) for every stored listing and every relevance
gate call. scoring.ts now has priceStats(prices) (usable prices, median,
MAD memo keyed by typical) and scoreDealFromStats(stats, price, options)
with scoreDeal's exact arithmetic; scoreDeal builds the stats and
delegates, so other callers are unchanged. watchBaselines attaches the
stats to each finished bucket and dealScore uses them.

A randomized test (7,500 cases: NaN/Infinity/zero prices, overrides,
pooled spreads, sensitivity, readiness options, one shared stats object
per bucket) checks the result equals the verbatim legacy scoreDeal.

Measured: per call 400-price bucket 98.6 -> 0.95 us, 100 prices 17.5 ->
0.17 us, 30 prices 5.6 -> 0.15 us. Bench storeListing phase per scan:
ungrouped.ai-off.100 11.0 -> 2.7 ms, grouped.ai-off.300 17.3 -> 10.9 ms;
scan.ungrouped.ai-off.100 median 17.0 -> 8.5 ms, grouped.ai-off.100
16.5 -> 14.0 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Relevance: gate-skipped and budget-exhausted listings were re-upserted as
'unknown' with the same hash, model and reason on every scan, only moving
checked_at, in one BEGIN/COMMIT per batch of six. The per-listing cached
row is now kept in scope and the write is skipped when it already matches
(input_hash, model, status 'unknown', relevant, reason, no error). The Jev
error path and every real decision still write. All collected relevance
rows are flushed in a single synchronous transaction after the pass.

Verification (BE-WRITE-4 part 2, BE-NET-M1 (a) and (b)): an already
captured snapshot state is no longer flipped to 'pending' and back;
updateListingDetailSnapshot only writes when status or input hash change;
the non-error saveDescriptionVerification UPDATE is guarded with IS NOT
comparisons, so a cache hit or the no-key path writes nothing. Only the
fresh-AI path marks the snapshot pending before its await. Each
candidate's synchronous writes run in one transaction. Error and fallback
writes stay unconditional, so the 6-hour retry timing is unchanged.

Behavior: listing_relevance.checked_at and
listings.ai_description_verification_at stop advancing on rows whose
content did not change; no client renders either.

Measured (audit probes): steady relevance pass 94 upserts / 16
transactions / ~470 KB WAL -> 0 / 0 / 0 KB; verification WAL per standing
cached candidate 33.6 -> 0 KB. probe.ts per scan (WAL frames, commits):
grouped.ai-off.100 366/61 -> 288/24, grouped.ai-stub.100 624/85 ->
313/32, ungrouped.ai-off.100 354/40 -> 281/17, ungrouped.ai-stub.100
613/70 -> 288/27, grouped.ai-off.300 837/155 -> 607/63.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
processDealCandidates verified every >=20% candidate before notifyDeal,
whose first check returns for hidden listings. A hidden standing deal
therefore re-downloaded and re-parsed its detail page (and could spend an
AI call and write a snapshot) on every scan for nothing. The hidden check
now runs before verification; alerts and digests are unchanged because
notifyDeal already ignored hidden listings.

Measured with the new service test: a hidden standing deal costs 0 detail
fetches, 0 AI calls and 0 snapshot writes per scan (before: 1 fetch per
scan, 1 AI call on the first scan). The bench fixtures contain no hidden
candidates, so their fetch counts are unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add mapPool(items, concurrency, fn) to server/limiter.ts: workers take
items in index order, each call's synchronous prefix runs before the next
item starts, and results keep the input order.

filterListingsByAiRelevance used fixed slices of jevCheckConcurrency()
awaited with Promise.all, so each slice waited for its slowest Jev call and
cache hits held slots. It now maps the same per-listing body through
mapPool; cache reads, the gate, budget counting and the in-flight dedup
still run synchronously before the first await in index order, and the
relevance rows are still flushed in one transaction after the pass.

assignVariantsWithJev (BE-NET-M2, BE-WRITE-8) awaited up to 10 uncached
Jev variant calls one after another before the page was stored. The
candidates are now deduplicated by input hash (a sequential repeat would
have hit the cache the first answer wrote), the distinct questions run
through mapPool with jevCheckConcurrency(), and resolver.setJev is applied
in candidate order after the pool. Only the order of jev_shadow_log rows
changes; a repeat of a hash whose call failed is not asked a second time
in the same scan.

Measured: bench-relevance.ts (real filterListingsByAiRelevance, stubbed
lognormal Jev latency) 100 cold listings 2.7-3.0 s -> 1.3-1.8 s; 70 cached
+ 30 new interleaved 3.4-3.9 s -> 1.16-1.26 s. assignVariantsWithJev with
10 uncached candidates at 100 ms per call: 1005 -> 201 ms, same 10 picks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Typo-variant watches ran SELECT COUNT(*) FROM scans WHERE watch_id = ? AND
watch_kind = ? after each source's fetch. watch_kind is not in
scans_watch_status, so every retained scan row of the watch was read, once
per source. runWatch and runMarketWatch now compute the ordinal lazily once
per run (every source's createScan runs before the first await and the
running set blocks a concurrent run, so all sources saw the same value).
The count uses the covering WHERE watch_id = ? form unless a watch of the
other kind has the same id, in which case the kind-filtered count is kept.

Test: a 2-source typo watch counts once, the value equals the kind-filtered
count, both sources fetch the same variant batch, and a colliding id falls
back to the filtered count.

Measured on baseline-cadence.db: steam-deck (27,798 scans) 12.1 ms warm /
68 ms first -> 1.06 / 1.35 ms; kindle (10,424) 5.4 / 11.0 -> 0.42 / 0.44
ms, now once per watch cycle instead of once per source.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every scan turned each >=20% listing into a candidate and fetched its
detail page for verification before notifyDeal decided that nothing could
be sent (already alerted at this price, no due channel or digest).

notifyDeal's write-free part is now planDealNotification: hidden check,
digest eligibility plus the digest's own shouldAlert, and per channel the
minimum priority and digest routing, alert state, latest delivery
due/pending logic and the alert sequence and keys. notifyDeal calls it
after verification (state can change during the awaits) and then does the
same writes; a channel reads only its own state, so planning all channels
before writing decides exactly what the interleaved loop did.

processDealCandidates plans each verification candidate first and skips it
entirely (no page fetch, AI call or writes) when the plan has no channel
and no due digest, the listing's stored verdict is from the active model
with a finished status (pass, reject, or unknown without an error), the
verdict is younger than 6 hours (user-approved bound), and this listing
state (title, price, condition, location, url) was already captured. Any
change that makes it alertable (a >=5% drop, a higher priority, a new
channel) verifies as before, so every alert is still checked against a
freshly fetched page. The 6-hour bound uses
listings.ai_description_verification_at, which survives restarts: a
re-verification that finds the same verdict now renews a timestamp older
than 6 hours, so a standing deal is re-fetched about every 6 hours.

Tests: a standing deal is not fetched while fresh and non-alertable, a new
channel and a price drop re-verify and alert, a verdict older than 6 hours
is re-verified once without an AI call, a new deal with no channel is
verified once, and a retitled listing is verified again.

Measured: bench-verify-refetch.ts (3 standing deals, 5 scans) detail
fetches 3/6/9/12/15 -> 3/3/3/3/3 (7.2 -> 1.4 MB), AI calls 3 and webhooks
3 unchanged. bench.ts net.publicPage per scan: grouped.ai-stub.100 14-17
-> 2, grouped.ai-stub.300 44-48 -> 4; verify phase 15.6 -> 4.0 ms and
47.5 -> 17.7 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
node:http2 sends no Accept-Encoding on its own, so the OLX offers API (every
OLX scan search, typo variant, manual search and research detail check) was
served identity-encoded JSON. fetchOlxApiSingleRequest now sends
'accept-encoding: gzip, deflate, br' and decodes the body by its
Content-Encoding (gzip, zlib or raw deflate, brotli) with a 16 MB
maxOutputLength bomb guard. Identity and unknown encodings pass through; a
corrupt or oversized body yields json=null, the same fail-closed path as a
non-JSON challenge page. Session reuse (step 2) is not done.

Tests run the real function against a local TLS HTTP/2 server with a
freshly generated self-signed certificate (no live requests).

Measured with the real function against a local h2 server serving a
50-offer payload and honoring Accept-Encoding: wire bytes per page
100.1 KB -> 18.8 KB; client+server CPU per request 3.2-3.3 -> 3.0-3.2 ms
(unchanged within noise).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
parseOlxOffer dropped offer.description and all but one photo, so every
high-priority OLX verification fetched the www.olx.pl HTML offer page (0.3-1
MB, or a Chromium render when fenced) only to extract the same text. The
parser now records { description, imageUrls } in a module-level
WeakMap<NormalizedListing, OlxDetailHint> exposed through olxDetailHint().
The description goes through the new listingDescriptionText(), the same
normalization parseListingDescription applies to a page candidate (text
content, 8,000-character cap, boilerplate filter); photos are the first 12
gallery URLs at 1000x750, filtered like parseListingImageUrls. Nothing is
added to NormalizedListing, so the text never reaches notification payloads
or API JSON; a cloned listing has no hint and falls back to the page fetch.

verifyHighPriorityDealOnce uses the hint when it carries a description (the
Jev gallery takes its first 3 photos) and otherwise keeps today's page fetch,
including the no-description 'unknown' semantics. The S2 freshness skip in
processDealCandidates is unchanged and still runs first; the hint only
removes the network fetch from verifications that do run, which covers the
rejected/no-description alertable standing deals S2 left refetching.

Behavior change (accepted by the item): OLX verification text is now the
API description rather than the first HTML candidate, so a standing OLX
deal whose normalized text differs gets a new snapshot state and one
re-verification after deploy.

Measured (bench-verify-refetch with the description the real API carries,
3 standing OLX deals, 5 scans, 500 KB detail page): passing deals 3 page
fetches (1.4 MB) -> 0; rejected deals with a webhook 15 fetches (7.2 MB)
-> 0. AI calls unchanged (3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
renderInBrowser launched a new Chromium for every render (every page fetch
for a marketplace with an imported session, plus 403/429 fallbacks) and let
every thumbnail download although only page.content() is read.

For a local launch (no SCOUT_BROWSER_WS) the browser is now launched lazily
and shared: each render still gets its own context with the same options,
storageState, route guard and WebSocket block, and closes only that context.
The browser closes 20 s after the last render, is dropped on 'disconnected'
and on launch failure (so the next render relaunches), and is closed from
the Fastify onClose hook. The Browserless/CDP path still connects per render
because Browserless's TIMEOUT is a hard session limit.

In the route handler, after the unchanged host guard, image requests to a
marketplace image CDN (isMarketplaceImageUrl) are fulfilled with a 1x1 GIF,
so no onerror handler rewrites src and the HTML is unchanged. Images from
other hosts (challenge platforms) still load. This applies to both paths.

Measured with the real local Chromium (chromium-1140), 5 sequential renders
of a local data: page: 5 launches -> 1; mean render 1039-1051 -> 876-878 ms;
Chromium CPU 1270-1320 -> 520-530 ms (including the one launch and close).
With 24 CDN images per page, 120/120 image requests were answered locally
by the GIF with no network request. A stub-browser test covers sharing,
the idle close, relaunch after disconnect and after a failed launch, and the
route decisions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
service.ts imported playwright-core eagerly and index.ts imported the MCP
Streamable HTTP transport and ./mcp, although most instances never render a
page and MCP loads only when an agent connects.

service.ts now imports only the Browser/BrowserContext types. A memoized
loadPlaywright() imports playwright-core inside renderInBrowser after the
availability check (SCOUT_BROWSER_WS set, or a Chromium executable found),
so hosts without a browser never load it, even on 403 fallbacks. POST /mcp
awaits a memoized import of ./mcp and the SDK transport, with the rest of
the handler unchanged; createScoutMcpServer stays synchronous. A failed
import is not memoized and answers the same JSON-RPC 500 as other handler
errors. The Vite SSR build now emits dist-server/assets/mcp-*.js and
service-*.js chunks, which the Dockerfile already copies with dist-server.
Known trade-off (per the verifier): an MCP call that first loads the chunk
after a rebuild but before the restart fails until the restart.

Measured with node dist-server/index.js on 127.0.0.1 (SCOUT_AUTH=off, temp
pre-migrated SCOUT_DB_PATH, 4-5 runs each): time to first /api/health 200
413-462 -> 164-186 ms; RSS 4 s after ready 144.5-145.2 -> 74.8-76.7 MB.
The first /mcp initialize takes 86 ms instead of 19 ms and brings RSS to
93.6 MB; the first render pays ~250 ms once for the playwright import.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add a dependency-free async onSend hook (server/compression.ts) for /api/*
routes. When the client accepts br it uses brotli q4 in text mode, otherwise
gzip level 6. It applies to JSON and text bodies of at least 1 KB and to
stream payloads, and adds Vary: Accept-Encoding. It skips 204/206/304,
responses that already carry Content-Encoding, binary types (snapshot images,
the SQLite debug snapshot) and SSE. Hijacked /events and /mcp never reach
onSend. Clients without Accept-Encoding still get identity bodies, and the
decoded bytes are identical (covered by the new tests).

http-bench on baseline.db (loopback, Accept-Encoding: gzip, deflate, br),
wire bytes before -> after: dashboard 452.2 -> 55.4 KB, listings (500 rows)
443.2 -> 53.9 KB, listings iOS 44.4 -> 6.8 KB, market-watches 74.1 -> 11.3 KB,
connector-runs 19.1 -> 2.9 KB, notifications 14.2 -> 2.7 KB. Loopback medians
move within noise plus about 2-4 ms of compression CPU (dashboard
17.7 -> 21.5 ms, listings 25.5 -> 20.8 ms).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add scripts/precompress.mjs (node:zlib only), which writes .br (q11, text
mode) and .gz (level 9) siblings for .js/.css/.html/.svg files of at least
1 KB under dist/, and run it at the end of `npm run build`. The Dockerfile's
build step runs the same script and copies all of dist/, and
/api/system/update runs npm run build, so both pick it up.

fastifyStatic now uses preCompressed: true and a setHeaders callback that
marks files under dist/assets as `public, max-age=31536000, immutable`.
Vite content-hashes those filenames, so a new build can never be served
stale. index.html, favicon.svg and the SPA fallback keep max-age=0. globIgnore
keeps the .br/.gz files from being registered as routes of their own. The
/api compression hook does not touch static files, so nothing is encoded twice.

Measured with http-bench on the rebuilt dist (Accept-Encoding: gzip, deflate,
br): index.js 241.5 KB -> 64.1 KB on the wire, index.css 67.9 KB -> 10.3 KB,
cache-control `public, max-age=0` -> `public, max-age=31536000, immutable`
(index.html unchanged at max-age=0). Across the build: 468,308 B -> 116,337 B
br / 134,288 B gzip for 14 files. A warm reload no longer revalidates hashed
assets. Loopback latency is unchanged (index.js 0.78 -> 0.67 ms).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
text() in server/mcp.ts now emits JSON.stringify(payload) without 2-space
indentation. The data is unchanged, and toolError already used compact JSON.
POST /mcp is hijacked, so the /api compression hook does not reach it and
dropping the whitespace is the only byte saving there. The indentation also
cost agents context tokens. The MCP test now asserts that tool text is compact.

Tool text on baseline.db (in-process MCP client), chars before -> after:
scout_dashboard 595,557 -> 461,998 (-22%; JSON-RPC string 664.9 -> 514.3 KB),
scout_watches 13,190 -> 8,338 (-37%), scout_listings (20 rows)
23,618 -> 18,459 (-22%), scout_listings pageSize 50 58,267 -> 45,394,
scout_connectors 1,188 -> 810, scout_readiness 354 -> 254.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
scout_dashboard now returns { ...dashboard, listings: first 20,
listingsTruncated: true when the feed held more }. Its description tells
agents to page the full feed with scout_listings. The user approved this
contract change. Stats, watches, connectors and lastScan are unchanged, and
the REST /api/dashboard still returns the full 500-row feed. The new MCP test
covers the exact-20 (not truncated) and 27-row (truncated) cases against
service.dashboard().

scout_dashboard tool text on baseline.db: 461,998 -> 27,686 chars (JSON-RPC
string 514.3 -> 31.1 KB). Against the pretty-printed original (595,557 chars),
the change is -95%, roughly 150k -> 8k tokens per call. The server time is the
same dashboard() call (26-32 ms in-process).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
/api/export used to build every table with .all() into one object that
Fastify stringified into one ~75 MB string, synchronously on the event loop.
The route now sends service.exportStream(). This is Readable.from() over the
new async generator exportChunks(), which:
- opens a separate read-only DatabaseSync on the main file (from PRAGMA
  database_list) and runs BEGIN, so every table comes from one snapshot and
  no transaction is held on the main connection;
- yields the same JSON text in ~64 KB chunks, with rows from
  StatementSync.iterate() in the existing key order, then the settings with
  the same redaction, and an event-loop turn between chunks;
- rolls back and closes the reader in finally, which also runs on client
  abort (Fastify destroys the stream, and pipeline/Readable.from end the
  generator);
- falls back to JSON.stringify(exportData()) for a database without a file
  or a Node without StatementSync.iterate().
exportStream() destroys the stream after 10 minutes, so a stalled client
cannot pin the snapshot and block WAL checkpoints from resetting.
exportData() stays for existing callers and now shares EXPORT_TABLES and the
settings redaction with the stream. With the /api compression hook the
export goes out as br or gzip.

The response is now chunked (no Content-Length), and an error mid-stream
truncates the body instead of returning a JSON 500. The decoded bytes are
identical: the new tests assert text === JSON.stringify(exportData()) (with
exportedAt aligned) on a seeded multi-chunk DB and an empty DB, and also cover
snapshot isolation, release on early return, the timeout, and the fallback.

Measured on the new path only (real server, baseline.db copy with the schedule
neutralized; the old path is not re-run on this host, but the harness baseline
recorded +1.3-1.7 GB server peak and ~0.75-0.86 s /api/health stalls):
server VmHWM 97 MB at ready -> 139 MB after the identity export (+42 MB) and
161 MB after br and gzip. Export 75.5 MB in 0.81-0.94 s. /api/health max
latency during the export 6.7-19 ms (p50 about 1 ms). Wire bytes: identity
75.5 MB, br 6.7 MB, gzip 8.8 MB, with the same decoded sha256 for all three.
Aborting after 5 MB leaves no reader: a TRUNCATE checkpoint then reports busy=0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SQLite's default auto-checkpoint (1000 frames) ran inside whichever commit
crossed the threshold, on the event loop, so every 2-3 scans one scan commit
paid the page write-back and fsyncs (100-300 ms on this host). The new
startWalCheckpointer() in server/db.ts starts an inline-eval'd CommonJS worker
(no build step) with its own node:sqlite connection (busy_timeout 5000,
synchronous=NORMAL). The worker runs PRAGMA wal_checkpoint(PASSIVE) every 2 s,
which never waits on or blocks the main connection and is a no-op when every
frame is already backfilled. The WAL only resets when a write starts on a
fully backfilled log, so while writes keep landing the worker retries every
100 ms until it catches up. It never triggers on the -wal file size. The main
connection keeps wal_autocheckpoint=10000 as a safety net and sets
journal_size_limit=64 MB, so a WAL inflated by a burst is trimmed on reset.
If the worker errors or exits, the main connection logs a warning and goes
back to wal_autocheckpoint=1000. index.ts starts the worker for the file-backed
DB and stops it in onClose before the final TRUNCATE checkpoint. ScoutService
and the existing tests do not use it. Durability is unchanged
(synchronous=NORMAL); only the thread that runs checkpoints changes. The WAL
file high-water mark grows to 14-33 MB under load (was about 6 MB).

Gate measurement (scratch ckpt-gate.ts: real runWatch 300-listing grouped
OLX scans on a baseline.db copy, 2 ms heartbeat for event-loop lateness),
inline -> worker, final run (earlier repeat runs agree):
- scans 2 s apart (15): scan median 132.9 -> 38.4 ms, mean 107.6 -> 40.4,
  max 255 -> 52.5; transaction phase median 122.9 -> 15.3; worst main-thread
  stall 256 -> 54 ms; stalls over 100 ms 8 -> 0.
- dense scans 300 ms apart (30): scan median 95.5 -> 27.3 ms, mean
  116.3 -> 31.9, max 297 -> 57.6; worst stall 297 -> 59 ms; stalls over
  100 ms 15 -> 0.
No worker errors or SQLITE_BUSY. The residual cost on the main thread is the
WAL header sync on each reset. The new test runs 2,000 commits while the
worker checkpoints every 20 ms, then checks that the WAL restarts after idle
and that stop() restores the default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
F1: /api/export opens its read-only reader, runs BEGIN and reads settings
synchronously in exportStream(), before the route sets any header, so an
open failure throws in the handler and becomes a plain 500 instead of a
JSON error body labelled Content-Encoding: br/gzip. The snapshot release is
idempotent and also runs on stream close, so a stream destroyed before its
first read still closes the reader. The compression hook also remembers the
replies whose stream it is compressing and, if that stream fails before
headers are sent, drops content-encoding (on the reply and the raw
response) before handling the error payload.

F2: mapPool stops taking new items once any call rejects; calls already in
flight finish and the pool rejects with the first error, so a failed
relevance or variant pass no longer keeps spending AI calls.

F3: onClose clears the scheduler and other intervals first, then calls
closeBrowser({ final: true }), which refuses later Chromium launches and
waits at most 5 s for the launch/close, so the WAL checkpoint and
db.close() always run even with a wedged browser.

F4: the server SSR build emits stable chunk names (index.js, mcp.js,
service.js) via a vite config branch on isSsrBuild, so a running server's
lazy import('./mcp.js') still resolves after an in-place rebuild that
fails before the restart. The MCP SDK stays lazily loaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
src/events.ts is now the tab's only /events stream. App.tsx subscribes to
its refresh events through subscribe() and follows the online/offline
indicator through the new subscribeStatus(), instead of opening its own
EventSource next to the one SearchPage opened. The shared source closes
when its last listener leaves, is replaced when the browser gives up on it
(CLOSED, e.g. a 401 or proxy error; retried after 5 s), and closes after a
tab has been hidden for 45 s. It reopens when the tab is shown, and status
listeners get a `reconnected` flag after any gap (hidden reopen or native
auto-reconnect after a server restart) so App reconciles once.

Why: after one visit to Search every tab held two never-closed streams, so
three tabs used all six HTTP/1.1 connections Chrome allows per host and
every further request hung; each event was also delivered twice.

Measured (mock API + headless Chromium, same host, back to back):
- 3 tabs after visiting Search: 6 -> 3 open SSE connections; probe fetch
  /api/settings: hung until the 8 s abort -> 200 in 5 ms.
- Tab hidden 50 s: SSE connections 1 -> 0 (reopened on visible with one
  reconciling dashboard GET).
- Search progress still streams (first rows 332 -> 363 ms, same run noise),
  indicator still flips offline/online across a server restart, login gate
  unchanged (no stream until signed in).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Replace App's refresh() (every scan/watch/notification/listing-action/
verification event refetched the 462 KB dashboard, plus the Listings page,
in every tab and view, visible or not) with a ref-based dirty scheduler.
Events mark resources stale in refs (liveRefresh.ts maps event -> resource),
so they no longer re-render App by themselves. flush() runs 100 ms after an
event, on view change and when the tab becomes visible; it does nothing in
a hidden tab and refetches only what the visible view renders:
- Overview: dashboard. Connectors: GET /api/connectors (new api.connectors,
  0.8 KB) merged into data.connectors.
- Listings / Analytics / Market research: bump only that page's key; pages
  that are not mounted just clear the flag (they fetch on mount).
- Analytics and Watches load the dashboard for watch names only until it
  first loads, and Analytics again after a 'watch' event.
- Settings, Logs, Search: nothing. Verification events: nothing (no list
  payload carries verification columns). Research scans: Market research
  only.
The first dashboard load is no longer serialized onto /listings, /logs,
/settings, /connectors or /search deep links. A dashboard refetch is
deferred while one is in flight (the shared GET dedupe would return the
stale response) and rescheduled when it lands. Watch mutations mark the
dashboard stale and refetch only the Watches list on the Watches view
(PATCH + /api/watches instead of PATCH + dashboard + /api/watches); other
views flush. A stream reconnect marks everything stale.

Measured (mock API with real seed payloads, headless Chromium, 5 'scan'
events 1.5 s apart; requests and main-thread task ms, before -> after):
- Overview: 5 dashboard, 268 ms -> 5 dashboard, 128 ms (request count
  unchanged)
- Listings: 5 dashboard + 5 listings, 501 ms -> 5 listings, 277 ms
- Analytics: 5 dashboard + 5 analytics, 103 ms -> 5 analytics, 43 ms
- Connectors: 5 dashboard (2.3 MB), 205 ms -> 5 /api/connectors (4 KB), 58 ms
- Settings / Watches / Logs / Search / Market research: 5 dashboard each,
  42-460 ms -> 0 requests, 2-31 ms
- Verification events (Overview/Listings): 5 dashboard (+5 listings) -> 0
- Hidden tab, 5 scans: 5 dashboard (+5 listings on Listings) -> 0, then
  exactly 1 on becoming visible
- Deep links /listings, /logs, /settings, /connectors: 1 extra dashboard
  GET -> 0
- Pause a watch on Watches: PATCH + GET /api/watches, busy 335 ms; the
  dashboard is fetched once on returning to Overview

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A 'listing-action' event (a hide or decision save in any tab) no longer
triggers the generic refetch. App patches the matching dashboard rows by
marketplace key (decision, hidden) and marks the dashboard, Listings page
and Analytics stale, flushing through the live-refresh scheduler 2 s after
the last triage event of a burst. ListingsPage subscribes to the same
event, patches its fetched page, drops rows that left its decision or
visibility filter and lowers the total; it also applies its own toggle
result straight away (toggleListingHidden now returns the saved action).
The note is not in the event payload and arrives with the reconcile.

Also derive the Listings page number from a key of the current filters
instead of resetting it in a separate effect (FE-DATA-8): the old effect
ran after the fetch effect had already requested the new filter with the
old page, so every filter change on page >= 2 sent one wasted 500-row
request (not cancellable server-side, since the GET transport is shared).

Measured (mock API with real seed payloads, headless Chromium):
- 5 hide clicks 400 ms apart on Overview: 5 PATCH + 5 dashboard GETs
  (2.3 MB), 798 ms task -> 5 PATCH + 1 dashboard GET 2 s after the burst,
  401 ms task
- Same on Listings: 5 PATCH + 5 dashboard + 5 listings GETs, 616 ms ->
  5 PATCH + 1 listings GET, 412 ms; hidden rows leave the page at once
  (500 -> 495 hide buttons) instead of staying until a refetch
- 3 hides from another tab while on Listings: 2 dashboard + 2 listings
  GETs, rows unchanged until then -> rows drop immediately (500 -> 497),
  1 listings GET after 2 s
- Listings page 2, change marketplace: 1 listings request (page=1) where
  the old code first requested page=2 with the new filter

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every 'log' event bumped App's logsRefreshKey, which re-rendered the whole
app in every tab and, on the Logs view, swapped all rows for a spinner,
refetched the full 500-entry /api/logs buffer (84 KB) and remounted every
row. The event already carries the complete LogEntry.

LogsPage now subscribes to 'log' through the shared stream and prepends the
entry (dedupe by id, capped at the server's 500-entry buffer). It fetches
/api/logs on mount, on Refresh and once after a stream reconnect (missed
lines, or a restarted server whose ids start over). The spinner shows only
on the first load and on Refresh, so background updates keep the rows and
the scroll position. Rows are a memo LogRow with one hoisted
Intl.DateTimeFormat instead of a toLocaleTimeString-with-options call per
row, and .log-row gets content-visibility: auto. logsRefreshKey is gone.

Measured (mock API, headless Chromium, 40 'log' events 150 ms apart):
- Logs view: 40 GET /api/logs, 112.8 ms main thread per line -> 0
  requests, 7.0 ms per line; newest line on top, 500-row cap kept
- Overview / Listings: 15.6 / 17.7 ms per line of App re-render -> 0.15 ms
- Cold /logs load, median of 3: 182.5 -> 111.3 ms task time (includes
  the dropped deep-link dashboard request from the live-refresh change)
- Server restart while on Logs: no refetch (stale list) -> 1 reconciling
  GET /api/logs, rows stay mounted

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
WatchDialog only uses result.watches from GET /api/market-watches to fill
the reference-series select; the server builds that list before paging,
so pageSize=1 returns the same watches without 99 discarded listings.

Measured (mock API with seed payloads, opening New watch): response
75,850 -> 4,503 bytes; the select shows the same 15 options (6 research
watches).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ListingTable and ListingRow were plain components, so every App re-render
(toasts, connection state, triage patches) and every dashboard or Listings
refetch reconciled all ~500 rows, even when nothing visible changed.

- ListingTable is memo'd; its props are already stable on Overview and
  ListingsPage (setSelectedListing, useCallback toggle handlers, memoized
  lists).
- ListingRow is memo'd with a field-wise comparator (sameListingFields:
  flat === per key; list payloads carry only primitives, and a nested value
  compares by reference, so it just re-renders).
- New listingRows.reuseUnchangedListings keeps the previous object for each
  refetched row whose fields match (keyed like the table, associationId ??
  id) and returns the previous array when nothing changed. App's
  refreshData and ListingsPage's fetch use it, so Overview's visibleListings
  memo and the Listings table skip entirely on an unchanged refetch.

Measured (mock API with real seed payloads, headless Chromium, median of 3,
F1 build -> this commit, back to back):
- 5 'scan' events, 50% of rows change 'observed' per refetch:
  Overview 29.0 -> 22.2 ms per event, Listings 56.9 -> 23.9 ms
- 5 'scan' events, unchanged payload: Overview 24.9 -> 6.6 ms,
  Listings 56.0 -> 10.9 ms
- Triage (5 hide clicks): Overview 77.1 -> 42.2 ms per click, Listings
  82.5 -> 49.2 ms; the reconcile 2 s later 77.3 -> 44.3 ms and
  114.1 -> 50.2 ms. Same rows leave (476 -> 471, 500 -> 495), same requests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Date#toLocale*String with an options object builds a new
Intl.DateTimeFormat per call. New src/format.ts holds one formatter per
locale/options pair (pl-PL, same options as before) plus formatDate(),
which keeps the old "Invalid Date" output instead of throwing on a bad
timestamp. Used by the Connectors run rows, the research listing table,
the analytics leaderboard, formatAnalyticsDate (every trend-chart point
title, up to 180 per chart), the analytics "generated" time, the drawer
first/last seen, the research snapshot "Saved" date and Logs' time
column (which now also gets the invalid-date guard).

Measured (headless Chromium, Europe/Warsaw, 500 timestamps across both
DST changes): 44-48 us -> 0.8-1.6 us per call for all six formats, 0
mismatches; tests/format.test.ts checks the same equivalence in Node
against the old calls. Per render that is about 4.5 ms for the 100
Connectors runs or research rows and about 8 ms for a 180-point chart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every keystroke in the search form re-rendered all result rows (up to 200
per source per page, more after Load more), and App's inline onSaveWatch
arrow re-rendered the whole page on every App render. A 300-row result set
was also fully laid out and styled on arrival.

- SearchPage, SearchResultsTable and a new SearchResultRow are memo'd; the
  row uses the same field-wise comparator as ListingRow. App passes a
  stable saveSearchAsWatch callback instead of an inline arrow.
- The final HTTP response now reconciles the streamed rows with
  reuseUnchangedListings, so rows that arrived over SSE unchanged do not
  re-render; streamed batches and Load more already kept existing objects.
- .search-result-row gets content-visibility: auto (69px intrinsic size),
  like .listing-row and .log-row.

Measured (mock API, 300 results, headless Chromium, median of 3, back to
back): per keystroke in "Included terms" 15.2 -> 5.5 ms task (script
5.2 -> 0.5 ms); search submit to settled 261 -> 169 ms task, layout
49 -> 14 ms. 300 rows render and a row click still opens the drawer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
React.lazy suspends on its first render even when the chunk is already
loaded, and React 19 then holds the reveal until ~300 ms after the
fallback, so every first visit to a route (hover preloading included), the
first drawer open and the first dialog open waited ~300 ms before the page
mounted and started its own request.

- New lazyWithPreload(loader) in App.tsx: preload() loads the module and
  remembers its component; once loaded, the wrapper renders it directly
  (no fallback, no throttle). The component type is pinned per mounted
  instance with useState, so a page that mounted through React.lazy is not
  remounted (state and data kept) when its chunk resolves. A failed load
  can be retried.
- Used for all route pages, ListingDetailDrawer, WatchAnalyticsDialog and
  the Dialogs exports; hover/focus preloading calls .preload().
- The deep-linked route's chunk starts loading at module evaluation, in
  parallel with the session check.
- The remaining route, drawer and dialog chunks (~45 KB gzip) load on idle
  after the first view has its data: after the first dashboard render on
  Overview/Analytics/Watches, 2.5 s after mount on views that fetch their
  own data (starting at once measurably slowed the 454 KB /api/listings
  download at 10 Mbit/s). Skipped with Save-Data.

Measured (mock API, headless Chromium, median of 3, back to back):
- Nav click -> page heading, first visit, no hover: 302-332 ms -> 15-27 ms
  (Listings 330 -> 89 ms, which includes rendering its 500 fallback rows);
  click -> the page's first API request 314-329 ms -> 14-18 ms
  (Listings 88 ms). With hover 800 ms before the click: 288-334 -> 11-27 ms.
- First drawer open: 355 -> 22 ms to the drawer, /api/listing-detail
  request 338 -> 7 ms after the click. First New watch dialog 338 -> 22 ms.
- Cold deep link at 100 ms RTT, 10 Mbit/s: route chunk starts 601 ->
  482 ms; first log row 904 -> 796 ms, first Listings row 1470 -> 1431 ms.
  Overview unchanged (1318 -> 1321 ms; its chunks load after the rows).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
App rendered nothing until /api/auth/session resolved, and only then did
ScoutApp mount and request /api/dashboard, so a cold Overview load paid
one extra round trip before its 462 KB payload even started.

- At module evaluation App.tsx starts the session check and, when the
  first view renders the dashboard (planFlush on the initial view:
  Overview, Analytics, Watches), GET /api/dashboard.
- App's effect awaits that session promise instead of a second request.
- ScoutApp's first refreshData takes the boot dashboard promise exactly
  once (takeBootDashboard), whether it is still in flight or already
  settled, so there is never a second fetch and no reliance on the GET
  dedupe timing.
- A signed-out load gets a 401 from the prefetch; App now ignores the
  unauthorized event while the session check is still deciding
  (current === "checking"), so the login screen appears exactly as before
  with no flash of the app shell. If the session check says signed in but
  the boot dashboard was a 401, refreshData re-dispatches the event and
  the app switches to the login screen, as a 401 did before.

Measured (mock API, headless Chromium, 100 ms RTT, 10 Mbit/s, cold load
of /, median of 3-5): dashboard request start 609 -> 489 ms (same time as
the session request), first row 1321 -> 1225 ms; exactly one
/api/dashboard request. Auth on, signed out: login screen only (no app
shell shown), label unchanged, requests on / session + 1 dashboard (401)
vs session only before, nothing further in the next 3 s; sign-in then
loads 476 rows. /listings and /logs deep links are unaffected (no boot
dashboard).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Logs: live lines that arrive while GET /api/logs is in flight are
  buffered and merged into its response (first load, Refresh and the
  reconnect refetch), so none are lost (src/logEntries.ts).
- Offline banner: after a failed dashboard fetch, a flush on a view that
  does not refetch the dashboard hands the indicator back to the live
  stream, so the banner no longer sticks outside Overview.
- Listings: a filter change stores page 1 for the new filters, so going
  back to earlier filters no longer brings back their old page.
- Background tabs: the hidden grace timer also starts when the stream
  opens in a hidden tab, and the boot dashboard is skipped when the tab
  starts hidden; once shown, the reopened stream reconciles once.
- First connect after failures: any "ready" after a stream error counts
  as a reconnect, so a page loaded while the server was down refetches.
- Expired session: a CLOSED stream checks GET /api/auth/session before
  reopening; a lapsed session shows the sign-in screen instead of
  retrying /events every 5 s.
- Stale shared GETs: server events stop sharing in-flight GETs, so
  refetches never reuse a response that started before the event, and
  only the newest /api/connectors response applies.
- Shown after release: the visibility flush is skipped while the
  released stream reopens, so its "ready" makes the one dashboard fetch.

Tests: events (session check, hidden at open, reconnect after failures),
liveRefresh, log merging, page reset, and GET de-duplication.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ScoutDate.parse built one or more ISO8601DateFormatters on every call, and
it runs on the main thread in list rows, the price-history chart body and
the listing detail. It now parses with two static Date.ISO8601FormatStyle
values (Sendable, thread-safe) in the same fallback order: fractional ISO,
plain ISO, then the SQLite `YYYY-MM-DD HH:MM:SS` form as UTC. Calendar days
are unchanged. Results match to within float rounding (.123Z now gives
...000.1230001), so the new millisecond test compares with 0.001 accuracy.

Release build (swiftc -O, Linux, per parse), before -> after:
  ISO with ms   91-131 us -> 2.0 us
  ISO no ms    166-184 us -> 1.8 us
  SQLite form  269-293 us -> 7.0 us

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
WidgetSnapshot.hasSameContent compared every stored field except the
generation time and thumbnails, so a scan that only bumped a shown deal's
observedAt (last_seen_at), its typical price, or the relative lastScan text
made SharedStore.saveSnapshot report a change and the app reload every
widget timeline. It now compares only what Shared/WidgetViews.swift draws:
demo flag, source, the three stats, and per deal the key and watch id
(identity and deep link), title, the price and discount as the widgets
format them, deal label, marketplace and photo address. Prices are compared
through the same PLN formatter the widgets use (it rounds half to even, so
Int(price.rounded()) would miss e.g. 10.5 -> 11.4), which moves
WidgetFormat from Shared/WidgetViews.swift into ScoutKit unchanged.

saveSnapshot gains a 15-minute floor (WidgetSnapshot.shouldReplace): an
unchanged snapshot still replaces the saved one once it is 15 minutes
older, so the footer time and an offline widget catch up while the app is
open. SharedStore.saveConnection now returns whether the server or demo
flag changed, so the app can skip redundant reloads.

Churn simulation with the real ScoutKit sources (release build, real
500-listing dashboard, 10 watches scanned every 5 min for 2 h with Deals
open, i.e. 240 dashboard loads with observedAt/typical/lastScan churn):
reloadAllTimelines 176 -> 10 (6 real drawn changes + 4 floor refreshes).
The comparison itself costs 2.8 -> 15 us per load (price formatting).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Client side of contract C1. ScoutClient.dashboard(top:timeout:) adds
`?top=N` only when top is set and takes a timeout (default 20 s), so every
existing caller sends the same request as before. The widget's filter and
stable sort move into WidgetSnapshot.ranked, which make() and the demo use.
DemoTransport honours `?top=N` for N from 1 to 50 like the server: the top
N ranked listings, no watches or connectors, the same stats and scan
fields; any other value gets the full dashboard.

Measured with the real ScoutKit (release build) on the audit's real
500-listing dashboard: 462,918 bytes / 6.00 ms decode for the full
response -> 10,286 bytes / 0.15 ms for top=12, and WidgetSnapshot.make
gives the identical snapshot from both. The widget starts sending it in I2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pure pieces the widget extension needs to stop refetching and re-encoding
(IOS-KIT-3/4, IOS-APP-2 provider part), moved into ScoutKit so they are
tested on Linux while UIKit, ImageIO and WidgetKit code stays in the
extension:
- WidgetSnapshot.isFresh(for:maxAge:now:): same source and fetched less
  than maxAge ago (a clock that went backwards never counts as fresh), for
  reusing the app's or a sibling timeline's snapshot.
- WidgetSnapshot.reuseThumbnails(from:): fills missing thumbnails from
  cached snapshots by photo address, for fetch reuse, LocalCache merging
  and the offline fallback.
- WidgetSnapshot.dealsMissingThumbnails(limit:): which of the first
  `limit` deals still need a download.
- WidgetLayout.thumbnailCount(drawsThumbnails:): photos each widget draws
  (Top deals small 1, large 6, other sizes 3; Summary 0), mirroring
  DealsWidgetContent.

No runtime change yet; the gains are measured when I2 wires them in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The widget timeline called client.dashboard(), downloading and decoding the
full 500-listing dashboard with every watch and connector to keep 6 deals and
3 stats. It now calls dashboard(top: 12, timeout: 10) (contract C1): the
server sends the top 12 of the same filtered, stably sorted feed with
identical stats, and WidgetSnapshot.make still re-sorts and keeps 6, so the
extra rows keep tie-breaks identical and older servers that ignore `top` give
the same result (IOS-KIT-1). The request timeout drops from the app's 20 s to
10 s, so an unreachable LAN/VPN server stops holding the extension and radio
for 20 s per timeline before the cached fallback shows (IOS-KIT-6).

Measured on the real 500-listing dashboard: 462,918 -> 10,286 B per widget
request; decode 6.00 -> 0.15 ms (I1 bench); identical WidgetSnapshot. Worst
case wait on an unreachable server 20 -> 10 s (the request timeout; not
measurable without a device).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every widget timeline (each kind, size and configuration) fetched the
dashboard on its own, even right after the app saved the same data to the
App Group and reloaded widgets, and each one downloaded, downsampled and
re-encoded all 6 deal photos, including the Summary widget that draws none.

- IOS-KIT-3 + IOS-APP-M1: load() uses the app's App Group snapshot when it
  has the same source and is under 60 s old (WidgetSnapshot.isFresh).
  Otherwise a process-wide actor, WidgetFeed, single-flights the dashboard
  load per source and reuses a successful one for 60 s; failures are
  dropped so the next timeline retries. Keyed by source, so a widget set to
  another server never gets the app server's data.
- IOS-KIT-4: the provider knows its kind (drawsThumbnails: true for Top
  deals, false for Summary) and downloads photos only for the deals its
  family draws (WidgetLayout.thumbnailCount: small 1, medium 3, large 6,
  Summary 0), after reusing thumbnails already in the widget's LocalCache by
  imageURL. LocalCache.save now merges the saved thumbnails by imageURL
  instead of dropping them, and skips the write unless what widgets draw or
  the thumbnails changed, or the saved copy is 15 min older (the same
  WidgetSnapshot.shouldReplace floor the app uses). Saves go through the
  actor so concurrent timelines merge one at a time. The offline fallback
  reuses LocalCache thumbnails by imageURL rather than by deal id.

Measured with a Linux simulation that compiles this exact provider source
against the real ScoutKit (WidgetKit, App Group, UserDefaults and the photo
download stubbed; counting local server with ~275 ms dashboard latency and
the real 500-listing dashboard) for Top deals medium + Summary small +
Summary lock-screen, before -> after this commit:
- cold scheduled wave: 3 -> 1 dashboard requests (30,858 -> 10,286 B),
  18 -> 3 photo downloads, 3 -> 2 LocalCache writes (151,647 -> 28,963 B)
- steady scheduled wave (same deals): 3 -> 1 requests, 18 -> 0 photos,
  3 -> 0 writes
- app-triggered wave: 3 -> 0 requests, 18 -> 0 photos, 3 -> 0 writes,
  wall 326 -> 0 ms
- offline Top deals after a Summary-only save still shows its cached
  photos (3 of 3 drawn for medium).
Behavior: widgets may show data the app or a sibling timeline loaded up to
60 s earlier (the footer shows that time), and the offline footer time can
lag the last fetch by up to 15 min when nothing drawn changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
syncWidgetConnection reloaded every widget timeline on each launch and
connection event, even when the server and demo flag were unchanged. It now
reloads only when SharedStore.saveConnection reports a change (IOS-APP-2
part 2). connect() and updateAPIToken() still force a reload, since a new
API token for the same server can change what widgets can load. Dashboard
loads keep reloading widgets through publishWidgets when drawn content
changes, or after the 15-min floor, which also lets an Offline widget
recover once the app has loaded data.

Before -> after: an app launch with an unchanged connection triggers 1 -> 0
reloadAllTimelines (and so 1 -> 0 widget reload waves) from this path;
connection changes, connect and token updates still reload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every data screen reloaded with `.task(id: refreshToken)`, so it refetched
on every appearance (tab switch, back navigation, Market segment switch),
paged lists dropped back to page 1, and every live event kind reloaded
every screen, including the SSE echo of the device's own triage.

- ReloadOnChange replaces `.task(id:)`: it loads on first appearance and
  on key changes, and skips a re-appearance when the key is unchanged,
  live updates are connected and the data is under 120 s old
  (ScoutKit ReloadPolicy). Loads return true once data is on screen.
- AppModel maps each event to scoped counters (ScoutKit LiveInvalidation,
  decoding the small payloads): refreshToken (Deals, Listings) for
  scan/watch; watchesToken (Watches, Analytics) for scan/watch/triage;
  per-watch counters plus triageToken for watch detail; serverToken
  (Settings) for scan/notification; research counters for market-watch.
  ai-description-verification bumps nothing. The 1 s coalescing stays.
- listing-action is published to Deals and Listings, which patch the
  decision in place (and republish widgets); a hidden change or an
  unloaded row reloads, since stats and deal counts count hidden rows.
- Listings and research saved listings refetch the loaded window in one
  request instead of page 1. MarketView owns the Research and Analytics
  data and filters, so switching segments keeps them.
- Own edits skip model.refresh() while live; the server's echo covers it.

Measured with the real AppModel on Linux (scratch appsim, scripted SSE
trace of 20 events: 10 scans, 2 strong-deal scans with verification and
notification, 3 decision-only triages, 1 hide, 1 research snapshot,
1 watch edit), reloads per screen before -> after: Deals and Listings
20 -> 11 (+1 reload for the hide; decision triage is patched), Watches
and Analytics 20 -> 15, watch detail 20 -> 6, Research 20 -> 1,
Settings 20 -> 12. Screen gate replay (ReloadPolicy on a simulated
clock): 15-listing triage pass from Deals 16 -> 4 dashboard loads with
no scans (7.2 -> 1.8 MB) and 16 -> 13 with a scan every 30 s; 20 tab
switches 14 -> 7 (Deals) and 13 -> 7 (Watches); 10 Market segment
switches 5 -> 1 (Analytics) and 6 -> 1 (Research). Unchanged while live
updates are down.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each `.active` scene phase cancelled a working event stream, reconnected
and refreshed; after a real background period the new stream's `ready`
then refreshed again a second later, and cold launch opened the stream
from both `.task` and the phase change.

- ScoutApp uses `.onChange(of: scenePhase, initial: true)` only: active
  calls resumeLiveUpdates(), background stops the stream. The `.task`
  start is gone.
- resumeLiveUpdates() keeps a running or backing-off stream. Otherwise it
  starts one and arms a 3 s fallback refresh in case it can't go live.
  Demo mode refreshes as before.
- Going live cancels the fallback and any pending coalesced refresh and
  refreshes immediately (one catch-up, about one round trip after the
  subscription, instead of immediately plus again 1 s later).

Measured with the real AppModel on Linux (scratch appsim, fake /events
answering `ready` after 50 ms), before -> after:
- cold launch, refreshes after the first load: 2 -> 1
- 10 background -> foreground cycles: 20 -> 10 refreshes, 10 -> 10
  stream connections
- 10 inactive -> active blips while live: 10 -> 0 refreshes and
  10 -> 0 reconnects
- foreground with the stream unreachable: 1 -> 1 refresh (now after 3 s)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Watch detail fetched its analytics, then every watch (to refresh this
one), one after the other, and repeated both on every range tap; research
watch detail did the same with its trend and the research summary, and
Analytics fetched its data, then the watch list.

- WatchDetailView and ResearchWatchDetailView start the range-dependent
  request with `async let` and fetch the watch (list or summary) only
  when the watch's change counters moved since it was fetched, or after
  pull-to-refresh or an action on this screen.
- AnalyticsView fetches its first watch list in parallel with analytics.
- Drops an unused LoadMemory.forget().

Measured with ScoutKit's client over the demo transport plus a fixed
round trip (scratch rttsim): a watch detail load 2 sequential requests
-> 2 parallel (501 -> 251 ms at 100 ms RTT, 311 -> 156 ms at 5 ms); a
range tap 2 -> 1 request, dropping /api/watches?includeArchived=true
(286 ms server, 9.4 KB in the baseline bench). Node serializes the
server work, so the parallel gain is one round trip, not server time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ListingThumbnail used AsyncImage, which decodes the full marketplace photo
for a 60-68 pt square on every appearance and keeps nothing; ServerImage
decoded saved photos at full size on the main thread, the saved-copy strip
loaded all 12 at once, the enlarge sheet downloaded the photo again, and
the default URLCache (512 KB / 10 MB, nothing over ~500 KB) let photos
evict each other.

- ImagePipeline: an NSCache of decoded images (20 MB cost limit, cost =
  bytesPerRow x height, keyed on size, mode and URL) filled by ImageIO
  thumbnails decoded in a detached task. The decode size comes from
  ScoutKit's ImageSizing: the short side covers the square for fill, the
  long side for fit, never upscaled. Images over a quarter of the limit
  (full-screen photos) are not kept, so they can't evict the thumbnails.
- PipelineImage draws the cached image in its first frame when there is
  one, keeps the same placeholders, and falls back to them on failure.
  ListingThumbnail, ServerImage (with the client's authenticated fetch),
  and the listing and research detail headers use it: thumbnails at their
  size x displayScale, strip tiles at 96 pt, headers at the screen width,
  the enlarge sheet at the screen's long side.
- The saved-copy strip is a LazyHStack.
- ScoutApp sets URLCache.shared to 8 MB memory / 100 MB disk before the
  model (and any request) exists, as the verifier sized it.

Decoded bitmap per photo, full decode -> ImageSizing at 3x (arithmetic
with the shipped function, scratch gatesim; not measured on a device):
Vinted 800x800 thumbnail 2.44 -> 0.16 MB; a 2560x1920 Allegro original
18.75 -> 0.21 MB as a thumbnail and 18.75 -> 4.76 MB as a detail header;
OLX 320x240 0.29 -> 0.21 MB; a 1000x750 saved photo tile 2.86 -> 0.42 MB,
so a 12-photo strip drops from 34 MB decoded at once to about 5 MB, and
only the visible tiles load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Listings: a listing-action event now patches decision and hidden into the
loaded rows and drops those that leave the active filters, lowering the
pagination total by the rows removed (local swipes too). The list reloads
only when a listing that isn't loaded could now pass the filters, so a
user's own hide or decision change no longer refetches up to 500 rows. The
pure logic is ListingActionEvent.triage(_:filter:) with ListingFilter and
ListingTriage in ScoutKit, covered by a Linux test.

Settings: load() returns false when the server settings fetch was
cancelled or failed, so ReloadOnChange doesn't record the load and the
server settings section is fetched again on the next appearance.

Analytics: AnalyticsStore remembers the watchesToken its watch list was
fetched for and refetches the picker's watches when it changes; a failed
or cancelled fetch keeps the previous list instead of setting it empty.

Disconnect: AppModel.disconnect() clears URLCache.shared and the
ImagePipeline memory cache, so photos fetched with one server's token are
not shown for another server at the same address.

Image pipeline: concurrent requests for the same URL, size and mode share
one download and decode through a lock-protected dictionary of Tasks. The
shared task is cancelled only once every waiting caller was cancelled,
and removeAll() cancels loads in flight and bumps a generation so loads
started before it never write into the cleared cache. The class stays
@unchecked Sendable with all mutable state behind an NSLock; it
type-checks in Swift 5 mode and with -strict-concurrency=complete.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
kubus merged commit f771897170 into main 2026-10-01 12:25:26 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
kubus/scout!24
No description provided.