Skip to content

Improve Expenses mobile UX (Monzo-style list + safe-area fixes)#2

Merged
sergdort merged 3 commits intomainfrom
feat/expenses-mobile-ux-pass1
Feb 26, 2026
Merged

Improve Expenses mobile UX (Monzo-style list + safe-area fixes)#2
sergdort merged 3 commits intomainfrom
feat/expenses-mobile-ux-pass1

Conversation

@LogenNineFingersIsAlive
Copy link
Collaborator

Summary

Polishes the Expenses page mobile UX inspired by Monzo list ergonomics, without changing the app theme.

Changes

  • simplify list rows to a cleaner two-line hierarchy (title + subtitle)
  • reduce visual noise (remove chip-heavy row treatment)
  • refine amount rendering (smaller typography, color-coded inflow)
  • remove card-style container to use a plain list presentation
  • add sticky day headers with day subtotal context
  • fix iPhone safe-area handling in shell (top and bottom insets)
  • make top app bar solid (non-transparent)
  • ensure bottom tab bar z-index stays above sticky section headers

Files

  • apps/pwa/src/pages/ExpensesPage.tsx
  • apps/pwa/src/components/MobileShell.tsx

Validation

  • pnpm --filter @tithe/pwa build passes

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MobileShell safe-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 reusing expenseAmountPresentation/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

  • expenseAmountPresentation hard-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 using theme.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.

Comment on lines 294 to 305
{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',
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 all items. 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

  • expenseAmountPresentation hard-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 / using theme.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 closeReimbursement mutation 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 MuiButtonBase minHeight=44) by forcing minHeight: 22 and 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.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 uses theme.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 pounds function using Math.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: 36 which 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: 36 which 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 reduce is performed inline during render for each day group. For better performance, consider memoizing the day subtotals using useMemo or computing them once when creating groupedExpenses, 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.

@sergdort sergdort merged commit 8ecfc76 into main Feb 26, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants