Commit Graph

547 Commits

Author SHA1 Message Date
null f909e8f587 fix(docker): regenerate lockfile with npm 10 so `npm ci` works in build
The lockfile had drifted (written by local npm 11 / Node 25) into a form that
node:22-alpine's npm 10.9.8 reads as incomplete — `npm ci` failed with
"Missing: esbuild@0.28.1 from lock file", breaking the Docker image build
(Dockerfile now uses `npm ci`, not `npm install`). Regenerated the lock inside
node:22-alpine with its own npm so `npm ci` resolves cleanly (887 packages,
lockfileVersion 3). No dependency version changes — lockfile metadata only.

Verified: clean `npm ci` in node:22-alpine, full image build, container boots
migrations + workers, full QA (auth/CSRF/admin/security) and a live SimpleFIN
sync all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:44:23 -05:00
null 1457de487f feat(security): show a key fingerprint (not the key) in the encryption status
The encryption key must never be retrievable through the app (that would undo
the whole point of TOKEN_ENCRYPTION_KEY — a stolen session/XSS could then read
the master key). Instead surface a non-reversible fingerprint so operators can:
- verify which key is currently active,
- confirm a running instance matches the key they backed up, and
- spot an accidental key change (which would make existing secrets unrecoverable).

- encryptionService.keyFingerprint(): domain-separated SHA-256 prefix of the
  active key (env if set, else the stored DB key); never force-creates a key
  (returns null when none exists), shares nothing with the cipher derivation.
- admin bank-sync-config exposes key_fingerprint alongside encryption_key_source.
- Admin Bank Sync card renders it with backup guidance.
- Test: stable per key, differs by key, not reversible / not the raw key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:15:32 -05:00
null f3f46a73f0 chore(deploy): harden Docker/compose from the deployment audit
- Dockerfile: npm ci (reproducible, lock-pinned, matches CI) instead of npm
  install; add a HEALTHCHECK against the /api/health liveness route.
- package.json: pin engines node>=22.18.0 — the server require()s .mts/.cts and
  relies on Node's default TS type-stripping (unflagged only on >=22.18), which
  was previously undocumented on a floating base image.
- .dockerignore: exclude .env / .env.* so a stray env file can't be baked into
  the image by COPY . .
- docker-compose: set TRUST_PROXY=true (behind the reverse proxy, so Secure
  cookies + client IP for rate-limit/audit are correct); flip CSRF_HTTP_ONLY to
  true (SPA reads the token from an endpoint, so no JS cookie access needed);
  remove the active default INIT_ADMIN_PASS; document TOKEN_ENCRYPTION_KEY (set
  it to move at-rest secrets off the DB-resident key); drop obsolete version key.

Not included: TOKEN_ENCRYPTION_KEY itself — that's a secret to generate and
inject into the deployment env (the app self-migrates to the env-key scheme on
next boot); documented in compose + .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:54:50 -05:00
null a03216ee55 perf(simplefin): batch sync writes in a transaction + align dedup key
- Wrap each source's account/transaction upserts + the status update in one
  better-sqlite3 db.transaction() — a single commit instead of one implicit
  commit per row (far fewer fsyncs on large syncs) and atomic (a mid-sync
  failure can't leave a half-written account). The write block is synchronous;
  the async SimpleFIN fetch already completed before it.
- Key the dedup lookup on (user_id, provider_transaction_id) to match the UNIQUE
  index and the reconnect-stable provider id. A data_source_id-keyed lookup
  missed the prior row after a disconnect/reconnect, so pending→posted and
  amount-refresh were skipped; now they survive a reconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:54:34 -05:00
null 4638d24a1d fix(payoff): correctness, data-integrity & clarity of the payoff simulator
Data integrity (A):
- "Apply … to my budget" overwrote the bill's expected_amount (its real
  recurring amount, used in budgets + payment recording) with the simulated
  payoff payment. Replace with "Save APR to this bill" — persists interest_rate,
  leaves expected_amount untouched. Fixes the corruption AND the interest-free
  root cause (future runs use the saved APR). BillModal debt section also opens
  when a bill has an interest_rate.

Error handling (B):
- Undo now has try/catch + error toast (was an unhandled rejection that left the
  wrong value applied); Apply uses errMessage.
- loadData no longer snaps the selection back to the first bill after a
  save/undo (was a stale-closure over selectedId); background refreshes are
  "silent" so a transient failure no longer replaces the whole page.

Makes-sense (C):
- Distinguish "no APR" from a real 0% and show a notice (was silently interest-
  free with no indication).
- Without a real minimum, show absolute Time-to-Payoff + Total Interest instead
  of a broken "$0 savings" / "~580 mo sooner".
- Remove the arbitrary, mislabeled "Snowball plan" line (was extra applied only
  to the alphabetically-first bill) from the page + chart.
- Detect the 600-month cap (incl. 0% tiny payment) as "won't pay off".
- Correct the "set a minimum on the Snowball page" guidance (no such editor).

Clarity (D):
- Plain-language toasts on every write; targeted HelpCircle tooltips (Interest/
  Time Savings, Total Interest, Required Minimum, APR, Save-APR); a clearer page
  description; states that explain why + what to do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:09:12 -05:00
null c9d90a125f docs(brand): add BRAND.md guidelines
120-line brand guidelines document covering the v0.42.0 brand
system: naming ('Bill Tracker' with the space, not 'BillTracker'),
asset paths, lockup usage rules (light vs dark variants, mono
mark, social preview), the canonical source-of-truth in
client/lib/brand.ts, and the GitHub-Open-Graph card dimensions.

This is commit 4 (and the last) of the v0.42.0 follow-up batch.
Docs-only; no code change. Depends on 68e57cf (favicon + PWA).
2026-07-05 17:50:18 -05:00
null 68e57cf45b chore(brand): favicon + PWA manifest follow the new SVG lockup
index.html: favicon and apple-touch-icon now point at the SVG mark
(plus the legacy PNG fallback for browsers that don't honour SVG
favicons). vite.config.mjs: VitePWA manifest icons reference the
SVG mark + social-preview, theme_color and background_color
inherit from the new brand tokens.

No behaviour change for end users — the PNG derivatives stay in
the same directory and are still used by the PWA splash / app-icon
paths that need raster.

This is commit 3 of the v0.42.0 follow-up batch. Depends on
1c04dfd (Brand components).
2026-07-05 17:50:11 -05:00
null 1c04dfdf8c feat(brand): BrandMark/Wordmark/Lockup consume the new SVGs
Brand.tsx (101 line churn) and brand.ts (47 line churn) now expose
the new SVG-first primitives:

- BrandMark: <img> with BRAND.assets.mark (vector, scales to any size).
- BrandWordmark: text fallback if the mark is unavailable.
- BrandLockup: the combined mark + wordmark for the layout chrome,
  honours the light/dark variant via CSS variables.
- BrandGlyph: unchanged but re-uses the new accent tokens.

brand.ts adds the new asset-path constants (mark, markMono, lockup,
lockupLight, socialPreview) and a tokens helper so the lockup can
switch variants without prop drilling.

This is commit 2 of the v0.42.0 follow-up batch. Depends on
720d31b (SVG assets).
2026-07-05 17:50:04 -05:00
null 720d31b56e feat(brand): SVG lockups + mark + social preview assets
Adds the vector source-of-truth for the v0.42.0 brand refresh:
- lockup.svg / lockup-light.svg: the wordmark + mark combination,
  light and light/dark variants
- mark.svg / mark-mono.svg: the standalone mark, colour and mono
- social-preview.svg: 1200x630 Open Graph / oEmbed card

These are the assets the new BrandMark / BrandWordmark / BrandLockup
components (commit 3 of this batch) consume, and the assets the
favicon / PWA manifest (commit 4) reference. They replace the
PNG-only brand directory committed in dfee7f9 with a vector
first-class set; PNG derivatives stay in the same dir for legacy
favicon / PWA paths.

This is the first commit in the v0.42.0 working-tree follow-up; the
previous v0.42.0 brand refresh landed as 9 commits (dfee7f9..3de91af)
in the prior round.
2026-07-05 17:49:55 -05:00
null 4b81600afe fix(brand): finish the "BillTracker" → "Bill Tracker" rename (display copy)
The v0.42.0 brand refresh renamed client copy but missed a few user-facing
server strings:
- routes/about.js: the /api/about `name` (rendered as the About page title) was
  still 'BillTracker' → 'Bill Tracker'.
- userDbImportService.js: two import-error messages referenced 'BillTracker'.
- Remove the now-unused APP_NAME export from lib/version.ts (0 consumers;
  lib/brand.ts BRAND.name is the canonical source).

Left intentionally as identifiers (not display copy): the updateCheckService
User-Agent 'BillTracker/<version>' and all repository URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:31:14 -05:00
null ecd907e508 refactor: remove orphan StatusBadge.tsx (dead-code audit follow-up)
The top-level client/components/StatusBadge.tsx has no importers — the tracker's
components/tracker/StatusBadge.tsx (used by TrackerRow/MobileTrackerRow) is the
real one. Deferred from the previous cleanup because it had uncommitted brand-
refresh edits; those landed in b714715, and it's still dead, so it goes now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:22:49 -05:00
null 86733d457a refactor: remove verified dead code — orphan file, dead HOC, unused api wrappers
From the repo-wide dead-code audit (High-confidence, re-verified against the live
tree; 0 references each):
- Delete client/components/SummaryCard.tsx — orphan superseded by the tracker's
  components/tracker/SummaryCards.tsx (the one actually imported).
- Remove the unused withErrorBoundary HOC from ErrorBoundary.tsx (the
  ErrorBoundary component itself stays; it's used app-wide).
- Remove 17 dead api.ts client wrappers with no caller (spendingBudgets,
  upcomingBills, billAmortization, billMonthlyState, archiveBill,
  deleteBillTemplate, updateSnowballPlan, aboutAdmin, exportUrl, notifMe,
  saveNotifMe, profileExports, profileImportHistory, createManualTransaction,
  updateTransaction, deleteTransaction, transactionMerchantMatch).

Deliberately left: StatusBadge.tsx orphan (has uncommitted parallel edits — will
remove once that lands) and the WebAuthn feature (a build-or-bin product decision,
not dead-code cleanup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:21:31 -05:00
null 3de91af13d docs: HISTORY + README track the v0.42.0 brand refresh
- HISTORY.md: 'Release Image' section with the Doing my part image
  (img/doingmypart.jpg) under the latest entry; the release image
  the new Brand module + app-primitives UI kit is going out with.
- README.md: 'BillTracker' -> 'Bill Tracker' (with the space) so
  the product name in the docs matches BRAND.name on the live site.

This is commit 9 of 9 in the v0.42.0 brand refresh. Docs-only; no
code change. Depends on 8ceea06 (lib).
2026-07-05 17:19:17 -05:00
null 8ceea06659 chore(lib): trackerUtils + version track the brand refresh
Small follow-ups the rest of the refresh depends on:

- client/lib/trackerUtils.ts: status-tone helper now returns the
  'good' / 'warn' / 'danger' tokens the new TonedCard / ToneDot
  consume, so the per-row badges stay in sync with the new design
  tokens.
- client/lib/version.ts: release-notes shape accepts the optional
  brand_image field that HISTORY.md now uses for the 'Doing my
  part' release image.

This is commit 8 of 9 in the v0.42.0 brand refresh. Depends on
1807456 (admin + data + dialogs).
2026-07-05 17:19:10 -05:00
null 1807456da7 refactor(brand): adopt new primitives in admin + data + dialogs
The remaining per-file 2-4 line adjustments land here: the admin
cards (EmailNotifCard, UsersTable), the bank-sync section, and the
two cross-cutting dialogs (ReleaseNotesDialog, SearchFilterPanel)
adopt the same BrandGlyph / TonedCard / SectionHeading imports as
the rest of the app. Each file is independently buildable on top of
the previous 6 commits.

This is commit 7 of 9 in the v0.42.0 brand refresh. Depends on
0eb024d (pages).
2026-07-05 17:19:03 -05:00
null 0eb024de74 refactor(brand): adopt new primitives in pages
The user-facing pages pick up the new primitives: LoginPage (21
lines) gets the new form tone + the Bill Tracker wordmark on the
auth card; TrackerPage (109 lines) gets the new cashflow timeline
geometry, the new BrandGlyph on the bucket empty state, and the
new TonedCard on the summary stack; the rest are 1-9 line
import/header adjustments. App.tsx is the lazy-loaded import for
the new brand module on the about/privacy paths.

This is commit 6 of 9 in the v0.42.0 brand refresh. Depends on
b714715 (tracker cards).
2026-07-05 17:18:56 -05:00
null b714715ddb refactor(brand): adopt new primitives in tracker cards
CashFlowCard, OverdueCommandCenter, SummaryCards, TrackerBucket, and
the shared StatusBadge move to the new TonedCard / ToneDot / BrandGlyph
primitives. The biggest single change is SummaryCards (81 lines
churn) where the per-card tone tokens now defer to the design system.

This is commit 5 of 9 in the v0.42.0 brand refresh. Depends on
c2b6a7d (UI primitives).
2026-07-05 17:18:49 -05:00
null c2b6a7d1b4 refactor(brand): adopt new primitives in ui/{button,card,badge}
The three shadcn primitives get the small adjustments the new
design-token set calls for — shadow, ring, and border tokens that
align with the index.css overhaul, and tone helpers that defer to
the tokens the primitives export. 20 lines total, no behaviour
change: same variants, same API, same default sizes.

This is commit 4 of 9 in the v0.42.0 brand refresh. Depends on
36834e6 (layout chrome + index.css tokens).
2026-07-05 17:18:38 -05:00
null 36834e6625 refactor(brand): wire brand + new primitives through layout chrome
The layout chrome adopts the new brand module + the just-landed
app-primitives. CSS variables, the favicon, the page title, and the
vite PWA manifest all switch to the new brand assets:

- BrandBlock / NavPill / Sidebar / Layout use BrandMark / BrandWordmark
  / BRAND.name from @/lib/brand.
- ThemeContext exposes the system / dark / light options for the new
  'theme = System' setting (commit 1 of 9 in the brand refresh set;
  the actual settings UI lands in the per-page commit).
- client/index.css: 153 lines of churn — the design-token refresh
  (accent palette, neutrals, surface tokens) consumed by every
  downstream commit.
- index.html: title, favicon, apple-touch-icon, theme-color, manifest
  href all point at client/public/brand/*.
- vite.config.mjs: VitePWA manifest icons + theme/background colors
  follow the new brand.

This is commit 3 of 9 in the v0.42.0 brand refresh. Depends on
4898987 (app-primitives).
2026-07-05 17:18:31 -05:00
null 4898987a18 feat(brand): shared app-primitives UI kit
Adds the cross-cutting UI primitives the rest of the brand refresh
consumes. The 159-line file bundles the small, repeated bits every
page/dialog needs so the per-page commits can stay minimal:

- TonedCard: bordered card with neutral / good / warn / danger / info
  variants (border + bg + text per tone), consumed by the
  overdue/summary/bank-sync cards.
- ToneDot: small status dot for the new badges.
- EmptyState: lucide Inbox + heading + body used for filtered-empty
  and zero-data states.
- SectionHeading / FieldRow: small typographic primitives for the
  Settings / Profile / Admin pages.
- BrandGlyph (re-export of the Brand module) so consumers only need
  to import from '@/components/ui/app-primitives'.

This is commit 2 of 9 in the v0.42.0 brand refresh. Depends on
dfee7f9 (brand module); consumed by the layout/UI/consumer commits.
2026-07-05 17:18:23 -05:00
null dfee7f9401 feat(brand): new brand module + logo assets
Adds the canonical brand definition used by the v0.42.0 brand refresh:

- client/lib/brand.ts — single source of truth for name, tagline,
  repository URL, asset paths, navigation glyphs, and accent palette
  (BRAND_GLYPHS / accent tokens consumed by Brand.tsx + page chrome).
- client/components/brand/Brand.tsx — BrandMark (img with the
  logo asset), BrandWordmark, BrandGlyph, and a small BrandStack
  used by the layout chrome.
- client/public/brand/{logo,pwa-192,pwa-512,apple-touch-icon}.png —
  replaces the inline img-cut references in README/HISTORY with a
  canonical set; PWA manifest + favicon now point here.

This is commit 1 of 9 splitting the working-tree brand refresh into
per-layer commits. Each commit is independently buildable; the order
matches the dependency direction (lib → component → chrome → UI
primitives → page consumers → admin/dialog consumers → docs).
2026-07-05 17:18:04 -05:00
null 1f57b22ff8 feat(settings): configurable reminder send-time (plan Tier 5)
The daily reminder job ran at a hardcoded 6 AM. Make the hour configurable:
- dailyWorker: resolveReminderHour() reads the global 'reminder_hour' setting
  (clamp 0–23, default 6) as the single source for both the cron expression and
  the next-run display; the task is stored so rescheduleDailyWorker() can restart
  it live (defensively wrapped so a bad value can't take the worker down).
- notifications admin GET/PUT expose reminder_hour; PUT clamps, persists, and
  live-reschedules the worker.
- Admin Email Notifications card: a "Reminder send time" select (account-wide).

It's a global setting by design — there's one shared daily job, so a per-user
hour would require an hourly-worker refactor (out of scope) and would otherwise
be a dead setting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:10:25 -05:00
null 0e6f04c7bc feat(settings): sign out of other devices (plan Tier 7)
- New POST /auth/logout-others keeps the current session and invalidates all
  others (invalidateOtherSessions with the current session id), writes a
  logout.others audit entry, and returns the count ended.
- api.logoutOthers(); a "Sign out of other devices" button in the Login History
  dialog, guarded by an AlertDialog confirmation. On success: toast with the
  count and refetch the history so it collapses to just this device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:05:59 -05:00
null 7e5e012434 feat(settings): default landing page (plan Tier 6)
- New per-user 'default_landing_page' setting (tracker default / calendar /
  summary / spending), registered in the whitelist + defaults.
- Implemented as a post-login redirect (keeps /=Tracker so the Home link and
  direct nav are unaffected). Precedence: admin → deep-link they were bounced
  from (state.from) → default landing → /. Resolved by fetching settings in the
  login success path, with a safe / fallback so a fetch failure never blocks login.
- Settings: Select in the Regional section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:03:01 -05:00
null 01030fe7d0 feat(settings): configurable "Due soon" window (plan Tier 4)
- statusService: resolveDueSoonDays (clamp 0–31, default 3) replaces the
  hardcoded 3-day threshold in calculateStatus; threaded via options.dueSoonDays
  through rowOptions in trackerService (×2) and routes/calendar.
- New per-user 'due_soon_days' setting registered in the whitelist + defaults.
- Settings: numeric "Due soon window" row next to Grace period, with min/max +
  clamp guard. Tooltips added to Grace period, Due-soon, and Price-change
  sensitivity explaining each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:00:32 -05:00
null 0f24016bcf feat(settings): first day of week (plan Tier 3)
- New per-user 'week_start' setting (0=Sun default / 1=Mon), registered in
  USER_SETTING_KEYS + USER_SETTING_DEFAULTS.
- CalendarPage reads it and offsets the month grid: leading-blank count
  (getDay()-weekStart+7)%7 and a rotated weekday header. Purely presentational.
- Settings: Sun/Mon Select in the new "Regional" section. Also aligns the
  grace_period_days client default (3→5) with the server seed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:57:35 -05:00
null 0fa80a77ef feat(settings): make Currency + Date-format real (plan Tier 2)
The Currency/Date-format dropdowns were decorative (nothing read them). Wire
them into the formatters, display-only (single active currency, no conversion):
- money.ts: activeCurrency/activeLocale read synchronously from localStorage at
  load (correct first paint), setMoneyFormat() write-through; formatUSD/
  formatUSDWhole/formatCentsUSD honor them. Default USD/en-US is byte-identical,
  so money.test + the probe stay green.
- utils.ts: fmtDate honors an activeDateFormat (MM/DD/YYYY | DD/MM/YYYY |
  YYYY-MM-DD) with setDateFormat() write-through.
- FormatSync (mounted in Layout) reconciles the server settings → localStorage/
  module and re-renders once only on a genuine cross-device divergence.
- Settings selects write through on change, currency list expanded (AUD/CHF/JPY/
  INR), "Regional" section, tooltips clarifying display-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:54:19 -05:00
null e2d4a9f703 feat(settings): theme "System" + reduce-motion (plan Tier 1)
- ThemeContext gains a 'system' theme that follows prefers-color-scheme and
  updates live when the OS flips; exposes resolvedTheme (consumed by Sonner) and
  keeps the <meta theme-color> in sync. Pre-bundle inline script in index.html
  prevents a light/dark flash. Both theme selectors (Settings ThemeCards +
  theme-toggle dropdown) gain the System option.
- New MotionPreferenceContext mounts framer-motion <MotionConfig>; a Reduce-motion
  toggle (localStorage device pref) forces reducedMotion="always" (else "user",
  which still respects the OS).
- Add an accessible SettingHelp tooltip (focusable button + aria-label) wrapped by
  a page-level TooltipProvider; used for the System + Reduce-motion explanations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:50:37 -05:00
null 1d73c8f13e fix(settings): invalidate ['settings'] cache on save so tracker toggles apply
Tier B moved the tracker's display settings onto a React Query cache
(staleTime 5m). SettingsPage saves via its own path and didn't touch that
cache, so toggling a region off (e.g. the Overdue Command Center) could leave
the tracker showing the stale value for up to 5 minutes. Invalidate the
['settings'] query after a successful save so returning to the tracker refetches
and the toggle takes effect immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:06:57 -05:00
null 21651b3f67 feat(tracker): a11y live regions + keyboard-shortcut hint (plan D/C5)
- aria-label on the pencil "Edit bill" buttons (were title-only), matching the
  autopay buttons' title+aria-label pattern.
- role="status" aria-live on the filter result count so filtering announces
  "N of M shown"; a visually-hidden live region announces drag/keyboard reorder
  ("Moved <bill> to position N of M").
- Header "Keyboard shortcuts" dropdown documenting the (previously invisible)
  j/k/arrows/Enter/p/Esc row shortcuts + ⌘K palette.

Deferred as low-value/optional: extra display toggles (C4) and a due-this-week
chip (C5) — avoided header/option clutter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:00:16 -05:00
null 0abe861928 feat(tracker): multi-select bulk pay/skip (plan C3)
- Add a leading selection checkbox to each desktop + mobile row, threaded from
  the page through TrackerBucket via an optional RowSelection prop (renders only
  when the page supplies a toggle handler).
- Page owns the selection Set; a sticky bottom action bar appears when ≥1 bill is
  checked: "N selected · Pay · Skip · Clear".
- Pay selected reuses the bulkPay item shape + Undo from "Pay all due".
- Skip selected fans out saveBillMonthlyState (no bulk endpoint; skip has no money
  effect) with Promise.allSettled, an aggregated "n skipped / m failed" toast, and
  an Undo that un-skips. Selection clears on month change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:56:34 -05:00
null 88874f3f3d feat(tracker): export/print the month + calendar link (plan C1/C2)
- Header "Export" menu: Export CSV (via the existing /api/export?from&to date
  range for the viewed month) and Print (window.print()). Extract the shared
  downloadFile blob helper to lib/download.ts (reused by DownloadMyDataSection).
- Add api.exportRangeUrl(from, to, fmt).
- Tracker-scoped @media print CSS: hide interactive chrome (.tracker-print-hide),
  keep the month title + summary + buckets, avoid splitting a row across pages.
- Header "Calendar" button deep-links to /calendar?year&month; CalendarPage now
  reads those params as its initial month (falls back to today).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:49:09 -05:00
null 313aafcb79 refactor(tracker): move settings/simplefin onto React Query + defer search (plan B)
- B1: replace the two raw useEffect/api fetches (simplefinStatus, settings) with
  useSimplefinStatus() and useSettings() query hooks — cached/deduped like the
  rest of the app. Tracker settings now derive from the ['settings'] cache
  merged under module-level TRACKER_SETTING_DEFAULTS.
- B2: settings save is now useSaveSettings() — a useMutation with optimistic
  cache update + rollback on error, replacing the hand-rolled optimistic/refetch.
- B3: useDeferredValue on the search term feeding filteredRows so typing stays
  snappy on large months (matches BillsPage/SubscriptionsPage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:43:13 -05:00
null 03009e2a83 refactor(client): de-duplicate parseUtc + settingEnabled to lib/utils (plan A2/A3)
- Extract parseUtc (was copy-pasted in 4 files) and settingEnabled (3 copies,
  one named settingsBool) into client/lib/utils.ts as the single source.
- Replace all local copies with the shared import.
- Add tracker_show_safe_to_spend to the tracker's local settings defaults so it
  matches SettingsPage's list (behavior unchanged; aligns the two sources).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:38:12 -05:00
null 3973560e92 refactor(tracker): remove dead code and unreferenced files (plan A1)
- Delete unreachable in-file dialogs: MonthlyStateDialog block (setShowMbs
  never called) and inline PaymentModal in TrackerRow + MobileTrackerRow
  (setEditPayment only ever null; editing works via PaymentLedgerDialog).
- Delete fully-unreferenced files: tracker/EditableCell.tsx,
  ui/confirm-dialog.tsx, ui/separator.tsx (+ orphaned @radix-ui/react-separator).
- Simplify OverdueCommandCenter dead branch (early-returns null when empty).
- Correct stale roadmap/FUTURE docs: tracker keyboard nav is implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:35:13 -05:00
null db1be4b0bd fix(tracker): pin-Due sort uses reconciled status; add filtered-empty state
- Pin-upcoming sort ranked on raw row.status, so a bill paid-by-threshold
  but still carrying a stale 'late'/'missed' status was pinned to the top.
  Rank on rowEffectiveStatus (same source the overdue filter uses) so it
  sorts to the bottom instead.
- Filtering to zero results showed a blank content area (rows.length checks
  the unfiltered list). Add a "No bills match your filters" state with a
  Clear-filters action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:35:03 -05:00
null ef566b12f0 feat(server-ts): paymentValidation → .cts, proving the CJS-module path (Phase 2)
The payment-input gate (well-tested by Phase 1) migrates to TypeScript as
a .cts (CommonJS) module: keeps require/module.exports, imports the branded
Cents type from money.mts, and casts the require(esm) result so toCents/
fromCents keep their branded signatures. This proves the low-churn path for
the ~90 remaining CJS services/routes (no require→import rewrite needed).

Both server-TS patterns now proven end-to-end: .mts (ESM, type-source) +
.cts (CJS). typecheck:server clean; suite 225 + probe 17/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:51:37 -05:00
null 3f9a6cf23e feat(server-ts): utils/dates → TypeScript (.mts); utils/ dir now TS (Phase 2)
dates.mts (ESM, typed) joins money.mts — the two most-required server
leaves are now TypeScript. Added .cts to the server tsconfig for the
CJS-module migration path. typecheck:server clean; suite 225 + probe 17/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:48:44 -05:00
null 200c6373f5 feat(server-ts): migrate the money boundary to TypeScript on Node's native runtime (Phase 2)
Establishes the server-TypeScript foundation and migrates utils/money →
utils/money.mts with branded Cents/Dollars (mirroring the client), so the
cents↔dollars boundary — the origin of the reconciliation bug class — is a
compile error for any typed server caller.

Approach (no build step): Node 25 runs .ts natively (type-stripping).
Migrated modules use the explicit-ESM .mts extension (the project is
"type":"commonjs", which disables .ts ESM auto-detection) and are required
from the CJS callers via Node's require(esm) — verified working. Infra:
tsconfig.server.json (allowJs + checkJs:false → incremental like the
client), npm run typecheck:server, check:server extended to .mts, wired
into ci. 28 require sites repointed to money.mts.

typecheck:server clean; full server suite 225 + e2e probe 17/17 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:44:04 -05:00
null 1d3b7435a0 feat(server): upgrade Express 4.18 → 5.2 (Phase 2)
The maintained line, and it forwards rejected async handlers to the error
middleware natively — closing the Express-4 gap where a throw in an async
route became an unhandled rejection that hung the request (this is why no
throwaway asyncHandler wrapper was needed). Small surface: only three
bare-* routes needed the path-to-regexp-v8 named-wildcard form
(/api/*splat, /legacy/*splat, /{*splat}); no removed APIs in use, all
req.query uses are reads. Suite 225 + probe 17/17 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:25:00 -05:00
null a6bc85b18c perf(ui): useDeferredValue on the Bills + Subscriptions search filters (Phase 2 stack leverage)
Both pages filter the whole list on every keystroke. The input now updates
instantly while the (deferred) filter+render lags behind — React 19's
useDeferredValue, which the app wasn't using anywhere. Typing stays snappy
on large lists; zero behavior change otherwise. typecheck/lint/build clean,
probe 17/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:16:21 -05:00
null 57e7ede670 refactor(ts): close the 4 client any's + document a best-effort catch (Phase 1b)
parseJsonSafe/upload-body → a structured ResponseBody type (typed error
envelope + index sig) instead of any, no cast churn; declineRecommendation
→ Recommendation; snowballPlans endpoint typed so the hook drops its (d: any).
Annotated the Spending settings-load catch as best-effort (Finding #8) —
cosmetic options fall back to defaults, no mount-time nag. Client 0 any's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:13:32 -05:00
null 398caec29d fix(resilience): client fetch timeout + global-handler headersSent guard (Phase 1b)
Client: _fetch had no timeout — a hung request (server accepted the
connection but never responds) spun the UI forever with no error. Now
wrapped in AbortSignal.timeout(120s, generous enough for the slowest
legit import/sync/backup on a LAN) and both timeout and network failure
map to a clean ApiError (REQUEST_TIMEOUT / NETWORK_ERROR) so they surface
as a normal error toast instead of a freeze or raw 'Failed to fetch'.

Server: the global error handler now returns next(err) when res.headersSent,
so an error after a partial response can't throw 'cannot set headers'.

(The async-handler-hang gap is folded into the Express 5 upgrade in Phase
2.2, which forwards async rejections natively — no throwaway wrapper.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:10:00 -05:00
null 896dfd8230 test(security): cover totpService 2FA + document Phase 1 milestone
TOTP: valid token verifies (raw + encrypted-secret path), wrong rejected,
recovery codes single-use (with normalization), challenges single-use +
expiring. Completes the Phase 1 safety net (encryption/auth/validation/
money-creators/2FA). WebAuthn full verify deferred (authenticator harness).
Suite 225 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:05:34 -05:00
null 637ad85512 test(money): cover the bank-sync payment creator + spending budgets (Phase 1)
billMerchantRuleService.syncBillPaymentsFromSimplefin (a money-CREATING
path): a rule-matched unmatched transaction becomes exactly one
provider_sync payment (cents, tx→matched), re-running is idempotent (no
dup — match-status filter + the UNIQUE(transaction_id) index), and a
non-matching tx is left alone. spendingService budgets: dollars↔cents
round-trip + set/update/clear. Suite 221 green.

(spreadsheet createPaymentFromImport dedupe is analogous to the payment
atomicity already covered in Track A; a full XLSX-parse test is deferred
as lower-value/high-setup.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:03:32 -05:00
null 3c2f6ca3b5 test(money): lock in paymentValidation — the gate for every payment (Phase 1)
Pure-function coverage of the validator that normalizes dollars→cents and
rejects bad input before it touches a balance: required-field errors +
field tag, dollars→cents conversion, positive-amount enforcement (rejects
0/negative/non-numeric; documents the intentional lack of an upper bound),
real-calendar-date validation (Feb 30 / month 13), payment_source
whitelist, and the require* option matrix. Suite 215 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:59:57 -05:00
null b3ce334987 test(security): lock in authService — password verify, hashed sessions, rotation (Phase 1)
Pins the auth-core invariants: bcrypt password round-trip, wrong-password
→ bad_password (feeds lockout), unknown user → null (no enumeration);
sessions stored HASHED (sha256), never the raw token; the session
lifecycle (create/lookup/expire/rotate/invalidate/prune); and
rotateSessionId enforcing ownership + killing the old session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:57:53 -05:00
null b95f771d18 test(security): lock in encryptionService — the secret-at-rest crown jewel (Phase 1)
Zero coverage on the AES-256-GCM + HKDF crypto that protects SimpleFIN
bank tokens, TOTP/recovery secrets, SMTP/OIDC creds and login-history
PII. A silent break in decryptSecret or the startup reEncryptWithEnvKey
migration would render every stored secret unrecoverable, invisibly.

Covers: round-trip on both key paths (env e2: / db v2:), unicode/empty/
large payloads, GCM tamper rejection, legacy (pre-v0.78 SHA-256) decrypt,
the env-key-required error, and the migration (migrate/skip/idempotent/
corrupt-tolerant). Suite 200 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:55:51 -05:00
null 03d15bd061 docs(reference): document v0.41.0 Track A/B/C/D in engineering manual
Eight in-flight commits targeting v0.41.0 (Track A money integrity,
Track B reconciliation tests, Track C API response typing across
auth/snowball/subscriptions/spending/bank-ledger, Track D 500
handler code:'INTERNAL_ERROR') are not yet pushed. Documenting them
in the manual so it stays current with the code that will land next.

Section updates:
- Header: keep package version 0.40.0, note v0.41.0 is the in-flight
  target; 'Last Updated' 2026-07-05.
- Notable changes: 4 new bullets summarizing Track A/B/C/D.
- API client (7): list Track C response typing and Track D error shape
  with cross-references to the sections where each is detailed.
- Domain types (7): expand to include User, SnowballProjection,
  SnowballSettings, Subscription*, Spending*, BankLedger*, CategoryGroup,
  CopyBudgetsResponse. Note single-consumer non-money casts left in
  place as a deliberate Track C stop.
- Auth state (7): User moved to @/types, re-exported from useAuth.
- client/lib/ (7): add paymentActions.ts (createPaymentOrConfirm).
- Response conventions (5): add 'INTERNAL_ERROR' for 500s; note
  409 DUPLICATE_SUSPECTED on POST /payments.
- Payments (5.5): Track A notes — transactional balance mutations,
  409 allow_duplicate flag, 409 DUPLICATE_SUSPECTED response shape,
  new tests/paymentsRoute.test.js and tests/reconciliation.test.js.
- Verification checklist (11): list 8 unpushed Track commits with
  one-line summaries and 19-file diff stat (813+/415-).
2026-07-05 12:32:02 -05:00
null 1df4b1bf87 refactor(ts): move User to @/types + type auth endpoints; add code to 500 (Track C/D)
Track C (auth): User lived in @/hooks/useAuth; moved it to @/types
(re-exported from useAuth for the 7 importers) and typed me/login/
totpChallenge/hasUsers, dropping those Admin/Login casts. Left the
single-consumer non-money casts (adminUsers, version/about/privacy/
health docs) and the optimistic-UI rewrite as deliberate low-value/
high-churn stops.

Track D: global 500 handler now emits code:'INTERNAL_ERROR' so an
unexpected error carries the same {error,code} field as structured 4xx.

typecheck/lint(0 err)/build/server-193 all green; probe 17/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:26:11 -05:00