Improve Expenses mobile UX (Monzo-style list + safe-area fixes)#2
Improve Expenses mobile UX (Monzo-style list + safe-area fixes)#2
Conversation
There was a problem hiding this comment.
Pull request overview
This PR polishes the PWA’s mobile Expenses experience by switching to a simpler, Monzo-style list layout with sticky day headers/subtotals, and by improving iPhone safe-area handling and layering in the mobile shell.
Changes:
- Redesign expense rows into a cleaner two-line list presentation with simplified metadata and amount styling.
- Add sticky day headers with per-day subtotal context.
- Update
MobileShellsafe-area padding, app bar styling, and z-index layering for the bottom tab bar.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| apps/pwa/src/pages/ExpensesPage.tsx | Reworks the expenses list UI, adds sticky day headers + subtotals, and adjusts amount rendering and reimbursement actions. |
| apps/pwa/src/components/MobileShell.tsx | Applies safe-area insets (top/bottom), makes the app bar solid, and ensures the bottom nav layers above sticky headers. |
Comments suppressed due to low confidence (3)
apps/pwa/src/pages/ExpensesPage.tsx:137
- The “is inflow” classification logic is duplicated (here and again in the day subtotal reducer). This risks the amount color/sign and the header subtotal diverging over time. Consider extracting a shared helper (e.g.
isInflowExpense(expense)) or reusingexpenseAmountPresentation/a shared direction function in both places.
const isInflow =
expense.kind === 'income' ||
expense.kind === 'transfer_external' ||
expense.kind === 'transfer_internal'
? expense.transferDirection === 'in' || expense.kind === 'income'
: false;
apps/pwa/src/pages/ExpensesPage.tsx:393
- The reimbursement UI no longer exposes any way to close/write off outstanding reimbursements (the previous “Mark written off / write-off amount” flow was removed), but
api.reimbursements.close(...)still exists. If writing off is still a supported workflow, consider keeping an affordance (e.g. an overflow menu action) so users aren’t forced to use another surface to close remainders.
{subtitle}
</Typography>
{canShowReimbursement && outstandingMinor > 0 ? (
<Button
size="small"
variant="text"
onClick={handleLinkRepayment}
disabled={linkReimbursement.isPending}
sx={{ mt: 0.1, minHeight: 22, px: 0 }}
>
Link repayment
</Button>
) : null}
{canShowReimbursement &&
outstandingMinor === 0 &&
expense.reimbursementStatus === 'written_off' ? (
<Button
size="small"
variant="text"
onClick={() => reopenReimbursement.mutate(expense.id)}
disabled={reopenReimbursement.isPending}
sx={{ mt: 0.1, minHeight: 22, px: 0 }}
>
Reopen
apps/pwa/src/pages/ExpensesPage.tsx:147
expenseAmountPresentationhard-codes hex colors for amount styling. Elsewhere in the PWA, amount colors use theme palette tokens (e.g.success.main,error.main,text.primary), which also keeps dark mode consistent. Consider returning palette keys (or usingtheme.palette.*) instead of raw hex values.
if (isInflow) {
return { text: `+${base}`, color: '#2E7D32' };
}
if (expense.kind === 'transfer_internal') {
return { text: base, color: '#5F6368' };
}
return { text: base, color: '#111827' };
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| {pounds( | ||
| items.reduce((sum, expense) => { | ||
| const isInflow = | ||
| expense.kind === 'income' || | ||
| ((expense.kind === 'transfer_external' || | ||
| expense.kind === 'transfer_internal') && | ||
| expense.transferDirection === 'in'); | ||
| return ( | ||
| sum + (isInflow ? expense.money.amountMinor : -expense.money.amountMinor) | ||
| ); | ||
| }, 0), | ||
| 'GBP', |
There was a problem hiding this comment.
The day header subtotal is computed as a signed sum (inflows added, outflows subtracted) but is rendered with pounds(...), which formats Math.abs(amountMinor) and therefore drops the sign. This will show the same value for net +£X and net -£X, and it can also under/overstate “spent” when a day mixes income/transfers with expenses. Consider either (a) switching to signedPounds if the intent is net flow, or (b) summing only outflows if the intent is daily spend, and in both cases avoid hard-coding the currency to 'GBP' (use the items’ currency or group by currency).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
apps/pwa/src/pages/ExpensesPage.tsx:306
- Day subtotal is formatted as GBP unconditionally (
pounds(..., 'GBP')) and the reducer also assumes amounts are comparable across allitems. If any expense in the group has a non-GBP currency, the displayed subtotal will be incorrect/misleading. Consider formatting in the group's currency (only if all items share it) or splitting / omitting the subtotal when currencies differ.
{pounds(
items.reduce((sum, expense) => {
const isInflow =
expense.kind === 'income' ||
((expense.kind === 'transfer_external' ||
expense.kind === 'transfer_internal') &&
expense.transferDirection === 'in');
return (
sum + (isInflow ? expense.money.amountMinor : -expense.money.amountMinor)
);
}, 0),
'GBP',
)}
apps/pwa/src/pages/ExpensesPage.tsx:148
expenseAmountPresentationhard-codes hex colors for amounts. Elsewhere the app uses theme palette tokens (e.g.success.main,text.primary,text.secondary), and hard-coded hex makes future theming changes harder and can drift from the design system. Prefer returning palette keys / usingtheme.palette.*instead of raw hex values.
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);
const isInflow =
expense.kind === 'income' ||
expense.kind === 'transfer_external' ||
expense.kind === 'transfer_internal'
? expense.transferDirection === 'in' || expense.kind === 'income'
: false;
if (isInflow) {
return { text: `+${base}`, color: '#2E7D32' };
}
if (expense.kind === 'transfer_internal') {
return { text: base, color: '#5F6368' };
}
return { text: base, color: '#111827' };
};
apps/pwa/src/pages/ExpensesPage.tsx:395
- This refactor removes the ability to write off/close an outstanding reimbursement from the Expenses UI (the
closeReimbursementmutation and “Mark written off” action were removed). Since the PR description focuses on UX polish and doesn’t mention a functional change, consider reintroducing the close action (e.g. via an overflow/menu to keep rows clean) or explicitly calling out the behavior change in the PR description.
{canShowReimbursement && outstandingMinor > 0 ? (
<Button
size="small"
variant="text"
onClick={handleLinkRepayment}
disabled={linkReimbursement.isPending}
sx={{ mt: 0.1, minHeight: 22, px: 0 }}
>
Link repayment
</Button>
) : null}
{canShowReimbursement &&
outstandingMinor === 0 &&
expense.reimbursementStatus === 'written_off' ? (
<Button
size="small"
variant="text"
onClick={() => reopenReimbursement.mutate(expense.id)}
disabled={reopenReimbursement.isPending}
sx={{ mt: 0.1, minHeight: 22, px: 0 }}
>
Reopen
</Button>
) : null}
apps/pwa/src/pages/ExpensesPage.tsx:379
- These reimbursement action buttons override the global 44px minimum tap target (theme sets
MuiButtonBaseminHeight=44) by forcingminHeight: 22and removing horizontal padding. This can make the tap target too small on mobile. Consider keeping a 44px hit area (e.g. via padding/transparent container) while still presenting the label with smaller visual density.
<Button
size="small"
variant="text"
onClick={handleLinkRepayment}
disabled={linkReimbursement.isPending}
sx={{ mt: 0.1, minHeight: 22, px: 0 }}
>
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (6)
apps/pwa/src/pages/ExpensesPage.tsx:305
- The sticky section headers use a hardcoded
zIndex: 2, which may not work correctly if they need to appear below the AppBar but above regular content. Since the AppBar usestheme.zIndex.appBar(typically 1100), consider using a theme-based z-index value like(theme) => theme.zIndex.mobileStepper(1000) or defining a specific stacking order relative to the theme's z-index values for better maintainability and to avoid potential layering issues.
zIndex: 2,
apps/pwa/src/pages/ExpensesPage.tsx:327
- The subtotal calculation will always display as a positive value due to the
poundsfunction usingMath.abs()internally. When the sum is negative (more outflows than inflows), it will still be displayed as positive. Consider using a custom formatting function that preserves the sign, or add conditional logic to prepend a minus sign when the sum is negative.
{pounds(
items.reduce(
(sum, expense) =>
sum +
(isInflowExpense(expense)
? expense.money.amountMinor
: -expense.money.amountMinor),
0,
),
'GBP',
)}
apps/pwa/src/pages/ExpensesPage.tsx:327
- The currency is hardcoded to 'GBP' for the subtotal calculation, but individual expenses may have different currencies (passed as
expense.money.currency). If expenses with different currencies are mixed in the same day group, the subtotal will be incorrect. Consider either filtering by currency, displaying multiple subtotals per currency, or adding a validation that all expenses in a group use the same currency.
{pounds(
items.reduce(
(sum, expense) =>
sum +
(isInflowExpense(expense)
? expense.money.amountMinor
: -expense.money.amountMinor),
0,
),
'GBP',
)}
apps/pwa/src/pages/ExpensesPage.tsx:428
- The buttons have
minHeight: 36which is below the global theme minimum of 44px defined for all ButtonBase components. This could create accessibility issues as the touch target may be too small for users, particularly on mobile devices. Consider using the default minHeight or at least 44px to maintain consistency with the theme and ensure adequate touch target size.
<Stack direction="row" spacing={0.75} sx={{ mt: 0.25 }}>
<Button
size="small"
variant="text"
onClick={handleLinkRepayment}
disabled={linkReimbursement.isPending}
sx={{ minHeight: 36, px: 0.5 }}
>
Link repayment
</Button>
<Button
size="small"
variant="text"
onClick={handleCloseRemainder}
disabled={closeReimbursement.isPending}
sx={{ minHeight: 36, px: 0.5 }}
>
Mark written off
</Button>
apps/pwa/src/pages/ExpensesPage.tsx:442
- The button has
minHeight: 36which is below the global theme minimum of 44px defined for all ButtonBase components. This could create accessibility issues as the touch target may be too small for users, particularly on mobile devices. Consider using the default minHeight or at least 44px to maintain consistency with the theme and ensure adequate touch target size.
<Button
size="small"
variant="text"
onClick={() => reopenReimbursement.mutate(expense.id)}
disabled={reopenReimbursement.isPending}
sx={{ mt: 0.25, minHeight: 36, px: 0.5 }}
>
Reopen
</Button>
apps/pwa/src/pages/ExpensesPage.tsx:327
- The subtotal calculation using
reduceis performed inline during render for each day group. For better performance, consider memoizing the day subtotals usinguseMemoor computing them once when creatinggroupedExpenses, especially if the expense list grows large. This would avoid recalculating subtotals on every render.
{pounds(
items.reduce(
(sum, expense) =>
sum +
(isInflowExpense(expense)
? expense.money.amountMinor
: -expense.money.amountMinor),
0,
),
'GBP',
)}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Summary
Polishes the Expenses page mobile UX inspired by Monzo list ergonomics, without changing the app theme.
Changes
Files
apps/pwa/src/pages/ExpensesPage.tsxapps/pwa/src/components/MobileShell.tsxValidation
pnpm --filter @tithe/pwa buildpasses