Skip to content
Merged
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
1 change: 1 addition & 0 deletions .agents/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@
[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 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.
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,13 @@ Failure:
- PWA Home Monthly Ledger widget includes a month-scoped Monzo `Sync month` action that syncs the selected month window and overwrites existing imported Monzo expenses for that month.
- 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 `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 Categories page uses a floating `+` action to open `Add Category`, and category add/edit dialogs can capture expense-category reimbursement settings/defaults while reimbursement auto-match rule management also runs in a dialog.
- 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 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.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,15 +364,18 @@ Current status in this implementation:
- PWA Home screen embeds a monthly cashflow ledger with month navigation, category breakdown lists, and both `Operating Surplus` and `Net Cash Movement` totals.
- 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.
- 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`.
- PWA Categories page uses a floating `+` button for `Add Category`, and opens dialogs for category add/edit (including expense-category reimbursement settings/defaults) and reimbursement auto-match rule management (link expense categories to income/transfer categories).
- PWA Categories page uses a floating `+` button for `Add Category`, opens dialogs for category add/edit (including expense-category reimbursement settings/defaults) and reimbursement auto-match rule management (link expense categories to income/transfer categories), and displays the category list in `Income`/`Expense` sections with category-color row accents (no per-row kind subtitle).
- PWA Categories edit saves update the cached category list immediately, so changes appear without refreshing the page.
- PWA Categories edit save uses the latest dialog draft values (including icon selection) to prevent stale writes on fast save clicks.
- 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`.
- `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 Down
42 changes: 33 additions & 9 deletions apps/pwa/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,53 @@
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { useMemo } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';

import { MobileShell } from './components/MobileShell.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 titleByPath: Record<string, string> = {
'/': 'Tithe',
'/expenses': 'Expenses',
'/commitments': 'Commitments',
'/categories': 'Categories',
'/insights': 'Insights',
const getTitle = (pathname: string): string => {
if (pathname === '/') return 'Tithe';
if (pathname.startsWith('/expenses')) return 'Expenses';
if (pathname.startsWith('/commitments')) return 'Commitments';
if (pathname.startsWith('/categories')) return 'Categories';
if (pathname.startsWith('/insights')) return 'Insights';
return 'Tithe';
};

export const App = () => {
const location = useLocation();
const title = titleByPath[location.pathname] ?? 'Tithe';
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 backToHomeHref = useMemo(() => {
const search = new URLSearchParams(location.search);
const month = search.get('month');
if (!month) {
return '/';
}
return `/?month=${encodeURIComponent(month)}`;
}, [location.search]);

const handleBack = () => {
if (canNavigateBackInApp) {
navigate(-1);
return;
}
navigate(backToHomeHref, { replace: true });
};

return (
<MobileShell title={title}>
<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 />} />
Expand Down
9 changes: 8 additions & 1 deletion apps/pwa/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,14 @@ export const api = {
}),
},
expenses: {
list: () => request<Expense[]>('/expenses?limit=100'),
list: (params?: { from?: string; to?: string; categoryId?: string; limit?: number }) => {
const search = new URLSearchParams();
search.set('limit', String(params?.limit ?? 100));
if (params?.from) search.set('from', params.from);
if (params?.to) search.set('to', params.to);
if (params?.categoryId) search.set('categoryId', params.categoryId);
return request<Expense[]>(`/expenses?${search.toString()}`);
},
create: (body: {
occurredAt: string;
postedAt?: string | null;
Expand Down
24 changes: 22 additions & 2 deletions apps/pwa/src/components/MobileShell.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
import InsightsOutlinedIcon from '@mui/icons-material/InsightsOutlined';
import ListAltOutlinedIcon from '@mui/icons-material/ListAltOutlined';
Expand All @@ -8,6 +9,7 @@ import {
BottomNavigation,
BottomNavigationAction,
Box,
IconButton,
Paper,
Toolbar,
Typography,
Expand All @@ -17,6 +19,8 @@ import { useLocation, useNavigate } from 'react-router-dom';

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

Expand All @@ -28,12 +32,23 @@ const tabs = [
{ label: 'Insights', path: '/insights', icon: <InsightsOutlinedIcon /> },
];

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

const active = useMemo(
() => tabs.find((item) => location.pathname === item.path)?.path ?? '/',
() =>
tabs.find((item) => {
if (item.path === '/') {
return location.pathname === '/';
}
return location.pathname === item.path || location.pathname.startsWith(`${item.path}/`);
})?.path ?? '/',
[location.pathname],
);

Expand All @@ -51,6 +66,11 @@ export const MobileShell = ({ title, children }: MobileShellProps) => {
}}
>
<Toolbar sx={{ minHeight: 56 }}>
{showBackButton ? (
<IconButton edge="start" aria-label="Back" onClick={onBack} sx={{ mr: 1 }}>
<ArrowBackIosNewIcon fontSize="small" />
</IconButton>
) : null}
<Typography component="h1" variant="h6" sx={{ fontWeight: 700 }}>
{title}
</Typography>
Expand Down
190 changes: 115 additions & 75 deletions apps/pwa/src/features/categories/components/CategoriesListCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,79 +31,119 @@ export const CategoriesListCard = ({
rulesByExpenseCategoryId,
onOpenAutoMatchRules,
onEditCategory,
}: CategoriesListCardProps) => (
<Card>
<CardContent>
<Typography variant="subtitle1" fontWeight={700} sx={{ mb: 1 }}>
Category List
</Typography>
{categories.length === 0 ? (
<Typography color="text.secondary">No categories yet.</Typography>
) : (
<List disablePadding>
{categories.map((category) => {
const expenseRules = rulesByExpenseCategoryId.get(category.id) ?? [];
const isPlaceholder = isMonzoPlaceholderCategoryName(category.name);
const CategoryRowIcon =
CATEGORY_ICON_COMPONENTS[category.icon || 'category'] ?? CategoryIcon;
}: CategoriesListCardProps) => {
const nonExpenseCategories = categories.filter((category) => category.kind !== 'expense');
const expenseCategories = categories.filter((category) => category.kind === 'expense');

return (
<Box key={category.id} sx={{ mb: 1.5 }}>
<ListItem alignItems="flex-start" disableGutters sx={{ pr: 6 }}>
<ListItemText
primary={
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<CategoryRowIcon fontSize="small" color="action" />
<Typography variant="body1">
{normalizeCategoryLabel(category.name)}
</Typography>
{isPlaceholder ? (
<Chip size="small" color="warning" label="Monzo placeholder" />
) : null}
{category.kind === 'expense' ? (
<Chip
size="small"
label={`reimbursement: ${category.reimbursementMode ?? 'none'}`}
variant="outlined"
/>
) : null}
{category.kind === 'expense' ? (
<Chip
size="small"
label={`auto-match: ${expenseRules.length} rule${expenseRules.length === 1 ? '' : 's'}`}
variant="outlined"
/>
) : null}
</Stack>
}
secondary={category.kind}
/>
<ListItemSecondaryAction>
<Stack direction="row" spacing={0.5}>
{category.kind === 'expense' ? (
<IconButton
edge="end"
aria-label="auto-match rules"
onClick={() => onOpenAutoMatchRules(category.id)}
>
<LinkIcon fontSize="small" />
</IconButton>
) : null}
<IconButton
edge="end"
aria-label="edit category"
onClick={() => onEditCategory(category)}
>
<EditIcon fontSize="small" />
</IconButton>
</Stack>
</ListItemSecondaryAction>
</ListItem>
</Box>
);
})}
</List>
)}
</CardContent>
</Card>
);
const renderCategoryRows = (items: Category[]) => (
<List disablePadding>
{items.map((category) => {
const expenseRules = rulesByExpenseCategoryId.get(category.id) ?? [];
const isPlaceholder = isMonzoPlaceholderCategoryName(category.name);
const CategoryRowIcon =
CATEGORY_ICON_COMPONENTS[category.icon || 'category'] ?? CategoryIcon;

return (
<Box key={category.id} sx={{ mb: 1.25 }}>
<ListItem
alignItems="flex-start"
disableGutters
sx={{
pr: 6,
pl: 1,
borderLeft: '3px solid',
borderColor: category.color,
borderRadius: 1,
}}
>
<ListItemText
primary={
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<CategoryRowIcon fontSize="small" sx={{ color: category.color }} />
<Typography variant="body1">{normalizeCategoryLabel(category.name)}</Typography>
{isPlaceholder ? (
<Chip size="small" color="warning" label="Monzo placeholder" />
) : null}
{category.kind === 'expense' ? (
<Chip
size="small"
label={`reimbursement: ${category.reimbursementMode ?? 'none'}`}
variant="outlined"
/>
) : null}
{category.kind === 'expense' ? (
<Chip
size="small"
label={`auto-match: ${expenseRules.length} rule${expenseRules.length === 1 ? '' : 's'}`}
variant="outlined"
/>
) : null}
</Stack>
}
/>
<ListItemSecondaryAction>
<Stack direction="row" spacing={0.5}>
{category.kind === 'expense' ? (
<IconButton
edge="end"
aria-label="auto-match rules"
onClick={() => onOpenAutoMatchRules(category.id)}
>
<LinkIcon fontSize="small" />
</IconButton>
) : null}
<IconButton
edge="end"
aria-label="edit category"
onClick={() => onEditCategory(category)}
>
<EditIcon fontSize="small" />
</IconButton>
</Stack>
</ListItemSecondaryAction>
</ListItem>
</Box>
);
})}
</List>
);

return (
<Card>
<CardContent>
<Typography variant="subtitle1" fontWeight={700} sx={{ mb: 1 }}>
Category List
</Typography>
{categories.length === 0 ? (
<Typography color="text.secondary">No categories yet.</Typography>
) : (
<Stack spacing={2}>
<Box>
<Typography variant="overline" color="text.secondary" sx={{ fontWeight: 700 }}>
Income & Transfers
</Typography>
{nonExpenseCategories.length === 0 ? (
<Typography color="text.secondary">
No income or transfer categories yet.
</Typography>
) : (
renderCategoryRows(nonExpenseCategories)
)}
</Box>

<Box>
<Typography variant="overline" color="text.secondary" sx={{ fontWeight: 700 }}>
Expense
</Typography>
{expenseCategories.length === 0 ? (
<Typography color="text.secondary">No expense categories yet.</Typography>
) : (
renderCategoryRows(expenseCategories)
)}
</Box>
</Stack>
)}
</CardContent>
</Card>
);
};
Loading