Skip to content

Latest commit

 

History

History
364 lines (343 loc) · 26.7 KB

File metadata and controls

364 lines (343 loc) · 26.7 KB

Ledger (native) — project guide

Ledger is a private, local-first money tracker, migrating from a single-file PWA to a Svelte + TypeScript app wrapped in Capacitor for native Android (iOS later), while keeping a web/PWA build. This file orients any new session working in this repo. See MIGRATION_PLAN.md for the phased roadmap and DESIGN.md for the visual system.

Golden rules

  • Private & local-first. All data lives on-device. Nothing leaves it except via opt-in, end-to-end-encrypted sync. Never add analytics, telemetry, accounts, or a server. No new network calls without explicit sign-off.
  • No feature regressions. The original app is preserved at reference/index.html — it is the parity oracle. When migrating a screen, diff behavior against it and do not silently drop features.
  • Keep the identity. Preserve Ledger's visual design (see DESIGN.md). "Native" means ergonomics + real system components, not re-skinning to stock Material/Cupertino.
  • Backendless sync. Sync is a bring-your-own-storage adapter (SyncBackend); client-side crypto is unchanged. Don't introduce a backend.
  • Small & auditable. Minimal dependencies — this is a security-sensitive finance app. Justify every new dependency; prefer platform/standard APIs. From P4, Capacitor plugins (with their web fallbacks) are the justified path to native capabilities — one implementation, not two hand-rolled web/native paths that drift; vet community plugins. See MIGRATION_PLAN.md → Locked decisions → P4+ native-first.
  • Don't touch the crypto params. AES-GCM + PBKDF2 (250k iterations, SHA-256). Don't weaken them.

Stack

  • Svelte + Vite + TypeScript; Vitest for tests. Not SvelteKit (SPA, no server).
  • Capacitor for native (Android now, iOS later). Native access via @capacitor/* plugins behind a src/lib/platform/ layer with web fallbacks (Capacitor.isNativePlatform() / Capacitor.getPlatform()).
  • Navigation: in-memory tab state, no URL router. Wire the Android hardware back button to tab state.

Architecture

  • src/lib/domain/ — framework-agnostic, typed, tested: state model, money math, currency/format, crypto, SyncBackend interface + implementations.
  • src/lib/stores/ — Svelte stores wrapping domain state + persistence (autosave).
  • src/lib/platform/ — Capacitor bridges (storage, share, filesystem, biometric, notifications, secure storage) with web fallbacks.
  • src/lib/components/ — reusable UI. src/views/ — the tab screens.
  • reference/index.html — original app (parity oracle; do not delete).
  • reference/ledger-testdata.jsonthe test fixture. Use it. Synthetic, safe to commit, and it exercises every feature: schema v9, ₱/PHP, 6 accounts (incl. a negative-opening credit card), 157 transactions (116 expense / 18 income / 23 transfer, 6 of them with a fee), 11 templates (5 recurring · 6 shortcuts, 2 variable-amount, all three types), 4 goals (one account-less), 5 IOUs both directions, and both debt systems — the tag-based People flow (13 tagged transactions across owes_me, their_money, paying_me, paying_back, plus sid settlement records) and the legacy loan system (one unsettled lent, one settled borrowed), which render together on People as · old loan. Load it through Settings → Restore from backup before checking any screen — an empty ledger renders empty states and hides nearly every parity bug. Extend this file when a case is missing rather than inventing throwaway data. Two properties to keep rather than "tidy up": 9 transactions are dated after today, so they land in this month's totals — whether that was intended when the fixture was generated is unknown, but it is kept on purpose as future-dated-entry coverage. Template-logged rows carry tpl; settlement rows carry sid with an empty account.
  • www/what ships today (Phase 1–2): the app plus the native shell. www/index.html is editable source and has now diverged from reference/index.html — the oracle stays frozen, so diff before assuming parity. The differences are the Phase 1 shell hooks (viewport-fit=cover, the native.css link, the two shell <script> tags) plus the Phase 2 cleanups: no window.storage adapter, no service-worker registration, no CSV import, and a softer backup nag. www/native.css (safe areas) and www/native.js (WebView capability probe, system bars, back button) are the shell; www/capacitor.js is staged from node_modules by npm run prepare:www and is gitignored. www/sw.js is a self-destructing kill switch, not a service worker — it exists only to clear the cache the old worker left on devices that installed an earlier APK, since those still serve a stale index.html cache-first. Keep it until every install has been through it; the real web-build SW lives at reference/sw.js. Phase 3 replaces all of it when webDir flips to dist/ — 🛑 but sw.js must keep being served from the app root through that flip. public/ does not contain it today, so a naive flip drops it, and a P1-era device then gets a 404 for sw.js, which makes its old worker stay registered and serve the stale cache permanently. The Huawei is being held on a P1 build precisely to test that path.
  • Token invariant: the status-bar colour has a native half — android/app/src/main/res/values{,-night}/colors.xml defines ledgerPaper, which must stay in step with the --paper / dark --paper tokens. Below Android API 35 the system paints the bars from these resources; www/native.js then overrides at runtime so the bar follows the app's theme rather than the device's.

Platform support

The binding constraint is the WebView (Chromium ≥ 105), not the Android version — WebView updates via the Play Store independently of the OS. :has() and the independent translate: property set that bar, and translate: is load-bearing (it centres and animates .toast). Declared/tested floor: Android 9 (API 28), verified on a Huawei running Chrome 138. minSdkVersion stays 24 — Android 7–8 are "probably fine, untested", not supported. Phase 2 added the runtime capability probe for exactly these two properties at the top of www/native.js; under-spec WebViews get a plain banner rather than a subtly broken layout. See MIGRATION_PLAN.mdLocked decisions for the rationale.

Testing the reject path: keep an AOSP Android 10 (API 29) AVD around — its com.android.webview is pinned at 74 and there is no Play Store, so it cannot update itself above the floor (a Google Play image would). It is the only way to see the failure branch, and it makes the breakage visible: flex gap is absent, so labels run together. It is not a supported target — its job is proving under-spec users get the banner. In P3, keep the probe in its own ES5 <script> outside the Vite bundle, or a SyntaxError in modern bundle output will white-screen the page before the banner can render.

The web build has no probe — it lives only in www/native.js, so an old browser still gets the silent breakage, which quietly undercuts the "excluded users still have the web build" rationale. Add it to Vite's index.html in P3; never retrofit the frozen oracle. :has() needs Safari 15.4, and on iOS every browser is Safari's engine.

Data model

Accounts · Transactions (income / expense / transfer + fee) · Loans (lent/borrowed, partial settle) · Templates (recurring + shortcuts, fixed/variable amount) · Goals · IOUs · Settings. Backup schema is version 9 (see the original backupText). Keep restore backward-compatible.

Toolchain / prerequisites

Install once before scaffolding (Phase 0). Node covers the web/Svelte/Capacitor side; Android Studio covers the native Android build.

  • Node.js LTS (20+) + npm — Vite, Svelte, Capacitor CLI, web build (a version manager like nvm/fnm is convenient).
  • Git.
  • Android Studio — bundles the Android SDK, build-tools, platform-tools (adb), a compatible JDK 21 (its JetBrains Runtime), and the emulator; it also works with Capacitor's Gradle wrapper (./gradlew fetches Gradle itself — no separate Gradle or JDK install needed).
  • After installing Studio:
    • SDK Manager → an SDK Platform (API 34/35), Build-Tools, Platform-Tools, Command-line Tools.
    • Accept licenses: sdkmanager --licenses.
    • Env vars (for CLI builds / adb): ANDROID_HOME → SDK path; add platform-tools to PATH; JAVA_HOME → a JDK 21 (can point at Studio's bundled jbr). Building only via Studio's UI needs no JAVA_HOME.
    • A physical device (USB debugging on) or an emulator AVD to run on.
  • Verify: npx cap doctor, then npx cap open android should build a debug APK.
  • iOS (later, not now): macOS + Xcode + CocoaPods — Mac-only (or a cloud-Mac CI).

Windows

  • Physical device: install the Google USB Driver (SDK Manager → SDK Tools) and enable USB debugging; some OEMs need their own driver too.
  • Long paths: deep node_modules/Gradle paths can exceed the legacy MAX_PATH — enable Win32 long paths (registry LongPathsEnabled=1 or Group Policy) and git config --global core.longpaths true.
  • Env vars: set ANDROID_HOME / JAVA_HOME via System Properties → Environment Variables (or setx); restart the terminal afterward.
  • Emulator speed: enable hardware acceleration (WHPX/Hyper-V or the Android Emulator hypervisor driver). Excluding the project + SDK folders from antivirus noticeably speeds up Gradle.

macOS

  • Xcode Command Line Tools (xcode-select --install) for the git/build toolchain; Homebrew for Node. Apple Silicon is fine. (Full Xcode + CocoaPods only when adding iOS.)

Linux

  • Emulator: enable KVM and add your user to the kvm group for usable speed.
  • Physical device: add udev rules (51-android.rules) so adb detects it.

Commands

  • Dev server: npm run dev
  • Tests: npm run test (Vitest, run mode) / npm run test:watch. Wired in P3a with the domain modules. The static + test gate is npm run gate (check then test). npm run check (svelte-check + tsc) remains the static-only gate. CI wired in P3e: .github/workflows/ci.yml runs npm run gate on push/PR to develop (+ production). The golden-master fixture (src/lib/domain/__golden__/oracle.json) is captured from the oracle by TZ=UTC node scripts/capture-golden.mjs (jsdom; regenerate only if the oracle or the domain's expected outputs change — the tests run under UTC to match it).
  • Build: npm run builddist/ (base /, for native/dev); npm run build:webdist/ (base /ledger/, for the Pages deploy). Both stage public/capacitor.js via prepare-public.
  • Sync native: npm run cap:syncbuilds dist/ then cap sync (webDir is now dist).
  • Android: npm run android (sync + open Studio), or ./gradlew assembleDebug inside android/android/app/build/outputs/apk/debug/app-debug.apk
  • iOS (later, needs macOS/Xcode): npx cap open ios
  • App ID: io.friendsnone.ledger (permanent; no domain purchase needed).
  • Capacitor webDir: dist (flipped in P3e; was www in Phase 1–2). www/ is frozen, pending P4 retirement.
  • Web deploy: GitHub Pages via Actions (builds dist/); Vite base: '/ledger/' (served at friendsnone.github.io/ledger/). No SPA 404 fallback — nav is tab state.

Git workflow

  • Branches:
    • legacy — archived pre-migration history (the old single-file PWA). Reference only; never build on it. Unrelated history to develop by design.
    • developdefault / integration branch; day-to-day work lands here.
    • production — stable release snapshots; GitHub Pages deploys from pushes here (an Action builds dist/). Promote a build by merging developproduction.
    • Feature work → short-lived branches off develop, merged back via PR.
  • Commits: Conventional Commits (feat:, fix:, docs:, refactor:, test:, chore:) — matches the original app's feat/fix history. Keep messages descriptive.
  • History: don't rewrite published history on develop / production; rebase only local feature branches before merging.

Migration status (keep this current)

Nothing has shipped yet, and that is deliberate. First ship is after P4. P1 and P2 are technically shippable; we are choosing not to. Until then the legacy single-file PWA stays hosted and is what real users are on, and the native build's whole audience is the dev devices. This is what makes the accepted carve-outs tolerable — GitHub sync's push does not land, and the native build cannot export data at all (backup download and CSV export both produce no file). Both must be closed by P4, because first ship is when they stop being tolerable. Until then, do not keep a real ledger in the native build. See MIGRATION_PLAN.mdLocked decisionsWhen we first ship.

  • P0 Alignment & docs
  • P1 Ship literal copy in Capacitor — www/ copy + native shell + debug APK. Verified on device: screen-by-screen parity vs the oracle, offline launch (airplane mode), data surviving recents-swipe / force-stop / reboot, the whole back-button chain, restore-from-backup, autocomplete with the soft keyboard up, native date/time pickers, privacy blur, theme switching, and system bars + safe areas across four devices spanning API 28–37 (matrix in MIGRATION_PLAN.mdVerification). Two accepted carve-outs, both deferred on purpose: 🔴 GitHub sync broken — pre-existing, push does not land; leave it off, dies in P4a. 🔴 Backup download & CSV export produce no file on native — a real regression vs the web build; needs P4's native share/save. Until then the native build has no way to export data, so don't keep a real ledger in it.
  • P2 Cleanup — dropped the window.storage shim, the native-build service worker (www/sw.js is now a kill switch for stale caches on older installs; the web build keeps reference/sw.js), and CSV import (CSV export stays). Softened the backup nag: first at 25 records, repeat after 30 days or 50 changes, and "Not now" is a real persisted 14-day snooze instead of a flag reset on every launch. Added the WebView capability probe in native.jstranslate: + :has(), with a plain banner instead of a silently broken layout. Verified in the web parity harness (7 of 8 screens byte-identical to the oracle, Settings differing only by the removed Import CSV button and its reworded hint), on both emulators, and — decisively — on the realme, which was carrying a genuine P1-era install rather than a staged one. adb install -r without uninstalling left caches [], no registration, an uncontrolled page and the P2 UI, with every record and setting preserved and nagSnooze the only key added. Offline launch was re-verified in airplane mode with zero caches — the APK, not the SW, is what makes native offline.
  • P3 Adapt to TS + Svelte, in five gated sub-phases (split to stop the logic-extraction and view-rebuild from drifting the oracle when done at once — see MIGRATION_PLAN.md → Phase 3): P3a extract the pure domain to typed Vitest-covered modules (golden-master vs the oracle); P3b tokens + reusable primitives, checked in isolation; P3c static screens, visual/structural parity before any behaviour; P3d wire the trusted domain in, functional parity 100%; P3e asset swap + webDir flip, carrying the three P2 gates (sw.js into dist/ root, probe outside the bundle as ES5, probe on the web build). Renderers go straight to Svelte — never an interim TS renderer.
    • P3a done. Pure domain extracted to src/lib/domain/ (types, money, date, currency, crypto, backup, derive, state reducers + Undo, sync) — ported near-verbatim, taking state/slices as params, no DOM. Vitest + jsdom installed; npm run test / npm run gate wired. 79 tests green, incl. a golden-master (golden.test.ts) that reproduces the oracle over the full fixture exactly: net worth, per-account balances, monthly totals, People balances + the partial-settle FIFO, transfers staying .neu, template due/overdue, schema-v9 backupText byte-identical, and opening a reference-sealed crypto blob (AES-GCM/PBKDF2 250k unchanged). One intentional divergence logged in PARITY_NOTES.md: backupDue uses the softened P2 thresholds (matches www, not the frozen oracle). The www build still ships unchanged. Golden regenerated by TZ=UTC node scripts/capture-golden.mjs.
    • P3b done. Design tokens live once in src/lib/styles/tokens.css (:root + [data-theme="dark"] verbatim from the oracle + minimal base/reset + .num/.pos/ .neg/.neu + .priv-* masking); every other style is component-scoped. 22 reusable primitives in src/lib/components/ (barrel index.ts): Icon/IconSprite, Button, IconButton, RowIconButton, Act, Seg, Card, Field, Input, Select, Dot, Row, Badge, SnapRow, Pills, Hero, Nag, Toast, Modal, ConfirmModal, Fab — each reproduces the oracle's CSS declaration-for-declaration and owns its scoped styles. Verified in the dev-only isolation harness (src/lib/dev/Harness.svelte): computed heights/sizes/tones, the load-bearing .toast translate:, and privacy masking match the oracle ≤480 then >780, light then dark; npm run check clean, 79 domain tests still green. Two best-of (a11y) deviations logged in PARITY_NOTES.md. Icons stay the inline sprite and fonts are unpackaged — both swap in P3e. webDir still www.
    • P3c done. The global shell (src/App.svelte + src/lib/shell/: header, tab bar with the ≤780 bottom-dock, footer, back-to-top, FAB, toast, sprite, modal/nag slots) and all 8 static screens under src/views/ (shared parts in src/views/parts/), composed from the P3b kit + the trusted P3a derivations via a dev session (src/lib/dev/session.svelte.ts, fixture → state); view helpers in src/lib/view/ (format/range/trends/help). Every screen is an EXACT match to the oracle — tag+class signature and #app innerText, zero diffs on the full fixture — plus mobile/desktop screenshot parity. npm run gate clean (79 tests). Three within-parity primitive notes (Trends SVG charts via {@html}; <Seg> optional icon; <Select> unkeyed) in PARITY_NOTES.md. Fonts + icons still P3e. Behaviour/wiring is P3d; the P3b Harness.svelte is superseded but kept. webDir still www.
    • P3d done. The dev session was replaced by a real store layer: src/lib/platform/ store.ts (localStorage→IndexedDB→memory, same K keys as www) + src/lib/stores/ (ledger.svelte.ts data core/settings/ephemeral UI as $state; commit() = snapshot → reducer → autosave → Undo toast; theme/privacy/nav/backup helpers; plus toast, modal, actions, sync stores). All 8 screens + parts wired (data-*onclickactions.ts); 6 form modals + 4 sync modals under src/lib/modals/ behind a ModalHost; view adapters view/{form,entry,template,statement,backup,autocomplete}.ts added (the last incl. the P3a-deferred rankByUse/acSource, wired via a use:autocomplete action), P3c view/{format,range,trends,help} kept (trends/range parameterised by live period). Orphans deleted (dev/session.svelte.ts, dev/Harness.svelte, parts/EntryForm.svelte); DEV-only dev/seed.ts (dynamically imported, out of the prod bundle) seeds the fixture. npm run gate clean (0 errors/warnings, 84 tests). Same-origin harness: 7/8 screens exact match (structure + #app text) to the oracle; Settings differs by exactly the intended CSV-import drop (tracking www, not the frozen oracle — like backupDue). Functional checks through the store: log+Undo, edit-account modal, People partial-settle (Jonah 1300−500=₱800), Track filter/statement, add-account, currency, quick-add field-swap, privacy, collapsible-card persistence, autocomplete (where/person ranked suggestions) — all correct. Sync is wired end-to-end (setup/reset/rename/conflict modals, connected card, disconnect, manual sync, header button). Only the GitHub backend + its setup fields retire in P4a — not the machinery: the state machine, the conflict diff/merge (moved into the domain sync module), the crypto envelope and the SyncBackend seam are backend-agnostic, so Drive slots in behind the same interface. The durable core is CI-tested end-to-end against a fake in-memory backend (domain/sync/roundtrip.test.ts: push→pull decrypt, sha-guard CONFLICT, both-changed→merge, clash by prefer, wrong-passphrase). GitHub's push bug is still not chased (auto-push off); only the live round-trip against a real repo is untestable here. Known minor: in-progress form drafts are screen-local (persistent view state persists via ui). Details in PARITY_NOTES.md → P3d. Fonts + icons + webDir flip still P3e.
    • P3e done — Phase 3 complete. Asset swap + build unification + the webDirdist cutover, verified in the browser and on-device (all gates passed). Fonts: base64 → @fontsource per-weight imports (main.ts; weights within 400/600/700, Fraunces 600 only) + a trim-fontsource Vite plugin that drops the non-latin subsets and legacy woff from Fontsource's weight CSS → 14 woff2 / 220 KB, keeping latin+latin-ext (where ₱ / U+20B1 lives) with unicode-range intact (Fontsource's recommended model; woff drop matches upstream #1068). Icons: inline sprite → tree-shaken phosphor-svelte (Icon.svelte maps 21 names; IconSprite deleted) — glyphs identical (sync → ArrowsClockwise), markup differs (<path> vs <use>), a best-of #5 delivery change. Native shell into the Vite build: index.html gains viewport-fit=cover + classic /capacitor.js + /native.js; public/native.js is the successor to www/native.js (reworded platform-neutral ES5 probe above #root; modal selector .modal-bg .modal-x); native.css imported after tokens; capacitor.js staged by prepare-public.mjs. www/* left frozen (P4 retirement). Three P2 gates: sw.js at dist/ root (public/sw.js); web build now runs the probe; probe stays ES5 & outside the bundle. Build: Vite base mode-based ('/' native/dev vs '/ledger/' for npm run build:web). Harness on dist/: 7/8 exact, Settings = the CSV-import drop; light/dark match; gate clean (84 tests). ✅ On-device: all cutover gates PASSED (3 AVDs + real Huawei via adb/CDP) — AOSP-10/WV74 probe reject-path (banner renders; fixed it to insert immediately, not at DOMContentLoaded, else a doomed-bundle cold start flashed white); modern boot/render (WV124/150, read old P1/P2 localStorage unchanged); airplane-mode offline; and the decisive Huawei P1→P3 in-place upgrade (install -r: stale SW + ledger-v101 cache cleared, P3 UI, settings preserved — Huawei now on P3). Fonts ₱/currency (kept as-is): JetBrains Mono (and the oracle's inline copy) lack ₹ ₱ ₩ ฿ ₺ → system-monospace fallback, device-dependent at oracle parity (renders on the real Huawei); check coverage with fontkit, not document.fonts.check (unreliable for monospace). CI/deploy added: .github/workflows/ ci.yml (gate) + deploy.yml (Pages, dormant — production-only + Pages Source still on the legacy branch). P4 cleanup: retire www/+sw.js, flip Pages Source at ship. Details in PARITY_NOTES.md → P3e.
  • P4 Native → FIRST SHIP (gated sub-phases; native-first via Capacitor plugins; replace/improve existing features, keep the feature set + identity — native mechanisms may differ — no new features): P4a Google Drive sync (replaces GitHub sync; CapacitorHttp) + Keystore for sync secrets; P4b native file ops — @capacitor/share+filesystem + picker (fixes the dead export); P4c hardening (storage→@capacitor/filesystem, web fallback = IndexedDB, + migration; retire www/+sw.js; status-bar polish); P4d exhaustive back-to-front check vs the reference across all devices + web, then cut over (Pages Source→Actions, develop→production, version bump). P4a/P4b close the P1/P2 carve-outs (broken sync, no export); first ship is P4d — a seamless successor to the legacy PWA (feature-complete + identity-preserving; native improvements welcome).
  • P5 New features (after first ship — additive): biometric/PIN lock, bill notifications, haptics, widget/QS tile, extra sync backends (Dropbox/WebDAV), "Import from…", iOS build. See MIGRATION_PLAN.md → backlog.

Dropped / changed (don't reintroduce without reason)

  • Dropped: window.storage Claude-artifact adapter; service worker in the native build (kept for the web build); CSV import.
  • Kept: CSV export.
  • GitHub sync ships in v1 → replaced by Google Drive sync (native Google Sign-In, drive.appdata, no backend), then retired.
  • Asset delivery: base64-inline fonts + inline SVG icon sprite → Vite-packaged local fonts (Hanken Grotesk / Fraunces / JetBrains Mono, weights 400/600/700 only) + tree-shaken Phosphor icons. Styling: scoped CSS + tokens, no utility framework (Tailwind/UnoCSS evaluated and set aside); each primitive owns its scoped styles (.btn in <Button>). See DESIGN.md.

Do / Don't

  • DO port screen-by-screen with parity checks against reference/index.html, following MIGRATION_PLAN.md## Parity method (functional = 100% exact; visual defaults to the oracle, deviate only as whitelisted "best-of" and log it in PARITY_NOTES.md; check each screen mobile then desktop before the next).
  • DO keep Undo on every mutation and the privacy-blur behavior.
  • DON'T add a backend, accounts, analytics, or external CDN/fonts (stay CSP-safe, offline).
  • DON'T break the Android back button, safe areas, or offline launch.

References

  • MIGRATION_PLAN.md — phased roadmap, decisions, feature triage, verification
  • DESIGN.md — visual system & platform-adaptation rules
  • reference/index.html — original app (parity oracle)