release: v0.3.1 — reliability, accessibility & data-integrity hardening - #67
Merged
Conversation
v0.3.0 pinned @sveltejs/kit to an exact 2.64.0 to dodge the CSS-preload doubled-path 404 regression introduced in kit 2.65.0 (kit #16039). The upstream fix (#16026) shipped in 2.65.1, so the pin is no longer needed — restored the caret range at the current latest (2.68.0). The tests/e2e/css-preload.spec.ts guard confirms the doubled-path 404s stay gone. Dev/build dependency only; no runtime or behaviour change. Lockfile regenerated under Node 24 in a container (host is Node 22 with engine-strict). docs/technical/offline-app-shell.md updated to reflect the lifted pin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pgWzTTSDZZWvJTREh63Da
Tailwind v4's automatic content detection scanned the whole project, so the Docker security_opt line in the compose files was parsed as an arbitrary-property class candidate and emitted as an invalid CSS rule (dropped by browsers with a console warning on every load). Scoping detection to src/ via the import's source() argument removes the junk rule while retaining every real utility class (all app markup lives in src/). Verified: junk class gone from the compiled CSS, all src classes retained, full lint/check/test/build/e2e green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pgWzTTSDZZWvJTREh63Da
The odometer <input> was located via getByPlaceholder('87,432') across 6
specs (21 sites). That couples the tests to placeholder text, which is not
a stable contract — PR #64 makes the placeholder dynamic (state-dependent),
which breaks every one of these locators.
Switch to the field's stable #odometer id so the tests survive placeholder
changes. Behaviour under test is unchanged; all 21 affected tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…or hardening Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ate placeholder - Added `step="1"` to enforce native integer validation on the odometer field. - Prevented keyboard input of decimal points (`.` and `,`) to block typing decimals. - Updated the placeholder to dynamically fall back to the last recorded odometer reading if available.
The odometer <input>'s continuation attributes landed indented 7 spaces (vs the 15-space alignment under id= used elsewhere) with a multi-line oninput carrying trailing whitespace. Re-align and collapse oninput to a single line. Whitespace-only — no behaviour change (verified: git diff -w). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… an idempotency key Root cause: validate()'s required-field loop only rejects undefined/null, so an empty-string or whitespace-only clientSubmissionId passed the gate and landed in the idempotency map as a key. Every submission carrying "" then collided on one shared entry: the second submit inside the 60 s window got the first's cached 200 and was never written upstream — a silent data-loss path for buggy API/Shortcuts clients. Fix shape: a type + non-blank gate in validate(), alongside the other `invalid` entries (same error style, same 400), because that is the one choke point every parse path (JSON, form-urlencoded, multipart) already funnels through. Non-strings are rejected too — the JSON path delivers raw client values, and any constant value would recreate the same shared-key trap. Tests: integration cases proving "" and " " → 400 naming clientSubmissionId, plus a non-string (number) case. Docs: idb-and-api.md fuelup validation row + 400 error matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
Root cause: the catch arm returned `(err as Error).message` verbatim. For a LubeLoggerError that message embeds the upstream HTTP status and a 200-char preview of the upstream response body — internal detail leaking through an unauthenticated probe endpoint (residual of review finding #16; the fuelup route was fixed then, healthz was missed). Fix shape: the response carries a fixed generic 'upstream unreachable' string, and the real error is logged server-side via locals.logger (the hook binds a per-request child logger on every route, healthz included — only the access-log line is silenced for this path). Same "detail in logs, generic on the wire" posture every other route uses. Tests: healthz test now injects locals; new case proves a secret string in the upstream error body never appears in the 503 response while the 'healthz upstream check failed' warn does reach the logger. Docs: idb-and-api.md healthz 503 row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
…g on /api/fuelup and /api/log Root cause: the transport deliberately runs with BODY_SIZE_LIMIT=Infinity (the v0.2.5/v0.2.6 multipart fix), which means the app is the only body gate — but /api/fuelup awaited formData()/text()/json() and /api/log awaited request.text() before any size check ran. A huge or hostile upload was fully buffered into memory first; /api/log even had a MAX_BATCH_BYTES check, but only *after* the buffer. Fix shape: port /api/ocr's `_contentLengthExceeds` Content-Length pre-guard pattern to both routes. - fuelup caps at 2 × env.ocrMaxImageBytes + 256 KiB form slack: the endpoint accepts at most two photo parts (pumpImage + odometerImage), each individually gated to the image policy post-parse, so the cap is derived from the same env value OCR uses rather than a new knob. - log checks the header against its existing MAX_BATCH_BYTES. Both return a clean 413. Absent / non-numeric / lying headers fall through to the authoritative post-parse checks, so chunked bodies keep working exactly as before. Tests: integration cases sending a tiny body with an oversized Content-Length header -> 413 with no body parse (fuelup would hit msw's onUnhandledRequest: 'error' if it proceeded). Docs: idb-and-api.md fuelup 413 row + /api/log cap description; logging.md endpoint summary line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
…rray order Root cause: the reduce compared records with a strict `>` on day-resolution dates, so two fillups on the same date always kept the first array entry — the earlier record under LubeLogger's ordering. The home page strip and odometer prefill then showed the stale reading for the rest of the day. Fix shape: on equal dates, prefer the record with the higher odometer (the later fillup always has the larger reading — the only ordering signal day-resolution dates leave). Values go through Number() as a guard against upstream builds serializing numerics as strings, even though the GasRecord type says number. A full tie (same date, same odometer) keeps the later array entry via `>=`. Tests: three same-date integration cases — higher odometer later in the array (the actual regression), higher odometer earlier in the array, and a full tie falling back to the later entry. Docs: idb-and-api.md last-fuelup 200 row documents the tie-break. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
… limiter maps Root cause: both in-memory limiters (OcrRateLimiter.hits and the log route's buckets map) only ever rewrite the *calling* IP's entry. An IP that never returns leaves its fully-expired entry in the map forever — an unbounded slow leak on a long-lived single-replica process. Fix shape: opportunistic sweep, deliberately minimal. Each limiter keeps one lastSweepAt timestamp; on a check/bucket lookup, if more than one rate window has passed since the last sweep, iterate the map once and delete entries with no hits inside the window (OCR) / an expired resetAt (log). O(n) only on sweep, amortized to once per window (1 h / 1 min respectively), no timers, no new dependencies — the right-sized fix for a personal-scale, single-replica deployment. Tests: OcrRateLimiter gains a test-only trackedKeyCount getter with fake-timer cases proving expired keys are evicted and still-active keys survive the sweep; the log route gains _bucketCountForTests and a case proving expired buckets are dropped on the next request after the window. Docs: photo-ocr.md rate-limiter bullet + logging.md endpoint line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
…ailed validation Root cause: the route only called budget.add() when outcome.ok, and the failure audit row hardcoded costCents: 0. A paid provider that returns a response — tokens billed — which then fails JSON.parse (ocrProviders.ts), schema, range, or cross-field validation (ocr.ts) burned money invisibly: the daily tally never moved and the 402 budget gate could never trip on that spend. Inside a chain, a paid slot's billed-but-failed attempt vanished even when a later free slot succeeded. Fix shape: - ChainOcrProvider accumulates lastFailedCostCents per extract(): each failed attempt adds its estimateCostCents() unless the error code is NETWORK — the one class of failure where no response arrived and nothing was billed. Non-OcrProviderError throws count (conservative: they happen after the request went out; the budget doc already frames estimates as fail-closed upper bounds). - PipelineOutcome's failure arm gains costCents. Pre-provider failures (415/unknown mode) carry 0; extract-throw carries the chain's accumulated burn (bare provider: its own cost unless NETWORK); post- extract validation failures carry the active provider's cost plus any chain burn. The success arm now also folds in failed-but-billed earlier chain attempts — same money, same call. - Route failure path calls budget.add(outcome.costCents) when non-zero and writes it to the audit row instead of 0. Tests: chain accumulation/reset/NETWORK-exemption cases in ocrProviders.test.ts; pipeline costCents matrix (schema, range, PARSE, NETWORK, pre-provider, chain failure + chain success) in ocr.test.ts; route-level proof that a paid schema-fail response increments the budget file and audits costCents > 0 while a network failure records no spend. Docs: photo-ocr.md lifecycle step 6 + audit-row costCents field note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
Root cause: the FX $effect's early-return branch (currency === target) reset fxRate/fxStale/needsManualFx but never cleared manualFxRate. The manual-rate field disappeared and the preview showed the unconverted amount, yet submit still sent `manualFxRate: Number(manualFxRate)` and the server (src/lib/server/convert.ts) applies it unconditionally — writing a silently mis-priced cost upstream. The same retention carried currency A's typed rate into currency B's submission. Shape: the effect now starts every run by clearing manualFxRate. Its only reactive dependency is `currency` (TARGET_CURRENCY is a const; everything else is a write or lives in async callbacks), so the clear fires exactly once per currency switch, and the write cannot re-trigger the effect. The existing clear in the success branch stays — it covers a rate typed while a lookup for the newly selected currency is still in flight (the field can remain visible from the previous currency). Test: new tests/e2e/fx-manual-rate.spec.ts — stubs /api/fx to 503, types a manual rate for CAD, switches back to USD, submits, and asserts the POSTed body has no manualFxRate. Passing on mobile-safari. Doc: fx-chain.md client-side preview section documents the reset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
…y drop a fill-up Root cause: the 5xx/network catch branch of submit() did a bare `await Queue.open(); await q.enqueue(input)` — unlike the success path four lines up, which wraps the same calls. When IndexedDB is unavailable (Safari private mode, quota exhausted), that rejection escaped the catch handler: no toast of any kind, submitting reset via finally, and the fill-up was silently lost. Shape: wrap the fallback enqueue in try/catch; the queued toast moves inside the try (it must only show when the entry actually landed), and the catch sets an explicit error toast — "Couldn't save — device storage unavailable. This fill-up was NOT saved." — so the user knows to retry. Component-level testing isn't practical for this page-level path (no component test harness for routes); covered instead by a new Playwright spec that stubs /api/fuelup to 500 and makes any indexedDB access throw via an init-script property trap, asserting the explicit toast. Passing on mobile-safari. Doc: offline-queue.md edge cases (quota / private browsing) rewritten — they previously claimed an error toast fired here, which the code didn't actually do until this fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
…ach the server Root cause: the odometer input blocks a typed '.' or ',' via onkeydown, but paste and autofill bypass keydown entirely — '50123.4' lands in the bound value, canSubmit only checks > 0, and submit sent Number(odometer) raw. The OCR-apply paths already round their readings; the manual-entry submit path was the one hole. Shape: build the submission input with Math.round(Number(odometer)). Test: required-fields.spec.ts gains a case that fills a decimal value and asserts the intercepted POST body carries the whole number. Passing on mobile-safari (full spec file re-run, 5/5). No technical doc documents the submit-time odometer coercion, so no doc change beyond the CHANGELOG line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
…rency Root cause: the "Logged: …" toast called formatCost(result.submitted.cost, null); the null falls back to the cached instance currency from loadServerInfo(), which is wrong on a cold cache (fresh install before /api/server-info has ever landed) — the toast then rendered the converted cost with the default USD symbol regardless of the instance currency. `result.submitted.currency` was added to the response for exactly this (it's what both converted-snapshot write sites already use). Shape: pass result.submitted.currency to formatCost. Test: the e2e /api/fuelup mocks (fixtures.ts mockLubelogger and attach-photo's FUELUP_OK) now include `currency` so they match the real FuelSubmissionResult wire shape — they were omitting a required field. A direct toast-text assertion wasn't added: the toast is only visible during the post-submit redirect to /maintenance (the happy-path spec deliberately asserts the URL, noting the toast is transient), so asserting its text would race the navigation and flake. Affected specs (happy-path, attach-photo) re-run green on mobile-safari. No technical doc describes the toast formatting; CHANGELOG only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
… cancelled Root cause: openPumpCamera()/openOdoCamera() cleared the mode's retained attach blob BEFORE the picker opened. Cancelling the picker either fires no change event or one with an empty file list — the onchange handler early-returns on !picked — so the blob was already gone: attach row vanished and the previously captured photo could no longer be attached. Shape: the blob-clearing moves into handlePumpCamera/handleOdoCamera, after the !picked early-return, so a retained photo is superseded only by an actual new pick. (runOcr still re-sets the slot on every send — "latest send wins" is unchanged.) Test: attach-photo.spec.ts gains a reopen→cancel case — Playwright intercepts the file chooser on the pill tap (never picks), then dispatches an empty setInputFiles to mimic a cancel-with-change browser, and asserts the attach row survives and the multipart submit still carries pumpImage. Passing on mobile-safari (4/4 in the file). Doc: attach-ocr-photo.md lifecycle step 1 and the two invariants that described clearing-on-open updated to the pick-time clearing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
…-ulp overshoot Root cause: sanitizeCrop rejected any rect with `x + w > 1 || y + h > 1` outright (null = fall back to the full image, silently). The viewport→base→source math in cropCoords.ts (viewportToBase divisions composed with displayToSource) can produce x + w = 1 + one float ulp for a crop dragged flush against an edge — the divisions don't cancel exactly. The user's carefully framed crop was then thrown away and the full photo went to OCR with no indication anything was dropped. Shape: after the existing finite/≥0/positive-size checks, reject only a genuinely invalid origin (x ≥ 1 or y ≥ 1) and clamp the size to the far edge: w = min(w, 1 - x), h = min(h, 1 - y). An overshooting-but-valid crop is kept; degenerate/garbage rects still collapse to null. Test: image.test.ts — new case pinning the Number.EPSILON-scale overshoot (crop kept and clamped to the edge), the old strict-reject case reworked to assert clamping semantics, and a new genuinely-invalid case (origin past the far edge) still falling back to the full image. Doc: photo-ocr.md image.ts bullet documents the clamp posture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
…tead of a hardcoded 5
Root cause: syncQueue() ended every drain with the literal
Queue.pruneSynced(5). The synced rows it deletes are exactly what the
History page renders (and they carry the converted-cost snapshots), so
History silently capped at 5 fill-ups per vehicle and older entries —
snapshot included — were unrecoverable on that device.
Shape (user decision: default 200):
- prefs.ts gains `historyKeepPerVehicle` (default 200).
- syncQueue(dbName?, historyKeepPerVehicle?) sanitizes the cap (whole
number >= 1) and falls back to loadPrefs() and then the 200 default.
The drain runs in the SERVICE WORKER, which has no localStorage, so
sync-trigger.ts reads the preference in the window context and sends
it on the `{ type: 'sync-queue', historyKeepPerVehicle }` message;
the SW forwards it. loadPrefs() is SW-safe (returns defaults there).
- Settings gains a "Fill-ups kept per vehicle" numeric control
(validated to a whole number >= 1, matching the existing quick-
increment field's card/field styling) with help text describing the
History retention behavior.
Tests: prefs.test.ts (default 200, roundtrip, legacy-JSON fallback);
sync-queue.test.ts proves the wiring — explicit cap honored, no-arg
drain keeps 7 rows under the 200 default (would have pruned to 5
before), stored pref honored in a window context, and garbage in both
sources (NaN message + non-numeric pref) falls back to 200;
sync-trigger.test.ts asserts the message carries the stored pref.
Docs: offline-queue.md (pruning section rewritten: setting, default,
message plumbing), history-page.md (retention cap documented — it was
previously undocumented), offline-odometer-prefill.md, idb-and-api.md,
service-worker.md (message shape) all updated in this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168aC5Wm8eTbBDhmfZtNgXG
…, T2) The 5xx and network-error flush paths (logger.ts:66-74) requeue records and double the retry backoff; dropping either behaviour would lose client logs invisibly during phone UAT. The beforeunload handler (110-117) hands the still-buffered records to sendBeacon on unload. None of these had coverage. Adds: 5xx flush requeues + backoff doubles + records resend on the next timer; network-error flush requeues then drains on a later flush; beforeunload beacons the buffer (with content assertion) and no-ops on an empty buffer. Batch T of the 2026-07-05 review (docs/superpowers/reviews/2026-07-05-full-code-review.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VAt61kJzrWmDQAXQmzD4T
…s (T3, T4)
api.ts was the suite's worst-covered file (six functions untested). postOcr's
FormData assembly (rotation/crop/hints omitted at defaults, carried when set)
had zero coverage — dropping those fields passed the whole suite. Adds the
200→OcrResult parse and the TimeoutError→status:0 mapping.
T4 pins the load-bearing degradation contracts a page relies on to render when
an upstream slot is down: getFx 503→{available:false}, lastFuelup 502→null,
getOcrStatus 500→{enabled:false}, listReminders non-ok→throw with .status.
Batch T of the 2026-07-05 review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VAt61kJzrWmDQAXQmzD4T
…t falsifiable (T5, R1) R1: the silenced-paths test asserted only X-Request-ID presence, so it could never fail if path-silencing regressed. It now captures process.stdout and asserts ZERO 'request' access-log lines for the silenced paths, with a positive control proving a non-silenced path emits exactly one. T5: the last-resort fence (hooks.server.ts:117-120) had no tests. A throwing resolve() now asserts a 500 'Internal Error', a preserved X-Request-ID, the 'handler threw' log line, and the error-level access log. Adds a JSON-line stdout capture helper (LOG_PRETTY=0 forces JSON) restored per test. Batch T of the 2026-07-05 review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VAt61kJzrWmDQAXQmzD4T
…d vehicles (T6, T10) T6: last-fuelup's entire 502 (LubeLoggerError) arm was untested — add the upstream-5xx→502 mapping with a no-detail-leak assertion, mirroring api-fuelup's. T10: fx's missing-param 400 and the catch-all 500. getRate() only throws FxUnavailableError, so the 500 arm is exercised by forcing service()'s loadEnv() to fail (LUBELOGGER_URL transiently unset) — the same "flip an env var to force a downstream throw" lever api-fuelup's 500 test already uses. Asserts the env-error text never reaches the response. Residual gap: the same generic-500 fallback in the vehicles route (+server.ts:24-25). Batch T of the 2026-07-05 review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VAt61kJzrWmDQAXQmzD4T
CropOverlay had 58% branch coverage: the four edge-handle drags (CropOverlay.svelte:251-271) and onWheel (288-296) were untested, so an edge-resize or wheel-zoom regression shipped green. Adds only-that-axis assertions for each of top/bottom/left/right, floor and image-boundary clamp cases on an edge drag, and a wheel-zoom case that grows about the cursor and clamps at MAX_ZOOM (5) / back to the fit floor (1). Batch T of the 2026-07-05 review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VAt61kJzrWmDQAXQmzD4T
…ation failure (T8) T8: JsonFileBudgetStore's corrupt-file self-heal (ocrBudget.ts:115-119) was only promised by a comment — without it a single bad write freezes the day's tally forever. Adds a real-file test that a corrupt file makes the mutator see null and gets rewritten, plus the non-ENOENT load() rethrow (a directory → EISDIR). Residual gap: the existing resolveAuditHmacKey error test lands on the READ arm (ENOTDIR). Adds a test that isolates the key-GENERATION arm (ocrAudit.ts:126-128): key file missing (read → ENOENT) but the parent dir read-only so writeFileSync → EACCES. Skipped under root. Deliberately NOT added: the append stat non-ENOENT rethrow (ocrAudit.ts:79). It can't be isolated via real FS — mkdir(dirname) runs first and fails on any path that would make stat throw non-ENOENT — and its observable outcome (swallow + 'ocr audit append failed') is already covered by the :135 test. Only mocking node:fs would reach the line, which is unreliable under vitest. Batch T of the 2026-07-05 review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VAt61kJzrWmDQAXQmzD4T
readPhotoDate's guard branches (truncated mid-marker, APP1 without the "Exif\0\0" prefix, bad TIFF byte-order, bad TIFF magic, regex-failing date) were uncovered. Adds a table-driven set asserting each returns null WITHOUT throwing (via .resolves.toBeNull, which fails if the parser ever rejects). Note: the review listed "month 13 → null", but that is stale — the numeric Date constructor rolls month 13 into Jan of the next year, so a valid Date returns. The parser's real invariant on hostile input is "never throw"; the month-13 test asserts the actual (rollover) behaviour and documents the discrepancy. Batch T of the 2026-07-05 review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VAt61kJzrWmDQAXQmzD4T
~8 e2e specs located the volume/cost inputs via getByPlaceholder('11.2') /
('42.18') — the exact placeholder coupling that broke the odometer spec when its
placeholder went dynamic (de6831f). The volume/cost placeholders are next in
line to become dynamic.
Gives both inputs an explicit aria-label (matching the adjacent Currency
select's convention) so their accessible name is a clean "Volume" / "Cost"
rather than the sibling-polluted "Volume Gal L", then migrates every spec to
getByLabel(..., { exact: true }). Also a small a11y win — screen readers now
announce the fields by name instead of the placeholder.
Batch T of the 2026-07-05 review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VAt61kJzrWmDQAXQmzD4T
… conversion math (R3)
The fixture mocks quicklogger's OWN /api/{vehicles,vehicle/last-fuelup,fx,fuelup}
endpoints, not LubeLogger — the old name misdescribed it. Its /api/fuelup handler
reimplements L→gal and CAD→USD so the confirmation UI has plausible numbers, which
means any confirmation-number assertion would test the fixture's arithmetic, not the
app's real server conversion (owned by tests/integration/api-fuelup.test.ts).
Renames the fixture and its 4 callers, and adds a prominent note (function docblock
+ inline) that the `submitted` values are a plausibility stub — never assert them
here. The math is kept (not deleted): it gives UI-flow tests input-consistent toast
numbers, and moving real-conversion coverage into e2e would require running the live
server against a mocked upstream, which the integration suite already does better.
Batch T of the 2026-07-05 review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VAt61kJzrWmDQAXQmzD4T
Two waitForTimeout guards had no positive signal, so a slow machine could under-wait and pass vacuously: - ocr-preview-crop: the 50ms settle after the synthetic crop drag → poll the interior handle's rendered offset (which mirrors rect.x) until it shifts, proving the drag applied before Done. - css-preload: the 500ms settle on the regression guard → poll that a first-load CSS asset actually resolved (200). networkidle already lets the hydration-time doubled-path request settle into the 404 list; the poll just stops the absence assertions from passing on a page whose CSS never loaded. server-info's setTimeout(300) is left as-is: it is a deliberate slow-endpoint simulation (the stale cache must paint before the live response), not a guard sleep, and the test already has a positive waitForFunction after it — the review flagged it as benign. Batch T of the 2026-07-05 review (R5). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VAt61kJzrWmDQAXQmzD4T
The logged/queued/failed toast after a fill-up submit was the only transient panel without an ARIA live-region role, so screen readers never announced the submit outcome. role="status" for success/queued/ warning kinds, role="alert" for errors — matching the offline banner and OCR cards. Two e2e toast assertions now locate by role so a dropped role regresses red. Review: 2026-07-05 full code review, Q5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
Extract withLubeLogger(locals, labels, fn), parseVehicleId(url), and lubeloggerFromEnv(logger) into $lib/server/lubeloggerProxy.ts. The five GET proxies (vehicles, vehicle/image, vehicle/info, vehicle/last-fuelup, vehicle/reminders) shared three near-verbatim blocks: the vehicleId param parse, the LubeLoggerClient construction from loadEnv(), and the LubeLoggerError→502 / else→500 catch. fuelup and server-info reuse the construction helper; healthz keeps its deliberately different client (2 s timeout, no logger). Bundled consistency fix: the GET routes now require a positive-integer vehicleId (parseVehicleId, same rule as /api/fuelup's validate) instead of any finite number — ?vehicleId=3.5 / -2 / 0 no longer reach the authenticated upstream URL. Covered by new it.each cases in api-reminders.test.ts; all existing per-route 400/502/500 tests pass unchanged (error strings preserved exactly). Review: 2026-07-05 full code review, Q1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
The history / maintenance / stats loaders repeated the same ~12-line "URL ?vehicleId= → prefs.lastVehicleId → vehicles[0] → null" block (comment included). Extracted as resolveSelectedVehicle(vehicles, url) in $lib/client/vehicle-resolve.ts with a dedicated unit-test file covering each rung of the chain and the mismatch/empty-list fallbacks. Review: 2026-07-05 full code review, Q2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
Four near-identical "current vehicle" card blocks (home button variant;
history / maintenance / stats anchor variants) collapse into one
VehicleCard.svelte with an href/onclick prop split. The five inline
'[year, make, model].filter(Boolean).join(" ")' repeats (those four
cards + the picker rows) now go through vehicleLabel() in format.ts,
unit-tested for missing parts and null input. Rendered markup is
unchanged — e2e text locators ('2014 Honda Accord' etc.) untouched.
Review: 2026-07-05 full code review, Q3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
Four copies of the "read text, build Error, bolt .status on via cast" block (submitFuelup, submitFuelupWithPhotos, listReminders, getVehicleInfo) collapse into one throwIfNotOk(res, label) helper that throws a typed ApiError. OcrError becomes a class extending ApiError (status inherited; retryAfter/serverError stay client-only). The two +page.svelte consumers instanceof-narrow instead of re-casting. Message shapes preserved (`<label> <status>: <text>`, empty body drops the colon); new tests assert instances so consumers can rely on instanceof. Review: 2026-07-05 full code review, Q4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
The identical 6-line ISO-parse-with-fallback preamble opened four format.ts functions (daysAgo, formatLastFillupDate, formatDueDate, formatIsoDate); extracted as parseIsoLocal(s): Date | null and reused. /history's dateKey() sort key now goes through it too — local-midnight ms order calendar dates identically to the old Date.UTC ms, and garbage still collapses to 0. Raw-input fallbacks unchanged (existing tests cover every branch); new parseIsoLocal describe block added. Review: 2026-07-05 full code review, Q7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
Q8: the 'text-[10px] uppercase tracking-wider font-semibold rounded px-1.5 py-0.5' status-chip core repeated ×8 (home offline-copy + prefilled chips, history Queued/Failed, maintenance urgency, settings Update available, stats Past Due, OcrPreview Cropped) becomes a .badge component class in app.css alongside .field-label/.toggle-pill; color, border, and layout tweaks stay at each call site. Q9: the camera path (×5), warning triangle (×3), and external-link arrow (×3) inline SVGs collapse into $lib/client/Icon.svelte (name/size/class props; external keeps its heavier 2.5 stroke). One-off icons (menu, close, checkmark) stay inline at their single call sites. Rendered markup unchanged. Review: 2026-07-05 full code review, Q8 + Q9. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
… (Q10)
USD/CAD/EUR/GBP/MXN was hard-coded as <option> lists in the entry form
and Settings; both now {#each} over SUPPORTED_CURRENCIES exported from
$lib/shared/currencies.ts. Server-side validation stays deliberately
broader (any ISO-4217 code), so the list is UI-only.
Review: 2026-07-05 full code review, Q10.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
parseLastOdometerMi / parseLastPricePerUnit in api/ocr/+server.ts were byte-identical except the field name — merged into parsePositiveField(form, name). Same defensive contract: only a finite positive number survives; everything else collapses to undefined so the prompt builder skips the hint and the audit row omits the field. Existing route tests cover both fields' accept/reject paths. Review: 2026-07-05 full code review, Q11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
Stops a careless value-import from pulling $lib/server type modules into client bundles at runtime (client/api.ts imports lubelogger types). Deviation from the review's "passes as-is" claim: 26 hits surfaced, 25 of them inline `import()` type annotations — those are erased at compile time (no runtime pull-in) and are the established test-file idiom, so the rule runs with disallowTypeAnnotations: false. The one genuine violation (convert.test.ts value-importing CurrencyService only for its type) is auto-fixed here. Review: 2026-07-05 full code review, Q14. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
ci.yml gains a concurrency group keyed on the ref with cancel-in-progress — rapid pushes used to run duplicate ~10-min jobs to completion. npm ci in CI and the Dockerfile deps stage runs with --no-audit --no-fund: CI audits explicitly in its own gated step, and the image build never acted on audit output. Review: 2026-07-05 full code review, Q13. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
…ts (Q6)
The two structurally-identical camera buttons and the two OCR
suggestion cards in the root +page.svelte become one {#snippet} each
(cameraButton: aria-label/label/pending/onclick; suggestionCard:
bold/dim/use/discard). Zero cross-file movement — the minimum-churn
variant the review prescribed; full OCR-subsystem extraction stays
optional later. Rendered markup unchanged (whitespace-equivalent), so
the ocr-flow e2e locators hold.
Review: 2026-07-05 full code review, Q6.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9RdAciNjtXHxCuA2uaLfX
…(D1) Root cause: the sync-queue replay is at-least-once, and two paths can re-POST a submission whose earlier POST already landed — the SW killed between the 200 and markSynced, or the foreground response lost in transit (the form's network-error catch enqueues with attempts: 0). The server's clientSubmissionId dedupe (IDEMPOTENCY_WINDOW_MS = 60_000, in-memory, wiped on restart) was sized for double-taps, not the hours-later next drain — so the replay created a duplicate gas record. Fix shape: every replayed POST now carries queueReplay: true (every one, not just attempts >= 1, because of the lost-response path), and the server consults the record store itself before writing a flagged submission — skip if a record with the same date + odometer + fuelConsumed (±0.0005 gal) exists, returning 200 + deduped with a snapshot mirroring the matched record. A failed pre-check returns 503 (never write on uncertainty; the entry stays queued and the 5-attempt cap backstops). fuelConsumed is in the match key because odometer prefill can give two same-day fill-ups the same odometer — a false match would silently drop a real fill-up; cost is excluded because FX drift between attempt and replay would break true matches. Rejected alternative: a file-backed seen-ID store — duplicates state the record store already holds, needs a TTL policy, dies with the volume, and keeps a (smaller) crash window between the upstream write and the file write. Querying LubeLogger makes the dedupe authority the source of truth and survives restarts with no new state. Design spec: docs/superpowers/specs/2026-07-11-offline-replay-dedupe-design.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNcvRjB9qc9PxvtWWKCA5d
The per-entry-loop doc still described the pre-#57 behaviour: currency sourced from loadServerInfo()?.lubeloggerCurrency ?? 'USD' plus a warning that the service worker always lands on the USD fallback. Since the #57 fix, sync-queue.ts reads BOTH cost and currency from the 2xx response body (submitted.cost / submitted.currency), and a body missing either yields no snapshot rather than a guessed currency. Rewrite the bullet to match the code and drop the stale warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNcvRjB9qc9PxvtWWKCA5d
Locks in the majority style that grew organically — 2-space indent, single quotes, printWidth 100, no trailing commas — via .prettierrc (with prettier-plugin-svelte wired for .svelte files) and reformats everything in one commit so the noise lands at a release boundary. Twelve tab-indented outliers (lubelogger.ts, ocr.ts, ocrProviders.ts, vehicle-identifiers.ts, their tests, the config files) converge on the majority. .prettierignore excludes generated output, the lockfile, and the runtime data volume. Two hand-fixes the reformat forced: - Four <a> tags whose multi-line rewrap moved them off their eslint-disable-next-line comments (svelte/no-navigation-without-resolve) now use block-form disable/enable, which survives future rewraps. - fx-chain.md's FRESH_MAX_MS code span wrapped across a line starting with '* 1000', which markdown parses as a list bullet — prettier's asterisk-escaping churned non-idempotently on it (would flap a CI --check). Rewrapped so the span sits on one line. Behavior-identical: full lint/check/test/build/e2e gate green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNcvRjB9qc9PxvtWWKCA5d
Adds format:check (prettier --check .) and runs it in ci.yml right after lint, so format drift fails the pipeline instead of accumulating until the next release-boundary reformat. deployment.md's CI-step list and the README scripts table document the new command. Dev-only — no runtime change, hence no CHANGELOG entry (same treatment as Q13/Q14). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNcvRjB9qc9PxvtWWKCA5d
Full grounding review of every technical/user doc plus architecture.md and deployment.md against the v0.3.1 code surfaced ~75 verified stale or wrong claims. All fixed here. Recurring root causes corrected: - pre-fix implementation descriptions left behind after the fix shipped (en-US locale pin, ISO-date/locale-invariant migration, queue pruning, 2-provider OCR opt-in vs the 4-slot chain, separate vs shared vehicle cache, truncate-to-0 vs rename-to-.1 audit rotation) - outdated constants and UI descriptions (17 vs 27 OCR env vars, 90s vs server-driven OCR timeout, 5-per-vehicle vs configurable 200 history cap, -/+ zoom buttons vs slider, camera-chip placement, Vehicles-list photos, "Log fuel" vs "Log fillup") - undocumented behavior (offline photo toast, legacy-date migration, SW error-forwarding responsibility, shared server modules, several /api/ocr and /api/fuelup request/response details) - dead #--main-form anchors (wrong slug) and the misleading LUBELOGGER_VOLUME_UNIT claim (only gallons_us works today) Also removed personal-machine/homelab specifics from deployment.md and corrected the .env.example BODY_SIZE_LIMIT comment (Infinity, not 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNcvRjB9qc9PxvtWWKCA5d
Two source comments contradicted the code they described: - ServerInfo.appCurrentVersion: it is set to APP_VERSION even on the UNREACHABLE fallback; null only when the Vite define is absent (vitest) - ODOMETER_MAX_DELTA_MI: the main form no longer imports it (#20b), so check E here is the single consumer, not "both call sites" Comments only — no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNcvRjB9qc9PxvtWWKCA5d
- photo-ocr.md date-prefill table: the isoDate initializer's "latent UTC-shift bug (out of scope)" note was stale — that bug was fixed with the smart-checks localIsoDate() seed; row now records it as since-fixed. - AuditRecord: declare the optional lastPricePerUnit field. Runtime audit rows already spread it on pump-mode requests; the interface only declared lastOdometerMi. Type-completeness only — no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNcvRjB9qc9PxvtWWKCA5d
Stamp CHANGELOG date (2026-07-14), bump package.json to 0.3.1, flip README status line to "v0.3.1 — stable". Screenshots intentionally not refreshed this cycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FNcvRjB9qc9PxvtWWKCA5d
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
v0.3.1 is a reliability, accessibility, and data-integrity hardening release. Full changelog section below.
[0.3.1] — 2026-07-14
Added
decimal point or comma as you type and enforces whole-number steps, so a
stray
.or,can no longer land in a reading. When the field is empty itsplaceholder now hints the last recorded reading — or "No last fuel up" when
there's no history — instead of a fixed example (enhancement: Odometer entry #64).
Changed
@sveltejs/kitto 2.68.0, dropping the temporary 2.64.0 pin.v0.3.0 pinned kit to 2.64.0 to dodge a CSS-preload doubled-path 404 regression
introduced in 2.65.0 (kit #16039);
the upstream fix (#16026) shipped in
2.65.1, so the pin is no longer needed and kit is back on a caret range at the
current latest. The
css-preloade2e guard confirms the doubled-path 404s staygone. Dev/build dependency only — no runtime or behaviour change.
fields carry an explicit accessible label rather than relying on their
placeholder, so assistive tech reads "Volume" / "Cost" instead of the example
value.
Fixed
technical and user doc (plus
architecture.mdanddeployment.md) againstthe v0.3.1 code found ~75 stale or wrong claims — pre-fix implementation
descriptions (locale pinning, ISO date handling, queue pruning, 2-provider
OCR activation, separate vehicle caches), outdated constants and UI
descriptions, dead anchors, and undocumented behavior. All verified findings
fixed; docs now match the shipped code.
failed toast after submitting a fill-up was invisible to assistive tech; it now
carries a live-region role (
status, oralertfor errors) like every othertransient panel in the app.
records in LubeLogger. If a submission's POST landed upstream but the app
never learned it (the service worker was killed before marking the entry
synced, or the response was lost in transit), the queued entry would be
re-sent on the next app open — past the server's 60-second dedupe window —
and a second identical gas record appeared in LubeLogger. Replayed
submissions are now flagged, and the server checks LubeLogger for an
already-landed record (same date + odometer + fuel volume) before writing,
so a re-send finds the original instead of duplicating it.
paid vision provider returned a response that then failed validation (bad
JSON, out-of-range reading), the spend was never recorded — the money was
burned invisibly and the daily budget cap could never trip on it. The budget
and the audit log now record the estimated cost of every paid attempt that
actually got a response, including failed attempts inside a provider chain;
pure network/timeout failures still cost nothing.
behind the OCR and client-log endpoints kept an entry for every IP that ever
called them — entries for IPs that never returned were never cleaned up, a
slow leak on a long-running server. Both maps now sweep out expired entries
opportunistically (at most once per rate window).
fillup". The last-fillup lookup compared records by date only, so with two
fillups on one date it returned whichever LubeLogger listed first — usually
the earlier one, which threw off the odometer prefill and the home-page
strip. Same-day ties now go to the record with the higher odometer reading.
The fillup and client-log endpoints read the whole request body before any
size check ran, so a huge (or hostile) upload could balloon server memory —
the container deliberately runs without a transport body cap. Both endpoints
now reject from the advertised
Content-Lengthup front with a clean 413:fillup at twice the photo policy (
OCR_MAX_IMAGE_MB) plus form slack, thelog endpoint at its existing 100 KiB batch cap. Requests without the header
still fall through to the existing post-parse checks.
upstream check failed,
/healthzreturned the internal error message —including the upstream status and a preview of its response body — to anyone
who asked. It now answers a generic "upstream unreachable" and keeps the
real cause in the server log.
swallow the next one. The API's required-field check let an empty or
whitespace-only
clientSubmissionIdthrough, where it became a sharedidempotency key — two different submissions sending
""within a minutecollided, and the second got the first's cached "success" without ever being
recorded. The server now rejects a blank or non-string id with a clear 400.
files. Tailwind v4's automatic class detection scanned the whole project —
including
compose.dev.yml/compose.example.yml— and turned the Dockersecurity_opthardening line into a bogus arbitrary-property CSS rule thatbrowsers logged as a dropped declaration on every page load. Detection is now
scoped to
src/(where all the app's markup lives), so the junk rule and itsconsole warning are gone; every real utility class is unaffected.
the rate sources were unreachable and you typed a manual rate, switching the
currency select — including back to your instance's own currency — hid the
field but silently kept the value, and the next submit sent it, mis-pricing
the fillup (the same retention could carry one currency's rate into
another). Any currency change now discards the typed rate.
storage are unavailable. When a submission hit a server error and the
offline save-for-later also failed (Safari private mode, storage quota), the
app showed nothing at all — no error, no queued chip, fill-up gone. It now
shows an explicit "Couldn't save — device storage unavailable. This fill-up
was NOT saved." error so you know to retry.
field blocks a typed
.or,, but pasting or autofilling a value like50123.4slipped through and was submitted as-is. The submission now roundsto the nearest whole reading, matching what the photo-OCR apply already did.
server reported. It previously fell back to the cached instance currency,
which shows the wrong symbol on a cold cache (a fresh install before the
server info has been fetched). The server response carries the authoritative
currency for the converted cost — the toast now uses it.
for attachment. Tapping a photo pill again used to throw away the retained
pump/odometer photo the moment the picker opened — backing out of the picker
left nothing to attach and the attach row gone. The retained photo is now
replaced only when a new one is actually picked.
discarded. Floating-point rounding in the zoom/pan crop math could push an
edge-flush crop a hair past the image boundary, and the sanitizer then threw
the whole crop away — the full photo went to OCR as if no crop had been
made. Such crops are now clamped to the edge and kept; genuinely invalid
crop rectangles still fall back to the full image.
offline queue pruned synced fill-ups down to the newest 5 per vehicle on
every sync — and those rows are exactly what the History page shows, so
older entries (and their converted-cost snapshots) quietly vanished. The
cap is now a setting — Settings → "Fill-ups kept per vehicle", default
200 — so History keeps a real trail and you control how much stays on the
device.
Tests
#odometerid rather than byplaceholder text (6 specs, 21 call sites). Placeholder text isn't a stable
test contract — matching on it coupled the specs to a fixed string and would
break them when the placeholder becomes dynamic; the id keeps them robust.
Test plan
scripts/scan.sh)🤖 Generated with Claude Code