diff --git a/.agents/notes.md b/.agents/notes.md index 3e2ef28..b841285 100644 --- a/.agents/notes.md +++ b/.agents/notes.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index d1b59dc..ce90286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index ca64dae..0e4afd1 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index ad0fdf9..d27b2db 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -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 = { - '/': '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 ( - + } /> } /> + } /> } /> } /> } /> diff --git a/apps/pwa/src/api.ts b/apps/pwa/src/api.ts index c75739a..4067481 100644 --- a/apps/pwa/src/api.ts +++ b/apps/pwa/src/api.ts @@ -101,7 +101,14 @@ export const api = { }), }, expenses: { - list: () => request('/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(`/expenses?${search.toString()}`); + }, create: (body: { occurredAt: string; postedAt?: string | null; diff --git a/apps/pwa/src/components/MobileShell.tsx b/apps/pwa/src/components/MobileShell.tsx index 9c27679..58dfef2 100644 --- a/apps/pwa/src/components/MobileShell.tsx +++ b/apps/pwa/src/components/MobileShell.tsx @@ -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'; @@ -8,6 +9,7 @@ import { BottomNavigation, BottomNavigationAction, Box, + IconButton, Paper, Toolbar, Typography, @@ -17,6 +19,8 @@ import { useLocation, useNavigate } from 'react-router-dom'; interface MobileShellProps { title: string; + showBackButton?: boolean; + onBack?: () => void; children: React.ReactNode; } @@ -28,12 +32,23 @@ const tabs = [ { label: 'Insights', path: '/insights', icon: }, ]; -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], ); @@ -51,6 +66,11 @@ export const MobileShell = ({ title, children }: MobileShellProps) => { }} > + {showBackButton ? ( + + + + ) : null} {title} diff --git a/apps/pwa/src/features/categories/components/CategoriesListCard.tsx b/apps/pwa/src/features/categories/components/CategoriesListCard.tsx index 54111a8..876b6d9 100644 --- a/apps/pwa/src/features/categories/components/CategoriesListCard.tsx +++ b/apps/pwa/src/features/categories/components/CategoriesListCard.tsx @@ -31,79 +31,119 @@ export const CategoriesListCard = ({ rulesByExpenseCategoryId, onOpenAutoMatchRules, onEditCategory, -}: CategoriesListCardProps) => ( - - - - Category List - - {categories.length === 0 ? ( - No categories yet. - ) : ( - - {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 ( - - - - - - {normalizeCategoryLabel(category.name)} - - {isPlaceholder ? ( - - ) : null} - {category.kind === 'expense' ? ( - - ) : null} - {category.kind === 'expense' ? ( - - ) : null} - - } - secondary={category.kind} - /> - - - {category.kind === 'expense' ? ( - onOpenAutoMatchRules(category.id)} - > - - - ) : null} - onEditCategory(category)} - > - - - - - - - ); - })} - - )} - - -); + const renderCategoryRows = (items: Category[]) => ( + + {items.map((category) => { + const expenseRules = rulesByExpenseCategoryId.get(category.id) ?? []; + const isPlaceholder = isMonzoPlaceholderCategoryName(category.name); + const CategoryRowIcon = + CATEGORY_ICON_COMPONENTS[category.icon || 'category'] ?? CategoryIcon; + + return ( + + + + + {normalizeCategoryLabel(category.name)} + {isPlaceholder ? ( + + ) : null} + {category.kind === 'expense' ? ( + + ) : null} + {category.kind === 'expense' ? ( + + ) : null} + + } + /> + + + {category.kind === 'expense' ? ( + onOpenAutoMatchRules(category.id)} + > + + + ) : null} + onEditCategory(category)} + > + + + + + + + ); + })} + + ); + + return ( + + + + Category List + + {categories.length === 0 ? ( + No categories yet. + ) : ( + + + + Income & Transfers + + {nonExpenseCategories.length === 0 ? ( + + No income or transfer categories yet. + + ) : ( + renderCategoryRows(nonExpenseCategories) + )} + + + + + Expense + + {expenseCategories.length === 0 ? ( + No expense categories yet. + ) : ( + renderCategoryRows(expenseCategories) + )} + + + )} + + + ); +}; diff --git a/apps/pwa/src/features/expenses/components/ExpensesList.tsx b/apps/pwa/src/features/expenses/components/ExpensesList.tsx new file mode 100644 index 0000000..ab6b951 --- /dev/null +++ b/apps/pwa/src/features/expenses/components/ExpensesList.tsx @@ -0,0 +1,438 @@ +import { + Alert, + Avatar, + Box, + Button, + Chip, + CircularProgress, + List, + ListItem, + ListItemAvatar, + Stack, + Typography, +} from '@mui/material'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; + +import { api } from '../../../api.js'; +import { pounds } from '../../../lib/format/money.js'; +import type { Category, Expense } from '../../../types.js'; + +const merchantInitials = (merchantName?: string | null): string => { + const trimmed = merchantName?.trim(); + if (!trimmed) { + return '\u2022'; + } + + const tokens = trimmed.match(/[A-Za-z0-9]+/g) ?? []; + if (tokens.length >= 2) { + return `${tokens[0]?.[0] ?? ''}${tokens[1]?.[0] ?? ''}`.toUpperCase() || '\u2022'; + } + + const singleToken = tokens[0] ?? ''; + const initials = singleToken.slice(0, 2).toUpperCase(); + return initials || '\u2022'; +}; + +interface ExpenseMerchantAvatarProps { + expenseId: string; + merchantName?: string | null; + merchantLogoUrl?: string | null; + merchantEmoji?: string | null; +} + +const ExpenseMerchantAvatar = ({ + expenseId, + merchantName, + merchantLogoUrl, + merchantEmoji, +}: ExpenseMerchantAvatarProps) => { + const logoUrl = merchantLogoUrl?.trim() || ''; + const [failedLogoUrl, setFailedLogoUrl] = useState(null); + const merchantLabel = merchantName?.trim() || 'Merchant'; + const emoji = merchantEmoji?.trim() || ''; + const canShowLogo = logoUrl.length > 0 && failedLogoUrl !== logoUrl; + const fallbackText = emoji || merchantInitials(merchantName); + const avatarKind = canShowLogo ? 'logo' : emoji ? 'emoji' : 'initials'; + + return ( + + {canShowLogo ? ( + setFailedLogoUrl(logoUrl)} + sx={{ width: '100%', height: '100%', objectFit: 'cover' }} + /> + ) : ( + fallbackText + )} + + ); +}; + +const dayLabel = (isoDate: string): string => { + const date = new Date(isoDate); + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const startOfInput = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diffDays = Math.round( + (startOfToday.getTime() - startOfInput.getTime()) / (24 * 60 * 60 * 1000), + ); + + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + + return date.toLocaleDateString('en-GB', { + weekday: 'short', + day: 'numeric', + month: 'short', + }); +}; + +const semanticKindLabel = ( + kind?: string, + transferDirection?: 'in' | 'out' | null, +): string | null => { + if (kind === 'transfer_internal') { + return `Internal transfer (${transferDirection === 'in' ? 'in' : 'out'})`; + } + if (kind === 'transfer_external') { + return `External transfer (${transferDirection === 'in' ? 'in' : 'out'})`; + } + return null; +}; + +const reimbursementChipLabel = (status?: string): string | null => { + if (!status || status === 'none') return null; + if (status === 'expected') return 'Reimbursable'; + if (status === 'partial') return 'Partial'; + if (status === 'settled') return 'Settled'; + if (status === 'written_off') return 'Written off'; + return null; +}; + +const isInflowExpense = (expense: { + kind?: string; + transferDirection?: 'in' | 'out' | null; +}): boolean => { + if (expense.kind === 'income') return true; + if (expense.kind === 'transfer_external' || expense.kind === 'transfer_internal') { + return expense.transferDirection === 'in'; + } + return false; +}; + +const expenseAmountPresentation = (expense: { + kind?: string; + transferDirection?: 'in' | 'out' | null; + money: { amountMinor: number; currency: string }; +}): { text: string; color: string } => { + const base = pounds(expense.money.amountMinor, expense.money.currency); + + if (isInflowExpense(expense)) { + return { text: `+${base}`, color: 'success.main' }; + } + + if (expense.kind === 'transfer_internal') { + return { text: base, color: 'text.secondary' }; + } + + return { text: base, color: 'text.primary' }; +}; + +interface ExpensesListProps { + categories: Category[]; + expenses: Expense[]; + isLoading: boolean; + isError: boolean; + emptyLabel: string; +} + +export const ExpensesList = ({ + categories, + expenses, + isLoading, + isError, + emptyLabel, +}: ExpensesListProps) => { + const queryClient = useQueryClient(); + + const linkReimbursement = useMutation({ + mutationFn: (payload: { expenseOutId: string; expenseInId: string; amountMinor: number }) => + api.reimbursements.link({ + ...payload, + idempotencyKey: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`, + }), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['expenses'] }), + queryClient.invalidateQueries({ queryKey: ['report', 'monthlyLedger'] }), + ]); + }, + }); + + const closeReimbursement = useMutation({ + mutationFn: (payload: { + expenseOutId: string; + closeOutstandingMinor?: number; + reason?: string | null; + }) => + api.reimbursements.close(payload.expenseOutId, { + closeOutstandingMinor: payload.closeOutstandingMinor, + reason: payload.reason ?? undefined, + }), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['expenses'] }), + queryClient.invalidateQueries({ queryKey: ['report', 'monthlyLedger'] }), + ]); + }, + }); + + const reopenReimbursement = useMutation({ + mutationFn: (expenseOutId: string) => api.reimbursements.reopen(expenseOutId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['expenses'] }), + queryClient.invalidateQueries({ queryKey: ['report', 'monthlyLedger'] }), + ]); + }, + }); + + const groupedExpenses = useMemo(() => { + const groups = new Map(); + + for (const expense of expenses) { + const key = dayLabel(expense.occurredAt); + const list = groups.get(key) ?? []; + list.push(expense); + groups.set(key, list); + } + + return Array.from(groups.entries()); + }, [expenses]); + + const categoryById = useMemo(() => { + const map = new Map< + string, + { name: string; color: string; icon: string; kind: string; reimbursementMode?: string } + >(); + for (const category of categories) { + map.set(category.id, { + name: category.name, + color: category.color, + icon: category.icon, + kind: category.kind, + reimbursementMode: category.reimbursementMode, + }); + } + return map; + }, [categories]); + + if (isLoading) { + return ( + + + + ); + } + + if (isError) { + return Unable to load expenses.; + } + + if (expenses.length === 0) { + return ( + + {emptyLabel} + + ); + } + + return ( + + {groupedExpenses.map(([label, items]) => ( + + `1px solid ${theme.palette.divider}`, + }} + > + + {label} + + + {pounds( + items.reduce( + (sum, expense) => + sum + + (isInflowExpense(expense) + ? expense.money.amountMinor + : -expense.money.amountMinor), + 0, + ), + 'GBP', + )} + + + + {items.map((expense) => { + const merchant = expense.merchantName?.trim() || 'Card payment'; + const categoryMeta = categoryById.get(expense.categoryId); + const categoryName = categoryMeta?.name ?? expense.categoryId; + const kindLabel = semanticKindLabel(expense.kind, expense.transferDirection); + const reimbursementLabel = reimbursementChipLabel(expense.reimbursementStatus); + const canShowReimbursement = + expense.kind === 'expense' && + expense.reimbursementStatus && + expense.reimbursementStatus !== 'none'; + const outstandingMinor = expense.outstandingMinor ?? 0; + const amountView = expenseAmountPresentation(expense); + const isPendingMonzo = expense.source === 'monzo' && expense.postedAt === null; + + const subtitle = canShowReimbursement + ? `${reimbursementLabel ?? 'Reimbursable'} · Outstanding ${pounds( + outstandingMinor, + expense.money.currency, + )}` + : kindLabel || categoryName; + + const handleLinkRepayment = () => { + const expenseInId = window.prompt( + 'Inbound transaction ID to link as reimbursement', + ); + if (!expenseInId) return; + const amountText = window.prompt( + 'Allocation amount (GBP)', + (outstandingMinor / 100).toFixed(2), + ); + if (!amountText) return; + const parsed = Number(amountText); + if (!Number.isFinite(parsed) || parsed <= 0) return; + linkReimbursement.mutate({ + expenseOutId: expense.id, + expenseInId: expenseInId.trim(), + amountMinor: Math.round(parsed * 100), + }); + }; + + const handleCloseRemainder = () => { + const amountText = window.prompt( + 'Write-off outstanding amount (GBP)', + (outstandingMinor / 100).toFixed(2), + ); + if (!amountText) return; + const parsed = Number(amountText); + if (!Number.isFinite(parsed) || parsed < 0) return; + const reason = window.prompt('Reason (optional)') ?? undefined; + closeReimbursement.mutate({ + expenseOutId: expense.id, + closeOutstandingMinor: Math.round(parsed * 100), + reason, + }); + }; + + return ( + + + + + + + + {merchant} + + + + {subtitle} + + {isPendingMonzo ? ( + + ) : null} + + {canShowReimbursement && outstandingMinor > 0 ? ( + + + + + ) : null} + {canShowReimbursement && + outstandingMinor === 0 && + expense.reimbursementStatus === 'written_off' ? ( + + ) : null} + + + + + {amountView.text} + + + + ); + })} + + + ))} + + ); +}; diff --git a/apps/pwa/src/features/home/components/MonthlyLedgerCard.tsx b/apps/pwa/src/features/home/components/MonthlyLedgerCard.tsx index c05988e..5233535 100644 --- a/apps/pwa/src/features/home/components/MonthlyLedgerCard.tsx +++ b/apps/pwa/src/features/home/components/MonthlyLedgerCard.tsx @@ -1,3 +1,4 @@ +import CategoryIcon from '@mui/icons-material/Category'; import { Alert, Box, @@ -9,17 +10,23 @@ import { FormControlLabel, List, ListItem, + ListItemButton, ListItemText, Stack, Switch, Typography, } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { MonthWindow } from '../../../lib/date/month.js'; import { pounds, signedPounds } from '../../../lib/format/money.js'; +import { CATEGORY_ICON_COMPONENTS } from '../../categories/constants.js'; import { useMonzoSyncMutation } from '../hooks/useHomeMutations.js'; -import { useHomeMonthlyLedgerQuery, useHomeMonzoStatusQuery } from '../hooks/useHomeQueries.js'; +import { + useHomeCategoriesQuery, + useHomeMonthlyLedgerQuery, + useHomeMonzoStatusQuery, +} from '../hooks/useHomeQueries.js'; import { MonthNavigator } from './MonthNavigator.js'; interface MonthlyLedgerCardProps { @@ -27,12 +34,14 @@ interface MonthlyLedgerCardProps { onPreviousMonth: () => void; onNextMonth: () => void; onAddTransaction: () => void; + onOpenExpenseCategory?: (categoryId: string) => void; } interface LedgerCategorySectionProps { title: string; titleColor: string; emptyLabel: string; + categoryMetaById: Map; rows: Array<{ categoryId: string; categoryName: string; @@ -40,14 +49,17 @@ interface LedgerCategorySectionProps { txCount: number; }>; rowAmountColor?: string; + onRowClick?: (categoryId: string) => void; } const LedgerCategorySection = ({ title, titleColor, emptyLabel, + categoryMetaById, rows, rowAmountColor, + onRowClick, }: LedgerCategorySectionProps) => ( @@ -63,17 +75,72 @@ const LedgerCategorySection = ({ - - - {pounds(item.totalMinor)} - + {onRowClick ? ( + onRowClick(item.categoryId)} + aria-label={`View ${item.categoryName} expenses`} + > + {(() => { + const categoryMeta = categoryMetaById.get(item.categoryId); + const CategoryRowIcon = + CATEGORY_ICON_COMPONENTS[categoryMeta?.icon ?? 'category'] ?? CategoryIcon; + + return ( + + + + ); + })()} + + + {pounds(item.totalMinor)} + + + ) : ( + <> + {(() => { + const categoryMeta = categoryMetaById.get(item.categoryId); + const CategoryRowIcon = + CATEGORY_ICON_COMPONENTS[categoryMeta?.icon ?? 'category'] ?? CategoryIcon; + + return ( + + + + ); + })()} + + + {pounds(item.totalMinor)} + + + )} ))} @@ -82,8 +149,10 @@ const LedgerCategorySection = ({ ); const LedgerTransferSection = ({ + categoryMetaById, rows, }: { + categoryMetaById: Map; rows: Array<{ categoryId: string; categoryName: string; @@ -106,8 +175,29 @@ const LedgerTransferSection = ({ + {(() => { + const categoryMeta = categoryMetaById.get(item.categoryId); + const CategoryRowIcon = + CATEGORY_ICON_COMPONENTS[categoryMeta?.icon ?? 'category'] ?? CategoryIcon; + + return ( + + + + ); + })()} { const ledgerQuery = useHomeMonthlyLedgerQuery(monthWindow); + const categoriesQuery = useHomeCategoriesQuery(); const monzoStatusQuery = useHomeMonzoStatusQuery(); const syncMutation = useMonzoSyncMutation(); const ledger = ledgerQuery.data; + const categories = categoriesQuery.data ?? []; const monzoStatus = monzoStatusQuery.data; const monthKey = `${monthWindow.from}|${monthWindow.to}`; const resetSyncMutation = syncMutation.reset; @@ -145,6 +238,11 @@ export const MonthlyLedgerCard = ({ ); const [spendMode, setSpendMode] = useState<'gross' | 'net'>('net'); const [excludeInternalTransfers, setExcludeInternalTransfers] = useState(true); + const categoryMetaById = useMemo( + () => + new Map(categories.map((item) => [item.id, { icon: item.icon, color: item.color }] as const)), + [categories], + ); const isInitialLoading = ledgerQuery.isLoading && !ledger; const hasBlockingError = ledgerQuery.isError && !ledger; @@ -399,6 +497,7 @@ export const MonthlyLedgerCard = ({ title="Income" titleColor="success.dark" emptyLabel="No income recorded." + categoryMetaById={categoryMetaById} rows={ledger.sections.income} rowAmountColor="success.main" /> @@ -407,10 +506,15 @@ export const MonthlyLedgerCard = ({ title="Expenses" titleColor="error.dark" emptyLabel="No expenses recorded." + categoryMetaById={categoryMetaById} rows={ledger.sections.expense} + onRowClick={onOpenExpenseCategory} /> - + )} diff --git a/apps/pwa/src/features/home/hooks/useHomeMonthCursor.ts b/apps/pwa/src/features/home/hooks/useHomeMonthCursor.ts index 1e3684a..3444641 100644 --- a/apps/pwa/src/features/home/hooks/useHomeMonthCursor.ts +++ b/apps/pwa/src/features/home/hooks/useHomeMonthCursor.ts @@ -2,8 +2,14 @@ import { startTransition, useMemo, useState } from 'react'; import { monthStartLocal, monthWindow, shiftMonthLocal } from '../../../lib/date/month.js'; -export const useHomeMonthCursor = () => { - const [monthCursor, setMonthCursor] = useState(() => monthStartLocal(new Date())); +interface UseHomeMonthCursorOptions { + initialMonthCursor?: Date; +} + +export const useHomeMonthCursor = (options?: UseHomeMonthCursorOptions) => { + const [monthCursor, setMonthCursor] = useState(() => + monthStartLocal(options?.initialMonthCursor ?? new Date()), + ); const window = useMemo(() => monthWindow(monthCursor), [monthCursor]); diff --git a/apps/pwa/src/lib/date/month.ts b/apps/pwa/src/lib/date/month.ts index 4e50d8b..d941776 100644 --- a/apps/pwa/src/lib/date/month.ts +++ b/apps/pwa/src/lib/date/month.ts @@ -4,6 +4,8 @@ export interface MonthWindow { label: string; } +const MONTH_PARAM_PATTERN = /^(\d{4})-(\d{2})$/; + export const monthStartLocal = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0); @@ -23,3 +25,28 @@ export const monthWindow = (cursor: Date): MonthWindow => { }), }; }; + +export const formatMonthParam = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + return `${year}-${month}`; +}; + +export const parseMonthParam = (value: string | null | undefined): Date | null => { + if (!value) { + return null; + } + + const match = value.match(MONTH_PARAM_PATTERN); + if (!match) { + return null; + } + + const year = Number(match[1]); + const month = Number(match[2]); + if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) { + return null; + } + + return new Date(year, month - 1, 1, 0, 0, 0, 0); +}; diff --git a/apps/pwa/src/pages/ExpenseCategoryDetailPage.tsx b/apps/pwa/src/pages/ExpenseCategoryDetailPage.tsx new file mode 100644 index 0000000..7876e41 --- /dev/null +++ b/apps/pwa/src/pages/ExpenseCategoryDetailPage.tsx @@ -0,0 +1,77 @@ +import { Alert, Stack, Typography } from '@mui/material'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; + +import { api } from '../api.js'; +import { ExpensesList } from '../features/expenses/components/ExpensesList.js'; +import { monthStartLocal, parseMonthParam, shiftMonthLocal } from '../lib/date/month.js'; + +export const ExpenseCategoryDetailPage = () => { + const { categoryId } = useParams<{ categoryId: string }>(); + const [searchParams] = useSearchParams(); + + const monthCursor = useMemo( + () => monthStartLocal(parseMonthParam(searchParams.get('month')) ?? new Date()), + [searchParams], + ); + + const monthLabel = useMemo( + () => + monthCursor.toLocaleDateString('en-GB', { + month: 'long', + year: 'numeric', + }), + [monthCursor], + ); + + const from = useMemo(() => monthCursor.toISOString(), [monthCursor]); + const to = useMemo(() => { + const nextMonthStart = shiftMonthLocal(monthCursor, 1); + return new Date(nextMonthStart.getTime() - 1).toISOString(); + }, [monthCursor]); + + const categoriesQuery = useQuery({ + queryKey: ['categories'], + queryFn: () => api.categories.list(), + }); + + const expensesQuery = useQuery({ + queryKey: ['expenses', 'category-detail', categoryId, from, to], + queryFn: () => api.expenses.list({ categoryId, from, to, limit: 1000 }), + enabled: Boolean(categoryId), + }); + + if (!categoryId) { + return Missing category.; + } + + const categories = categoriesQuery.data ?? []; + const categoryName = categories.find((item) => item.id === categoryId)?.name ?? 'Category'; + + return ( + + + {monthLabel} + + + {categoryName} + + + Transactions in this category for the selected month. + + + {categoriesQuery.isError ? ( + Category metadata unavailable. Showing raw category IDs. + ) : null} + + + + ); +}; diff --git a/apps/pwa/src/pages/ExpensesPage.tsx b/apps/pwa/src/pages/ExpensesPage.tsx index 9017207..9d4ec6d 100644 --- a/apps/pwa/src/pages/ExpensesPage.tsx +++ b/apps/pwa/src/pages/ExpensesPage.tsx @@ -1,157 +1,22 @@ import AddIcon from '@mui/icons-material/Add'; import { Alert, - Avatar, Box, Button, - Chip, - CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Fab, - List, - ListItem, - ListItemAvatar, MenuItem, Stack, TextField, - Typography, } from '@mui/material'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; +import { useState } from 'react'; import { api } from '../api.js'; -import { pounds } from '../lib/format/money.js'; - -const merchantInitials = (merchantName?: string | null): string => { - const trimmed = merchantName?.trim(); - if (!trimmed) { - return '•'; - } - - const tokens = trimmed.match(/[A-Za-z0-9]+/g) ?? []; - if (tokens.length >= 2) { - return `${tokens[0]?.[0] ?? ''}${tokens[1]?.[0] ?? ''}`.toUpperCase() || '•'; - } - - const singleToken = tokens[0] ?? ''; - const initials = singleToken.slice(0, 2).toUpperCase(); - return initials || '•'; -}; - -interface ExpenseMerchantAvatarProps { - expenseId: string; - merchantName?: string | null; - merchantLogoUrl?: string | null; - merchantEmoji?: string | null; -} - -const ExpenseMerchantAvatar = ({ - expenseId, - merchantName, - merchantLogoUrl, - merchantEmoji, -}: ExpenseMerchantAvatarProps) => { - const logoUrl = merchantLogoUrl?.trim() || ''; - const [failedLogoUrl, setFailedLogoUrl] = useState(null); - const merchantLabel = merchantName?.trim() || 'Merchant'; - const emoji = merchantEmoji?.trim() || ''; - const canShowLogo = logoUrl.length > 0 && failedLogoUrl !== logoUrl; - const fallbackText = emoji || merchantInitials(merchantName); - const avatarKind = canShowLogo ? 'logo' : emoji ? 'emoji' : 'initials'; - - return ( - - {canShowLogo ? ( - setFailedLogoUrl(logoUrl)} - sx={{ width: '100%', height: '100%', objectFit: 'cover' }} - /> - ) : ( - fallbackText - )} - - ); -}; - -const dayLabel = (isoDate: string): string => { - const date = new Date(isoDate); - const now = new Date(); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const startOfInput = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - const diffDays = Math.round( - (startOfToday.getTime() - startOfInput.getTime()) / (24 * 60 * 60 * 1000), - ); - - if (diffDays === 0) return 'Today'; - if (diffDays === 1) return 'Yesterday'; - - return date.toLocaleDateString('en-GB', { - weekday: 'short', - day: 'numeric', - month: 'short', - }); -}; - -const semanticKindLabel = ( - kind?: string, - transferDirection?: 'in' | 'out' | null, -): string | null => { - if (kind === 'transfer_internal') { - return `Internal transfer (${transferDirection === 'in' ? 'in' : 'out'})`; - } - if (kind === 'transfer_external') { - return `External transfer (${transferDirection === 'in' ? 'in' : 'out'})`; - } - return null; -}; - -const reimbursementChipLabel = (status?: string): string | null => { - if (!status || status === 'none') return null; - if (status === 'expected') return 'Reimbursable'; - if (status === 'partial') return 'Partial'; - if (status === 'settled') return 'Settled'; - if (status === 'written_off') return 'Written off'; - return null; -}; - -const isInflowExpense = (expense: { - kind?: string; - transferDirection?: 'in' | 'out' | null; -}): boolean => { - if (expense.kind === 'income') return true; - if (expense.kind === 'transfer_external' || expense.kind === 'transfer_internal') { - return expense.transferDirection === 'in'; - } - return false; -}; - -const expenseAmountPresentation = (expense: { - kind?: string; - transferDirection?: 'in' | 'out' | null; - money: { amountMinor: number; currency: string }; -}): { text: string; color: string } => { - const base = pounds(expense.money.amountMinor, expense.money.currency); - - if (isInflowExpense(expense)) { - return { text: `+${base}`, color: 'success.main' }; - } - - if (expense.kind === 'transfer_internal') { - return { text: base, color: 'text.secondary' }; - } - - return { text: base, color: 'text.primary' }; -}; +import { ExpensesList } from '../features/expenses/components/ExpensesList.js'; export const ExpensesPage = () => { const queryClient = useQueryClient(); @@ -200,288 +65,17 @@ export const ExpensesPage = () => { }, }); - const linkReimbursement = useMutation({ - mutationFn: (payload: { expenseOutId: string; expenseInId: string; amountMinor: number }) => - api.reimbursements.link({ - ...payload, - idempotencyKey: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`, - }), - onSuccess: async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['expenses'] }), - queryClient.invalidateQueries({ queryKey: ['report', 'monthlyLedger'] }), - ]); - }, - }); - - const closeReimbursement = useMutation({ - mutationFn: (payload: { - expenseOutId: string; - closeOutstandingMinor?: number; - reason?: string | null; - }) => - api.reimbursements.close(payload.expenseOutId, { - closeOutstandingMinor: payload.closeOutstandingMinor, - reason: payload.reason ?? undefined, - }), - onSuccess: async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['expenses'] }), - queryClient.invalidateQueries({ queryKey: ['report', 'monthlyLedger'] }), - ]); - }, - }); - - const reopenReimbursement = useMutation({ - mutationFn: (expenseOutId: string) => api.reimbursements.reopen(expenseOutId), - onSuccess: async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['expenses'] }), - queryClient.invalidateQueries({ queryKey: ['report', 'monthlyLedger'] }), - ]); - }, - }); - - const categoryById = useMemo(() => { - const map = new Map< - string, - { name: string; color: string; icon: string; kind: string; reimbursementMode?: string } - >(); - for (const category of categoriesQuery.data ?? []) { - map.set(category.id, { - name: category.name, - color: category.color, - icon: category.icon, - kind: category.kind, - reimbursementMode: category.reimbursementMode, - }); - } - return map; - }, [categoriesQuery.data]); - const categories = categoriesQuery.data ?? []; - const expenses = expensesQuery.data ?? []; - const groupedExpenses = useMemo(() => { - const groups = new Map(); - - for (const expense of expenses) { - const key = dayLabel(expense.occurredAt); - const list = groups.get(key) ?? []; - list.push(expense); - groups.set(key, list); - } - - return Array.from(groups.entries()); - }, [expenses]); - - if (categoriesQuery.isLoading || expensesQuery.isLoading) { - return ( - - - - ); - } - - if (categoriesQuery.isError || expensesQuery.isError) { - return Unable to load expenses.; - } return ( - {expenses.length === 0 ? ( - - No expenses logged yet. - - ) : ( - - {groupedExpenses.map(([label, items]) => ( - - `1px solid ${theme.palette.divider}`, - }} - > - - {label} - - - {pounds( - items.reduce( - (sum, expense) => - sum + - (isInflowExpense(expense) - ? expense.money.amountMinor - : -expense.money.amountMinor), - 0, - ), - 'GBP', - )} - - - - {items.map((expense) => { - const merchant = expense.merchantName?.trim() || 'Card payment'; - const categoryMeta = categoryById.get(expense.categoryId); - const categoryName = categoryMeta?.name ?? expense.categoryId; - const kindLabel = semanticKindLabel(expense.kind, expense.transferDirection); - const reimbursementLabel = reimbursementChipLabel(expense.reimbursementStatus); - const canShowReimbursement = - expense.kind === 'expense' && - expense.reimbursementStatus && - expense.reimbursementStatus !== 'none'; - const outstandingMinor = expense.outstandingMinor ?? 0; - const amountView = expenseAmountPresentation(expense); - const isPendingMonzo = expense.source === 'monzo' && expense.postedAt === null; - - const subtitle = canShowReimbursement - ? `${reimbursementLabel ?? 'Reimbursable'} · Outstanding ${pounds( - outstandingMinor, - expense.money.currency, - )}` - : kindLabel || categoryName; - - const handleLinkRepayment = () => { - const expenseInId = window.prompt( - 'Inbound transaction ID to link as reimbursement', - ); - if (!expenseInId) return; - const amountText = window.prompt( - 'Allocation amount (GBP)', - (outstandingMinor / 100).toFixed(2), - ); - if (!amountText) return; - const parsed = Number(amountText); - if (!Number.isFinite(parsed) || parsed <= 0) return; - linkReimbursement.mutate({ - expenseOutId: expense.id, - expenseInId: expenseInId.trim(), - amountMinor: Math.round(parsed * 100), - }); - }; - - const handleCloseRemainder = () => { - const amountText = window.prompt( - 'Write-off outstanding amount (GBP)', - (outstandingMinor / 100).toFixed(2), - ); - if (!amountText) return; - const parsed = Number(amountText); - if (!Number.isFinite(parsed) || parsed < 0) return; - const reason = window.prompt('Reason (optional)') ?? undefined; - closeReimbursement.mutate({ - expenseOutId: expense.id, - closeOutstandingMinor: Math.round(parsed * 100), - reason, - }); - }; - - return ( - - - - - - - - {merchant} - - - - {subtitle} - - {isPendingMonzo ? ( - - ) : null} - - {canShowReimbursement && outstandingMinor > 0 ? ( - - - - - ) : null} - {canShowReimbursement && - outstandingMinor === 0 && - expense.reimbursementStatus === 'written_off' ? ( - - ) : null} - - - - - {amountView.text} - - - - ); - })} - - - ))} - - )} + { - const { window, goPreviousMonth, goNextMonth } = useHomeMonthCursor(); + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + const monthParam = searchParams.get('month'); + const parsedMonthCursor = useMemo( + () => monthStartLocal(parseMonthParam(monthParam) ?? new Date()), + [monthParam], + ); + const { monthCursor, setMonthCursor, window, goPreviousMonth, goNextMonth } = useHomeMonthCursor({ + initialMonthCursor: parsedMonthCursor, + }); const [addOpen, setAddOpen] = useState(false); const [payInstanceId, setPayInstanceId] = useState(null); + const monthKey = useMemo(() => formatMonthParam(monthCursor), [monthCursor]); + + useEffect(() => { + setMonthCursor((current) => + current.getTime() === parsedMonthCursor.getTime() ? current : parsedMonthCursor, + ); + }, [parsedMonthCursor, setMonthCursor]); + + useEffect(() => { + if (searchParams.get('month') === monthKey) { + return; + } + const next = new URLSearchParams(searchParams); + next.set('month', monthKey); + setSearchParams(next, { replace: true }); + }, [monthKey, searchParams, setSearchParams]); + + const handleOpenExpenseCategory = (categoryId: string) => { + navigate( + `/expenses/category/${encodeURIComponent(categoryId)}?month=${encodeURIComponent(monthKey)}`, + { state: { inAppBackTarget: 'home' } }, + ); + }; return ( <> @@ -24,6 +58,7 @@ export const HomePage = () => { onPreviousMonth={goPreviousMonth} onNextMonth={goNextMonth} onAddTransaction={() => setAddOpen(true)} + onOpenExpenseCategory={handleOpenExpenseCategory} /> setPayInstanceId(instanceId)} /> diff --git a/tests/pwa/mobile.spec.ts b/tests/pwa/mobile.spec.ts index 1c54668..5a5d3ef 100644 --- a/tests/pwa/mobile.spec.ts +++ b/tests/pwa/mobile.spec.ts @@ -335,10 +335,240 @@ test('home shows monthly ledger and transfer direction in add transaction flow', await expect(page.getByLabel('Direction')).toBeVisible(); }); +test('ledger expense category drills into month-scoped detail and back preserves home month', async ({ + page, +}) => { + let expenseListRequestUrl: URL | null = null; + + await page.route('**/v1/reports/monthly-ledger*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + data: { + month: '2026-02', + range: { + from: '2026-02-01T00:00:00.000Z', + to: '2026-03-01T00:00:00.000Z', + }, + totals: { + incomeMinor: 300000, + expenseMinor: 125000, + transferInMinor: 5000, + transferOutMinor: 20000, + operatingSurplusMinor: 175000, + netCashMovementMinor: 160000, + txCount: 6, + }, + sections: { + income: [ + { + categoryId: '22222222-2222-2222-2222-222222222222', + categoryName: 'Salary', + totalMinor: 300000, + txCount: 1, + }, + ], + expense: [ + { + categoryId: '11111111-1111-1111-1111-111111111111', + categoryName: 'Sports', + totalMinor: 125000, + txCount: 2, + }, + ], + transfer: [ + { + categoryId: '33333333-3333-3333-3333-333333333333', + categoryName: 'ISA', + direction: 'out', + totalMinor: 20000, + txCount: 1, + }, + ], + }, + }, + meta: {}, + }), + }); + }); + + await page.route('**/v1/commitment-instances?status=pending', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, data: [], meta: {} }), + }); + }); + + await page.route('**/v1/commitments', async (route) => { + if (route.request().method() !== 'GET') { + await route.continue(); + return; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, data: [], meta: {} }), + }); + }); + + await page.route('**/v1/categories', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + data: [ + { + id: '11111111-1111-1111-1111-111111111111', + name: 'Sports', + kind: 'expense', + icon: 'sports_soccer', + color: '#2E7D32', + isSystem: false, + archivedAt: null, + createdAt: '2026-02-01T00:00:00.000Z', + updatedAt: '2026-02-01T00:00:00.000Z', + }, + { + id: '22222222-2222-2222-2222-222222222222', + name: 'Salary', + kind: 'income', + icon: 'payments', + color: '#2E7D32', + isSystem: false, + archivedAt: null, + createdAt: '2026-02-01T00:00:00.000Z', + updatedAt: '2026-02-01T00:00:00.000Z', + }, + { + id: '33333333-3333-3333-3333-333333333333', + name: 'ISA', + kind: 'transfer', + icon: 'savings', + color: '#1976D2', + isSystem: false, + archivedAt: null, + createdAt: '2026-02-01T00:00:00.000Z', + updatedAt: '2026-02-01T00:00:00.000Z', + }, + ], + meta: {}, + }), + }); + }); + + await page.route('**/v1/integrations/monzo/status', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + data: { + status: 'disconnected', + mode: 'developer_api_expenses_only', + configured: false, + connected: false, + accountId: null, + lastSyncAt: null, + lastCursor: null, + mappingCount: 0, + lastError: null, + }, + meta: {}, + }), + }); + }); + + await page.route('**/v1/expenses*', async (route) => { + if (route.request().method() !== 'GET') { + await route.continue(); + return; + } + expenseListRequestUrl = new URL(route.request().url()); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + data: [ + { + id: 'exp-sports', + occurredAt: '2026-02-05T10:00:00.000Z', + postedAt: '2026-02-05T11:00:00.000Z', + money: { amountMinor: 2500, currency: 'GBP' }, + categoryId: '11111111-1111-1111-1111-111111111111', + source: 'local', + kind: 'expense', + transferDirection: null, + reimbursementStatus: 'none', + myShareMinor: null, + closedOutstandingMinor: null, + counterpartyType: null, + reimbursementGroupId: null, + reimbursementClosedAt: null, + reimbursementClosedReason: null, + recoverableMinor: 0, + recoveredMinor: 0, + outstandingMinor: 0, + merchantName: 'Sports Direct', + merchantLogoUrl: null, + merchantEmoji: null, + note: null, + providerTransactionId: null, + commitmentInstanceId: null, + createdAt: '2026-02-05T10:00:00.000Z', + updatedAt: '2026-02-05T10:00:00.000Z', + }, + ], + meta: {}, + }), + }); + }); + + await page.goto('/?month=2026-02'); + + await page.locator('[data-ledger-section="income"]').click(); + await expect(page).toHaveURL(/\/\?month=2026-02$/); + + await page.getByText('ISA').click(); + await expect(page).toHaveURL(/\/\?month=2026-02$/); + + await page.getByRole('button', { name: 'View Sports expenses' }).click(); + await expect(page).toHaveURL( + /\/expenses\/category\/11111111-1111-1111-1111-111111111111\?month=2026-02$/, + ); + await expect(page.getByText('February 2026')).toBeVisible(); + await expect(page.locator('[data-expense-id="exp-sports"]')).toBeVisible(); + await expect( + page.locator('button.MuiBottomNavigationAction-root.Mui-selected', { hasText: 'Expenses' }), + ).toBeVisible(); + + expect(expenseListRequestUrl).not.toBeNull(); + if (!expenseListRequestUrl) { + throw new Error('Expected expenses request URL to be captured'); + } + const capturedExpenseListRequestUrl = expenseListRequestUrl as URL; + const expectedFrom = new Date(2026, 1, 1, 0, 0, 0, 0).toISOString(); + const expectedTo = new Date(new Date(2026, 2, 1, 0, 0, 0, 0).getTime() - 1).toISOString(); + expect(capturedExpenseListRequestUrl.searchParams.get('categoryId')).toBe( + '11111111-1111-1111-1111-111111111111', + ); + expect(capturedExpenseListRequestUrl.searchParams.get('limit')).toBe('1000'); + expect(capturedExpenseListRequestUrl.searchParams.get('from')).toBe(expectedFrom); + expect(capturedExpenseListRequestUrl.searchParams.get('to')).toBe(expectedTo); + + await page.getByLabel('Back').click(); + await expect(page).toHaveURL(/\/\?month=2026-02$/); +}); + test('monthly ledger sync posts current month range with overwrite and shows result summary', async ({ page, }) => { let ledgerRequestRange: { from: string; to: string } | null = null; + let initialLedgerRequestRange: { from: string; to: string } | null = null; let syncRequestBody: unknown = null; await page.route('**/v1/reports/monthly-ledger*', async (route) => { @@ -346,6 +576,9 @@ test('monthly ledger sync posts current month range with overwrite and shows res const from = url.searchParams.get('from') ?? '2026-02-01T00:00:00.000Z'; const to = url.searchParams.get('to') ?? '2026-03-01T00:00:00.000Z'; ledgerRequestRange = { from, to }; + if (!initialLedgerRequestRange) { + initialLedgerRequestRange = { from, to }; + } await route.fulfill({ status: 200, @@ -459,10 +692,10 @@ test('monthly ledger sync posts current month range with overwrite and shows res await page.getByRole('button', { name: 'Next month' }).click(); await expect(page.getByText('Imported 2, updated 1, skipped 3.')).toHaveCount(0); expect(ledgerRequestRange).not.toBeNull(); - if (!ledgerRequestRange) { + if (!initialLedgerRequestRange) { throw new Error('Expected monthly ledger request range to be captured'); } - const range = ledgerRequestRange as unknown as { from: string; to: string }; + const range = initialLedgerRequestRange as unknown as { from: string; to: string }; expect(syncRequestBody).toEqual({ from: range.from, to: range.to,