Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .agents/notes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[0] `tsx` CLI can fail in restricted macOS sandboxes with `listen EPERM` on `.../tsx-*/.pipe`; using `node --import tsx ...` avoids the IPC pipe path.
[0] `rrule` should be imported as a default object in runtime-executed TS/ESM paths here (`import rrule from 'rrule'`), then accessed as `rrule.rrulestr(...)`.
[0] `pnpm dev` port-binding failures like `listen EPERM` on `0.0.0.0:5173`/`:8787` can come from sandbox networking restrictions, not app code regressions.
[1] `pnpm dev`/Playwright `config.webServer` port-binding failures like `listen EPERM` on `0.0.0.0:5173`, `127.0.0.1:5173`, or `:8787` can come from sandbox networking restrictions, not app code regressions.
[0] For nested apps, Vite reads `.env` from the app directory unless `envDir` is set; use workspace-root `envDir` when shared root env vars are expected.
[0] Drizzle SQLite schema callbacks should return an array in `sqliteTable` third argument (not an object map) to avoid deprecation warnings.
[0] Long-running CLI service orchestration should live outside the data-command `run(...)` wrapper so help-only calls keep migration laziness and process signals can be managed explicitly.
Expand Down Expand Up @@ -50,4 +50,6 @@
[0] If the user runs dev stack manually, keep VS Code debug setup minimal: prelaunch task should only open Chrome using `.env` `PWA_PORT`, then `pwa-chrome` attach to `:9222`; avoid auto-managing Vite tasks in debugger config.
[0] When editing hooks with repeated `invalidateQueries` blocks, apply_patch can match the wrong mutation; re-open the file and verify the intended function was changed (here: update mutation, not create mutation).
[0] For large PWA screens mixing query + dialog + form state, extract dialog-specific state machines into dedicated feature hooks (`useCategoryEditDialog`, `useAutoMatchRulesDialog`) and keep the screen as a composition layer.
[0] For route-driven PWA detail screens, keep hooks single-responsibility: separate route parsing, data fetching, and shell chrome into focused units so the page reads top-down (`parse inputs -> fetch data -> configure shell -> render`) instead of hiding all concerns in one page hook.
[0] For category list readability in PWA, grouping rows into explicit `Income` and `Expense` sections plus using each category's configured color as icon/row accent makes scan/edit workflows faster without changing data model.
[0] `packages/domain/src/services/monzo.service.ts` currently threads `overrideExisting` through the API/UI/service boundary but does not branch on it inside `syncInternal`; once an imported `monzo:<transactionId>` row exists, sync updates it in place regardless of the flag.
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Failure:
- `tithe --json monzo status`
- PWA Home Monzo card exposes connect/status controls (`Connect`) and shows last sync/error state.
- PWA `Connect` opens Monzo OAuth in a separate browser window/tab (popup opened synchronously on click to reduce popup blocking).
- PWA Expenses list merchant avatar fallback is `logo -> emoji -> initials` for Monzo-imported expenses when display metadata is available.
- PWA Transactions list merchant avatar fallback is `logo -> emoji -> initials` for Monzo-imported expenses when display metadata is available.

### CLI invocation notes

Expand Down Expand Up @@ -156,16 +156,16 @@ Failure:
- Monthly Ledger Monzo sync success/error feedback is scoped to the selected month and clears when navigating to a different month.
- PWA Home Monthly Ledger widget also surfaces v2 summary metrics (`Cash In`, `Cash Out`, `Net Flow`, `True Spend`, `Reimbursement Outstanding`) with `Gross/Net` and `Exclude internal transfers` toggles.
- PWA Home Monthly Ledger category breakdown rows use category icon/color accents (matching Categories list styling) when category metadata is available.
- PWA Home Monthly Ledger `Expenses` section rows are tappable and drill into an Expenses detail route scoped to the selected category and month (`/expenses/category/:categoryId?month=YYYY-MM`); `Income`/`Transfers` rows remain non-interactive.
- PWA Home Monthly Ledger `Income`, `Expenses`, and `Transfers` section rows are tappable and drill into a Transactions detail route scoped to the selected category and month (`/transactions/category/:categoryId?month=YYYY-MM`); transfer drill-ins may also include `direction=in|out`.
- PWA Home `Add Transaction` is a single manual entry flow for `income|expense|transfer`; transfer entries require direction and support transfer subtype (`internal|external`) via semantic `kind`, and reimbursable expense categories can capture `Track reimbursement` + `My share`.
- PWA Home pending commitments support a quick `Mark paid` action that creates a linked actual transaction (`source='commitment'`) and updates the ledger.
- PWA Expenses page now surfaces semantic/reimbursement chips (`Internal transfer`, `External transfer`, `Pending`, `Reimbursable`, `Partial`, `Settled`, `Written off`) and basic reimbursement actions (`Link repayment`, `Mark written off`, `Reopen`).
- PWA Expenses includes a ledger-origin drill-in detail flow (category + month scoped list) while keeping the bottom tab bar active on the `Expenses` tab and exposing top-bar back navigation.
- PWA Transactions page now surfaces semantic/reimbursement chips (`Internal transfer`, `External transfer`, `Pending`, `Reimbursable`, `Partial`, `Settled`, `Written off`) and basic reimbursement actions (`Link repayment`, `Mark written off`, `Reopen`).
- PWA Transactions includes a ledger-origin drill-in detail flow (category + month scoped list) that shows the category name in the top bar, keeps the bottom tab bar active on `Home`, and exposes top-bar back navigation to the source month.
- PWA Categories page uses a floating `+` action to open `Add Category`, category add/edit dialogs can capture expense-category reimbursement settings/defaults, reimbursement auto-match rule management runs in a dialog, and the category list is grouped into `Income`/`Expense` sections with each row accented by category color (no per-row kind subtitle).
- PWA Categories edit saves update the cached `categories` query immediately so the list reflects changes without a manual page refresh.
- PWA Categories edit save reads the latest in-dialog draft state (including icon changes) to avoid stale writes when saving immediately after selecting a value.
- PWA Categories edit mutation marks `categories` as stale without immediate refetch after cache write to avoid stale-response overwrites of freshly edited rows.
- PWA short-form list-page dialogs (for example Expenses/Categories add/edit flows) should follow the Expenses pattern: MUI `Dialog` with `fullWidth` and no mobile `fullScreen`.
- PWA short-form list-page dialogs (for example Transactions/Categories add/edit flows) should follow the Transactions pattern: MUI `Dialog` with `fullWidth` and no mobile `fullScreen`.
- Ledger v2 development rollout requires a fresh local DB reset (no backfill); reset `DB_PATH` (default `~/.tithe/tithe.db`) before running v2 migrations/commands.
- PWA large pages should use thin route entrypoints in `apps/pwa/src/pages` and feature-scoped UI/data modules under `apps/pwa/src/features/<feature>`; shared domain-neutral helpers belong in `apps/pwa/src/lib`.
- PWA Home dashboard widgets (ledger, Monzo, commitments) should manage loading/error states independently to avoid page-wide blocking when one widget fails.
Expand All @@ -174,6 +174,7 @@ Failure:

- Treat TanStack Query as the source of truth for server state; avoid copying query results into component state except for transient UI drafts.
- Keep route/screen components as composition layers and extract workflow-specific state into focused hooks (for example edit dialog state vs auto-match rules state).
- Keep hooks single-responsibility: avoid page hooks that mix route parsing, data fetching, shell chrome, and copy derivation. When a screen grows, prefer small focused hooks such as `use...Route`, `use...Data`, and `use...Shell`, with the page component reading top-down as parse inputs -> fetch data -> configure shell -> render.
- Prefer one focused state object per active dialog/workflow instead of parallel id-indexed maps when only one entity can be edited at a time.
- Keep query cache writes/invalidation policy inside mutation hooks so UI components consume a stable API (`mutateAsync`, `isPending`) without cache plumbing details.
- For optimistic cache writes, avoid immediate active refetches that can overwrite fresh UI with stale responses; mark stale and refetch intentionally.
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ Current status in this implementation:
- PWA Home Monthly Ledger widget includes `Sync month`, which syncs the selected month window and overwrites existing imported Monzo expenses for that month.
- PWA Home Monthly Ledger widget also surfaces Ledger v2 summary metrics (`Cash In`, `Cash Out`, `Net Flow`, `True Spend`, `Reimbursement Outstanding`) with `Gross/Net` and `Exclude internal transfers` toggles.
- PWA Home Monthly Ledger category breakdown rows use category icon/color accents (matching Categories list styling) when category metadata is available.
- PWA Home Monthly Ledger `Expenses` rows drill into a month-scoped category detail screen under the Expenses tab (`/expenses/category/:categoryId?month=YYYY-MM`); `Income` and `Transfers` rows are not tappable.
- PWA Home Monthly Ledger `Income`, `Expenses`, and `Transfers` rows drill into a month-scoped category detail screen under `Home` (`/transactions/category/:categoryId?month=YYYY-MM`); transfer drill-ins also preserve `direction=in|out`.
- Monthly Ledger sync feedback is month-scoped and clears when you navigate to another month.
- PWA Home includes a single `Add Transaction` flow for manual `income`, `expense`, and `transfer` entries (transfer entries require direction and support semantic subtype `internal` / `external`).
- Reimbursable expense categories in Home manual entry can capture `Track reimbursement` plus `My share`.
Expand All @@ -375,7 +375,8 @@ Current status in this implementation:
- PWA Categories edit marks category data stale without immediate refetch after cache update to prevent stale server responses from overwriting freshly edited rows.
- PWA Home pending commitments support `Mark paid`, which creates a linked actual transaction (`source=commitment`) and updates the monthly ledger.
- Home dashboard cards load independently: a ledger/Monzo/commitments fetch error is shown in that card without blocking the entire Home screen.
- Expenses tab includes a dedicated ledger-origin drill-in detail page with top-bar back navigation while keeping bottom-tab selection on `Expenses`.
- Transactions tab lists expenses, income, and transfers together.
- Ledger-origin category drill-ins show the category name in the top bar, keep bottom-tab selection on `Home`, and preserve the source month on back navigation.
- `Connect` opens the Monzo OAuth flow in a separate window/tab (opened immediately on click to avoid popup blocking after async API calls).
- Initial import window is last 90 days; subsequent sync uses cursor overlap.
- Import policy includes non-zero Monzo debits + credits (`amount != 0`), including pending rows (`postedAt=null` until settlement).
Expand All @@ -384,11 +385,11 @@ Current status in this implementation:
- Monzo sync classifies pot transfers as `transfer_internal`, non-pot debits as `expense`, and non-pot credits as `income`.
- `tithe --json monzo sync --override` (or PWA Monthly Ledger `Sync month`) overwrites existing `monzo` rows in place using latest Monzo-derived category/amount/date/kind/merchant fields while preserving local notes and local reimbursement metadata.
- Reports (`trends`, `category-breakdown`, `monthly-ledger`) keep totals settled-only by excluding pending Monzo rows.
- Expense API responses include optional Monzo merchant display metadata (`merchantLogoUrl`, `merchantEmoji`) used by the PWA expenses list avatar.
- Expense API responses include optional Monzo merchant display metadata (`merchantLogoUrl`, `merchantEmoji`) used by the PWA transactions list avatar.
- Expense API responses include semantic `kind` plus reimbursement fields/derived reimbursement totals for Ledger v2 workflows.
- Expense API responses also include `transferDirection` (`in|out|null`); transfer semantic rows require it, income/expense rows return `null`.
- PWA expenses list merchant avatars use `logo -> emoji -> initials` fallback for imported Monzo merchants.
- PWA Expenses rows show a `Pending` badge for Monzo rows where `postedAt` is still null.
- PWA transactions list merchant avatars use `logo -> emoji -> initials` fallback for imported Monzo merchants.
- PWA Transactions rows show a `Pending` badge for Monzo rows where `postedAt` is still null.
- Monzo sync best-effort resolves pot-transfer descriptions that are raw Monzo pot IDs (`pot_...`) into display labels like `Pot: Savings` for new imports; if pot lookup fails or the pot is missing, the raw description is kept.
- Merchant logo/emoji metadata is stored for new Monzo imports only (no historical backfill for older imported rows).
- Monzo category mappings are flow-aware (`in|out`) and auto-create `Monzo: <Category>` categories with `expense`/`income` kind inferred from flow. Pot transfers use a dedicated transfer category.
Expand Down
79 changes: 61 additions & 18 deletions apps/pwa/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,61 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';

import { MobileShell } from './components/MobileShell.js';
import { type ShellChromeConfig, ShellChromeContext } from './lib/shell-chrome.js';
import { CategoriesPage } from './pages/CategoriesPage.js';
import { CommitmentsPage } from './pages/CommitmentsPage.js';
import { ExpenseCategoryDetailPage } from './pages/ExpenseCategoryDetailPage.js';
import { ExpensesPage } from './pages/ExpensesPage.js';
import { HomePage } from './pages/HomePage.js';
import { InsightsPage } from './pages/InsightsPage.js';

const getTitle = (pathname: string): string => {
interface AppLocationState {
inAppBackTarget?: string;
categoryName?: string;
}

const getTitle = (pathname: string, state: AppLocationState | null): string => {
if (pathname === '/') return 'Tithe';
if (pathname.startsWith('/expenses')) return 'Expenses';
if (pathname.startsWith('/transactions/category/')) {
return state?.categoryName?.trim() || 'Transactions';
}
if (pathname.startsWith('/transactions')) return 'Transactions';
if (pathname.startsWith('/commitments')) return 'Commitments';
if (pathname.startsWith('/categories')) return 'Categories';
if (pathname.startsWith('/insights')) return 'Insights';
return 'Tithe';
};

const mapLegacyExpensesPathToTransactions = (pathname: string): string =>
pathname.replace(/^\/expenses\b/, '/transactions');

// Preserve existing deep links after the public Transactions route rename.
const LegacyExpensesRouteRedirect = () => {
const location = useLocation();

return (
<Navigate
replace
to={{
pathname: mapLegacyExpensesPathToTransactions(location.pathname),
search: location.search,
hash: location.hash,
}}
state={location.state}
/>
);
};

export const App = () => {
const location = useLocation();
const navigate = useNavigate();
const title = getTitle(location.pathname);
const showBackButton = location.pathname.startsWith('/expenses/category/');
const canNavigateBackInApp =
(location.state as { inAppBackTarget?: string } | null)?.inAppBackTarget === 'home';
const locationState = (location.state as AppLocationState | null) ?? null;
const [shellChrome, setShellChrome] = useState<ShellChromeConfig | null>(null);
const title = shellChrome?.title ?? getTitle(location.pathname, locationState);
const showBackButton =
shellChrome?.showBackButton ?? location.pathname.startsWith('/transactions/category/');
const canNavigateBackInApp = locationState?.inAppBackTarget === 'home';
const backToHomeHref = useMemo(() => {
const search = new URLSearchParams(location.search);
const month = search.get('month');
Expand All @@ -43,16 +74,28 @@ export const App = () => {
};

return (
<MobileShell title={title} showBackButton={showBackButton} onBack={handleBack}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/expenses" element={<ExpensesPage />} />
<Route path="/expenses/category/:categoryId" element={<ExpenseCategoryDetailPage />} />
<Route path="/commitments" element={<CommitmentsPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/insights" element={<InsightsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</MobileShell>
<ShellChromeContext.Provider value={setShellChrome}>
<MobileShell
title={title}
activeTab={shellChrome?.activeTab}
showBackButton={showBackButton}
onBack={shellChrome?.onBack ?? handleBack}
>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/transactions" element={<ExpensesPage />} />
<Route
path="/transactions/category/:categoryId"
element={<ExpenseCategoryDetailPage />}
/>
<Route path="/expenses" element={<LegacyExpensesRouteRedirect />} />
<Route path="/expenses/*" element={<LegacyExpensesRouteRedirect />} />
<Route path="/commitments" element={<CommitmentsPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/insights" element={<InsightsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</MobileShell>
</ShellChromeContext.Provider>
);
};
9 changes: 6 additions & 3 deletions apps/pwa/src/components/MobileShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,31 @@ import { useLocation, useNavigate } from 'react-router-dom';

interface MobileShellProps {
title: string;
activeTab?: string;
showBackButton?: boolean;
onBack?: () => void;
children: React.ReactNode;
}

const tabs = [
{ label: 'Home', path: '/', icon: <HomeOutlinedIcon /> },
{ label: 'Expenses', path: '/expenses', icon: <ReceiptLongOutlinedIcon /> },
{ label: 'Transactions', path: '/transactions', icon: <ReceiptLongOutlinedIcon /> },
{ label: 'Commitments', path: '/commitments', icon: <RepeatOutlinedIcon /> },
{ label: 'Categories', path: '/categories', icon: <ListAltOutlinedIcon /> },
{ label: 'Insights', path: '/insights', icon: <InsightsOutlinedIcon /> },
];

export const MobileShell = ({
title,
activeTab,
showBackButton = false,
onBack,
children,
}: MobileShellProps) => {
const location = useLocation();
const navigate = useNavigate();

const active = useMemo(
const defaultActive = useMemo(
() =>
tabs.find((item) => {
if (item.path === '/') {
Expand All @@ -51,6 +53,7 @@ export const MobileShell = ({
})?.path ?? '/',
[location.pathname],
);
const selectedTab = activeTab ?? defaultActive;

return (
<Box sx={{ minHeight: '100dvh', bgcolor: 'background.default' }}>
Expand Down Expand Up @@ -104,7 +107,7 @@ export const MobileShell = ({
overflow: 'hidden',
}}
>
<BottomNavigation value={active} onChange={(_e, value) => navigate(value)} showLabels>
<BottomNavigation value={selectedTab} onChange={(_e, value) => navigate(value)} showLabels>
{tabs.map((item) => (
<BottomNavigationAction
key={item.path}
Expand Down
2 changes: 1 addition & 1 deletion apps/pwa/src/features/expenses/components/ExpensesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export const ExpensesList = ({
}

if (isError) {
return <Alert severity="error">Unable to load expenses.</Alert>;
return <Alert severity="error">Unable to load transactions.</Alert>;
}

if (expenses.length === 0) {
Expand Down
Loading