Skip to content

release: v0.3.1 — reliability, accessibility & data-integrity hardening - #67

Merged
varunpan merged 51 commits into
mainfrom
release/v0.3.1
Jul 14, 2026
Merged

release: v0.3.1 — reliability, accessibility & data-integrity hardening#67
varunpan merged 51 commits into
mainfrom
release/v0.3.1

Conversation

@varunpan

Copy link
Copy Markdown
Owner

Summary

v0.3.1 is a reliability, accessibility, and data-integrity hardening release. Full changelog section below.

[0.3.1] — 2026-07-14

Added

  • Odometer entry is now whole-numbers only. The odometer field rejects a
    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 its
    placeholder 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

  • Re-bumped @sveltejs/kit to 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-preload e2e guard confirms the doubled-path 404s stay
    gone. Dev/build dependency only — no runtime or behaviour change.
  • Screen readers now announce the Volume and Cost fields by name. Both amount
    fields carry an explicit accessible label rather than relying on their
    placeholder, so assistive tech reads "Volume" / "Cost" instead of the example
    value.

Fixed

  • Documentation reconciled with the code. A full grounding review of every
    technical and user doc (plus architecture.md and deployment.md) against
    the 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.
  • Screen readers now announce the submit outcome. The logged / saved-offline /
    failed toast after submitting a fill-up was invisible to assistive tech; it now
    carries a live-region role (status, or alert for errors) like every other
    transient panel in the app.
  • Offline fill-ups replayed hours or days later can no longer create duplicate
    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.
  • Failed-but-billed OCR calls now count against the daily budget. When a
    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.
  • Rate-limiter memory no longer grows forever. The per-IP tracking maps
    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).
  • Two fillups on the same day no longer show the earlier one as "last
    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.
  • Oversized uploads are now rejected before they are buffered into memory.
    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-Length up front with a clean 413:
    fillup at twice the photo policy (OCR_MAX_IMAGE_MB) plus form slack, the
    log endpoint at its existing 100 KiB batch cap. Requests without the header
    still fall through to the existing post-parse checks.
  • The health endpoint no longer echoes raw LubeLogger error text. When the
    upstream check failed, /healthz returned 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.
  • A fillup submitted with a blank submission id can no longer silently
    swallow the next one.
    The API's required-field check let an empty or
    whitespace-only clientSubmissionId through, where it became a shared
    idempotency key — two different submissions sending "" within a minute
    collided, 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.
  • Stopped Tailwind from emitting an invalid utility class out of the compose
    files.
    Tailwind v4's automatic class detection scanned the whole project —
    including compose.dev.yml / compose.example.yml — and turned the Docker
    security_opt hardening line into a bogus arbitrary-property CSS rule that
    browsers 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 its
    console warning are gone; every real utility class is unaffected.
  • A manually-entered FX rate no longer survives a currency switch. When
    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.
  • A fill-up can no longer vanish silently when both the server and device
    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.
  • A pasted decimal odometer reading is rounded before submit. The odometer
    field blocks a typed . or ,, but pasting or autofilling a value like
    50123.4 slipped through and was submitted as-is. The submission now rounds
    to the nearest whole reading, matching what the photo-OCR apply already did.
  • The "Logged: …" confirmation now formats the cost in the currency the
    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.
  • Reopening the camera and cancelling no longer discards the photo queued
    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.
  • A crop dragged flush against the photo's edge is no longer silently
    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.
  • History is no longer silently capped at 5 fill-ups per vehicle. The
    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

  • e2e specs locate the odometer field by its #odometer id rather than by
    placeholder 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

  • Automated sweep green — lint / check (0 errors, 0 warnings) / 806 unit tests / build / 92 e2e
  • Image vulnerability scan clean — 0 fixable CRITICAL/HIGH (Trivy, scripts/scan.sh)
  • Per-feature/fix UAT completed on iPhone Safari during the release cycle (each change UAT'd before its merge into the release branch)
  • Screenshots NOT refreshed this cycle — shipping stale by explicit choice (notably the new Settings "Fill-ups kept per vehicle" control)

🤖 Generated with Claude Code

varunpan and others added 30 commits June 26, 2026 11:53
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
varunpan and others added 21 commits July 7, 2026 17:34
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
@varunpan
varunpan merged commit c7a74cd into main Jul 14, 2026
2 checks passed
@varunpan
varunpan deleted the release/v0.3.1 branch July 14, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants