Skip to content

Split ExpenseTable.jsx into typed sub-components - #47

Merged
derekl-beep merged 2 commits into
mainfrom
claude/expense-table-refactor
Jul 10, 2026
Merged

Split ExpenseTable.jsx into typed sub-components#47
derekl-beep merged 2 commits into
mainfrom
claude/expense-table-refactor

Conversation

@derekl-beep

Copy link
Copy Markdown
Owner

Summary

Pure refactor, no feature or behavior change intended: splits the 1,056-line ExpenseTable.jsx — which had grown to handle the expense list, income list, category breakdown, recurring charges, the edit dialog, and the budget dialog all in one file — into focused components, and adopts TypeScript for them.

  • TypeScript tooling (first commit): typescript + typescript-eslint, a project-references tsconfig setup (allowJs: true so untouched .jsx files keep working without conversion), tsc -b wired into npm run build so type errors actually fail the build. No source files converted in this commit — verified against the existing all-.jsx codebase first (lint clean, build passes, and a throwaway type error was confirmed to fail the build before being removed).
  • The split (second commit): BudgetDialog.tsx, ExpenseEditDialog.tsx, ExpenseList.tsx, IncomeList.tsx, CategoryBreakdown.tsx, RecurringSection.tsx, with ExpenseTable.tsx becoming a thin orchestrator. Converted to .tsx because this is exactly the moment new component boundaries are being drawn — typing them now catches a missed/wrong prop at the call site instead of failing silently at runtime. Everything else in the app (App.jsx, Chat.jsx, Login.jsx, the shadcn components/ui/*) stays .jsx — this is incremental adoption, not a repo-wide migration.

Review

Ran an independent three-angle review (behavior-preservation against the original file via git show, TypeScript type-safety, cross-file prop wiring) before committing. Cross-file wiring came back clean. The other two angles caught real issues, all fixed:

Three behavior regressions (none caught by the 68-spec e2e baseline, since none of them exercise these specific interaction sequences):

  • Re-opening the same expense after clicking Cancel could show the previous unsaved edit instead of its actual current values — the resync check compared object references instead of tracking the dialog's open/closed transition, so re-opening the same row (same object reference, since nothing changed on Cancel) skipped the resync.
  • showAllCategories (breakdown "view more") and showRecurring had become local state inside components that now unmount on every Expenses/Income tab switch, silently collapsing a section the user had just expanded. Moved both back to the parent.

Two type-safety gaps:

  • Expense.logged_by/Income.logged_by were typed as non-nullable string, but get_expenses()/get_income() LEFT JOIN against a nullable user_id column — existing runtime code already defensively guards against null (e.logged_by && ...), which was the tell that the type was the one lying, not the code.
  • A save-diff loop used values[key] as never as a blanket escape hatch (assignable to anything, at any key) to work around a known TS limitation with union-typed loop variables. Replaced with an explicit unrolled diff — five fields, needs no cast at all.

Added e2e coverage for the two regressions verifiable with existing seed data (reopen-after-cancel, and tab-switch persistence for the breakdown section). The recurring-section fix uses the identical lift-to-parent pattern but has no dedicated regression test — the seeded data has no expense repeating ≥3 times, which recurring detection requires, and restructuring seed data for that felt like disproportionate scope for a pure refactor.

Test plan

  • uv run pytest tests/ — 150 passed (backend untouched, sanity check only)
  • npm run lint — clean
  • npm run build (now tsc -b && vite build) — clean, zero any anywhere in the new files
  • npm run test:e2e — 72 passed (68 original + 4 new, both viewports), zero changes to any pre-existing test file required by the split itself — the only test-file edits were the two new regression tests

Roadmap / future work

Chat.jsx (778 lines) is the next-largest file and a reasonable candidate for the same treatment if it starts costing the same way ExpenseTable.jsx did. No urgency noted yet.


Generated by Claude Code

claude added 2 commits July 10, 2026 03:06
Adds typescript + typescript-eslint, a project-references tsconfig
setup (app + node configs, allowJs so untouched .jsx files keep
working without conversion), and wires tsc -b into npm run build so
type errors actually fail the build instead of being silently
ignored. eslint.config.js now lints .ts/.tsx too.

No source files converted yet - this is pure tooling setup, verified
against the existing all-.jsx codebase (lint clean, build passes, and
a throwaway type error was confirmed to fail the build before being
removed). Adoption is incremental: components convert to .tsx as
they're touched, starting with the ExpenseTable.jsx split.
The single 1056-line ExpenseTable.jsx handled the expense list, income
list, category breakdown, recurring charges, the edit dialog, and the
budget dialog all in one file - every review pass this session flagged
its size as a real cost. Splits it into BudgetDialog, ExpenseEditDialog,
ExpenseList, IncomeList, CategoryBreakdown, and RecurringSection, with
ExpenseTable itself becoming a thin orchestrator. Converted to .tsx
(TypeScript tooling added in the prior commit) since this is exactly
the moment new component boundaries are being drawn - typing them now
catches missed/wrong props at the call site instead of failing silently
at runtime. Everything else in the app stays .jsx; adoption is
incremental, not a repo-wide migration.

This was treated as a pure refactor: behavior, DOM output, and styling
are unchanged. Verified against the full existing e2e suite (68 specs)
before and after with zero test file changes required.

An independent three-angle review (behavior-preservation against the
original file, TypeScript type-safety, cross-file prop wiring) caught
three real regressions the e2e suite didn't cover, all now fixed:

- Re-opening the same expense after Cancel could show the previous
  unsaved edit instead of current values, because the resync check
  used object-reference equality instead of open/closed transition.
- showAllCategories (breakdown "view more") and showRecurring became
  local state in components that unmount on every Expenses/Income tab
  switch, silently collapsing a section the user had just expanded.
  Both moved back to the parent.

Also fixed two type-safety gaps the same review found: Expense/Income's
logged_by was typed as non-nullable despite the backend's LEFT JOIN
being able to send null (existing runtime code already defensively
guarded this - the type was the one that was wrong), and a save-diff
loop used `as never` as a blanket escape hatch, replaced with an
explicit unrolled diff that needs no cast at all.

Added e2e coverage for the two regressions verifiable with existing
seed data (the reopen-after-cancel case and the tab-switch persistence
case for the breakdown section). The recurring-section fix uses the
identical code pattern but has no dedicated regression test - the
seeded data has no expense repeating >=3 times, which recurring
detection requires, and restructuring seed data for that felt like
disproportionate scope for a pure refactor.

Verified: 150 backend tests, 72 e2e tests, lint, and build (now
including tsc type-checking) all green.
@derekl-beep
derekl-beep merged commit 12604a8 into main Jul 10, 2026
3 checks passed
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