+ YT
+ YouTube Premium looks stoppedMonthly · no charge since 9 Aug
+ 9 Aug—
+ ₹149a month
+ HideConfirm cancelled
+
+
+
+
+
+
+
Bills: decisions + open questions
+
+
Calendar shows bills, not spend (Home already has spend). Paid/due/late/overdue as soft fills, one bill per cell + "+1" if more.
+
Row actions only on flagged subscriptions; calm ones get a ··· menu. OK?
+
"How to cancel" opens Ask Lumen with a ready question; Mark cancelled / Set reminder act in place.
+
Needs backend: REST route for find_recurring (agent-only today); paid/late/overdue matching per cycle; "unused" (no usage data, only "looks stopped"); saved cancelled/reminder/not-a-bill state; card-bill due dates (amount varies, not detected).
Approve remaining 4Nov total would be ₹73,500 of ₹1,00,000
+
+
+
+
+
Last 6 months›
+
Within budgetOverOctober forecastBudget
+
+
+
+ Track record
+
3 of 5 months under
+
Over in June (travel) and August (the Croma order). October is heading for ₹86,400.
+
+
+
+
+
+
+
+
+ Budget page: decisions + open questions
+ 1. Draft lives on the page (not a dialog); Approve/Skip carry the category in their accessible names.
+ 2. Outlier basis ("median, excluding a one-off Croma order") and "2 of 3 months had spend" need backend: draft_budget is mean-of-3 today and basis.months always lists 3.
+ 3. Month switcher, pace chart and 6-month history need backend: GET /api/budget is current month only, no history or daily series.
+ 4. Safe per day subtracts known upcoming bills. OK, or plain left ÷ days?
+ 5. Unassigned ₹27,000 shown honestly. Should it nudge to "Other", or stay?
+
Bank statement? Use Import statement (CSV).CloseUpload 2 more
+
+
Open questions for the owner
+1. Review cards replace the old Review tab; Approve on a duplicate becomes "Keep both". OK?
+2. Field highlights come from the existing validation flags (total, date, vendor, currency). True per-field confidence is needs-backend.
+3. Library merges RAG documents + invoices into one list with a status pill. Persisted "Reading" state and Retry are needs-backend (extraction is synchronous today).
+4. Statement imports history (bank, period, counts) is needs-backend; import returns counts but nothing is stored. PDF statements too: CSV only today.
+5. List view over grid: calmer and scans by amount. Grid toggle kept for photo receipts.
Spending: decisions + questions
+1. Categories as horizontal bars, not a donut: rent (40%) would swallow a donut. Click a row to filter the table; Clear undoes.
+2. Calendar + reminders moved out to Bills. Spending has no due dates.
+3. Month bars live in the hero inset; for 3M/YTD they become the main chart and the cumulative line hides.
+4. "First time at merchant" is not a finding kind yet (backend has duplicate / spike / price_rise / review). Add it, or drop the card?
+5. Export: CSV of the filtered list only, or the whole period?
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md b/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md
new file mode 100644
index 0000000..b956db2
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md
@@ -0,0 +1,181 @@
+# UI revamp, Phase 5: Home v2 and the inner pages
+
+Addendum to `2026-10-20-ui-revamp-design.md` (IA, Sky palette, motion, zero layout-shift rule and §12 backlog still
+apply). Approved by the owner on 2026-10-22 from the mockups in `2026-10-22-phase5/` (open the `.html` files in a
+browser; `shell.css` is the shared style). The dark "decisions + questions" box on each mockup is a review aid, not UI.
+
+## 1. Why
+
+The owner ran Phases 0–4 locally and found the app still felt like the old one with a new coat: Home didn't resemble
+the inspiration (Origin's app home), labels used a generic all-caps mono font, sidebar groups weren't marked, and some
+chips sent text to the AI when they should have done something. The inner pages (Spending, Bills, Receipts) are still
+the old pages stacked inside new URLs, and Budget has no page. Phase 5 makes every main page look and behave like one
+calm product: "not too pushy with info, not too implicit".
+
+The shell fixes (labelled groups, collapse in the sidebar header, no desktop top bar, `.label-ui` sentence-case labels,
+upload chip in Ask) shipped first in PR #96 and are the baseline for this phase.
+
+## 2. Goals, non-goals, anti-goals
+
+**Goals**
+- Home v2 and four pages (Spending, Bills & subscriptions, Budget, Receipts & statements) built to the mockups.
+- Budget becomes a page with a **Plan** sidebar group (Plan · Budget), between Track and Records.
+- One shared page skeleton so the pages can't drift: header (title, one-line serif brief, quiet actions), cards,
+ labels, number style, the smooth area chart, the calendar grid.
+- The cheap backend pieces each page needs (§5), and the §12 items tagged Phase 5 (§7).
+- Zero layout shift: every range/filter/month change, hover-edit and dialog keeps its box (bounding-box or CLS tests,
+ as in Phases 0–4).
+
+**Non-goals**
+- Forecast page (Plan · Forecast) and Phase 6 performance work.
+- Smarter "Lumen found" and budget drafts (median/trimmed mean, one-off detection): stays §12 *Later*, gated on an eval
+ set first (owner request). The draft UI is built so a "basis" sentence can carry it later.
+- Paid-vs-overdue matching of bills to payments, per-field extraction confidence, "unused" (usage) detection, PDF bank
+ statements, async extraction states (Reading / Failed + Retry): deferred, see §6.
+
+**Anti-goals** (would be a failure even if it "works")
+- Showing anything the data can't back: a deferred element is **hidden**, never faked or filled with placeholders.
+- Loud saturated blue buttons, all-caps mono labels, a dark dialog inside the light app.
+- A chip or button whose label is an action ("Upload…", "Mark as cancelled") that sends text to the chat instead.
+- Layout jumps when data loads, filters change or a row enters edit mode.
+
+## 3. Shared skeleton (all pages)
+
+As in `home.html`:
+- **Header row:** sans 26px `h1`, quiet actions on the right (`Upload` as a light button, icon buttons for export/help),
+ leaving room for the floating account menu (`md:pr-12`). Below it a one-line Newsreader brief built from real numbers
+ (same style as Home's brief; one emphasised phrase in italic accent).
+- **Cards:** white, hairline border, 16px radius, very soft shadow; title row = `.label-ui` + optional `›` link + optional
+ small `+` action on the right. One strong number per card, tabular numerals.
+- **Charts:** smooth (monotone/Catmull-Rom) area line in accent with a soft gradient, previous period dashed grey,
+ forecast dotted light accent, today dot, bill pins as small round markers on the x-axis; y labels right-aligned;
+ sparse x ticks (no overlap at any width; fixes the "01 OC09 OCT" overlap on phones).
+- **Range/period chips** centred under the chart (Home) or in a segmented control under the header (pages).
+- **Buttons:** `.btn` quiet (light, hairline) or dark primary for the one main action of a page. The shadcn `Button`
+ default blue is not used on these pages; Export/Upload get restyled (§12).
+- **Dialogs:** light (Sky tokens), including the upload dialog.
+
+Implementation is the builder's call (a `PageHeader` + a few card primitives in `components/page/` is the expected
+shape); the rule is that the five pages use the same pieces.
+
+## 4. Pages
+
+### 4.1 Home v2 (`home.html`)
+Greeting row; brief; **hero card "Spent in ›"** (big number, delta vs previous period with sign colour, "heading
+for ₹X" from the forecast, legend, `+` = upload, chart, range chips 1W/1M/3M/YTD); then a two-column grid:
+- left: **", day by day"** card = month total + calendar with the amount in each day cell (heat shade, today
+ outlined, future days dashed with known bills named) beside **Recent transactions** (5, logo chip, date · category,
+ amount, "See all" → Spending); **Lumen found** as a quiet list (icon, headline, detail, two action links that *do*
+ things: e.g. Review duplicate → the review item, See in Spending → filtered Spending, Ask why → Ask with the question
+ filled).
+- right: **Budget in ** (spent of total, green bar + %, top 4 category bars; no budget → the draft promo linking to
+ the Budget page); **Coming up** (next 4 bills with date chips); Activity stays only if it fits, else moves to
+ Receipts.
+Everything on Home already has data except per-category budget bars (exists via `/api/budget`) — no new endpoint.
+
+### 4.2 Spending (`spending.html`)
+Answers "where did my money go, and is anything off?". Header + period control (This month / Last month / 3M / YTD /
+Custom) + "Compare with " toggle + date range. Hero: total, delta, cumulative line vs previous; inset "Month
+by month" bars (last 6 months, average). For 3M/YTD the bars become the main chart (owner may revisit).
+Row 2: **By category** (horizontal bars: amount, share, change vs previous; click a row = filter the table, "Clear";
+"N transactions still need a category → Sort them now") and **Lumen noticed** (findings from `/api/home/summary`
+kinds: duplicate, spike, price_rise, review; each with real actions + "Ask Lumen why"). The mockup's "First time at a
+merchant" card is **dropped** (no such finding kind).
+Row 3: **Transactions** table (search, category chip filter, source filter, date/merchant/category pill/source/amount,
+hover pencil → category menu; "Always file here" only if a rule store exists — otherwise omit) and **Top
+merchants**. Export = CSV of the current filtered view.
+Removes the duplicated payment calendar + reminders and the two `
`s (§12).
+
+### 4.3 Bills & subscriptions (`bills.html`)
+Answers "what's due, what recurs, what can I cut?". Header + month switcher. Three tiles: due in next 30 days, recurring
+per month, "subscriptions that look stopped" (renamed from "you may not use": we only know charges stopped, not usage).
+**Month calendar** with bill names/amounts on due days (states shown: upcoming, today; paid/late/overdue states are
+deferred) beside an **Upcoming** agenda grouped This week / Next week / Later with group totals. **Subscriptions table**
+(tabs Subscriptions / Bills / All): logo chip, cadence, last charged, next charge, amount, pills for price rise and
+"looks stopped". Actions: **Mark as cancelled**, **Not a bill** (both saved, §5), **How to cancel** → opens Ask with
+"How do I cancel ?" typed but not sent (owner choice). "Set reminder" and the overdue strip are deferred.
+Bills owns the calendar and reminders; Spending no longer shows them.
+
+### 4.4 Budget (`budget.html`) — new page, sidebar Plan · Budget
+Answers "am I on track, and what should my plan be?". Header + month switcher, "Edit budget" (quiet) and "Let Lumen
+draft" (dark). Hero: spent of total, green bar with an even-pace tick, left / days left / safe per day (= left ÷ days
+left; subtracting known upcoming bills is the builder's call if the bills data is already loaded), pace chart (actual
+vs straight line to the budget). **By category** table: budget, spent, left, bar (green / amber ≥90% / red over), hover
+→ inline edit of the amount (Enter saves, Esc cancels, no row height change), footer "categories add up to X, Y
+unassigned" + Add category. **Draft panel on the page** (owner choice; replaces the dialog): each line shows proposed
+amount, old limit, basis sentence, a small month-bars glyph, Approve / Skip with the category in the accessible name,
+decided state in place, "Approve remaining N". Home's promo links here. **Last 6 months**: budget outline vs actual
+bars (green under / red over), current month as "so far" + forecast.
+
+### 4.5 Receipts & statements (`receipts.html`)
+Answers "what have I given Lumen, and what needs my OK?". Header actions Upload / Import statement / Connect email.
+**Needs your review** row of cards (only when the queue is non-empty; count = sidebar badge): extracted fields with the
+doubtful ones highlighted from existing validation flags (`total_mismatch`, `duplicate`, `bad_date`, `unknown_vendor`,
+`no_currency`), a plain-words reason, Approve / Edit / Reject (duplicate: "Keep both" / Compare / Reject). This replaces
+the old Review tab. **Library** list (filter chips All / Receipts / Invoices / Statements / Contracts, search, list view
+default; grid toggle optional): file icon, title, came from (Upload / Gmail / Import), date, amount, status (Needs
+review, Done; Reading/Failed deferred). Right column: **Statement imports** history (bank/file, period, lines, new /
+matched / duplicates / credits skipped bar) and **From your inbox** (Gmail status, Check now, Pause). **Light upload
+dialog**: choose Receipts & invoices vs Documents, drop zone, accepted types, per-file progress rows, pointer to Import
+statement for CSVs.
+
+## 5. Backend in scope ("build what's cheap", owner choice)
+- `GET /api/bills/recurring`: wraps `ai/recurring.find_recurring` (vendor, cadence, typical amount, last/next date,
+ bill vs subscription, price change, looks-stopped) minus vendors the user dismissed; plus
+ `POST /api/bills/recurring//state` with `cancelled | not_a_bill | active` saved per user (new small table).
+ Home's bills card and the Bills page both use it.
+- `GET /api/budget?month=YYYY-MM` and `GET /api/budget/history?months=6` (budget vs actual per month). Daily cumulative
+ series for the pace chart comes from the transactions the page already fetches (no new endpoint unless that is
+ heavy).
+- Statement import history: persist one row per import (file name, account/bank label if given, period from the
+ rows, counts already returned by the import); `GET /api/statements/imports`.
+- §12 backend items: `POST /chat` creates the thread and its messages in one commit; the lost approval race returns a
+ generic "This proposal was already decided" (re-read status after a failed claim); `basis.months` lists only months
+ that had spend.
+- Everything new: per-user scoping, rate limits like the neighbouring routes, tests first.
+
+**Decisions made after the builder check (agent's calls, owner can overrule):**
+- **Budgets are versioned by month.** Today `Budget` is one `monthly_limit` per category. Add an `effective_from`
+ month (migration; existing rows get the month they were created). A month's budget = for each category, the latest
+ row with `effective_from <= month`. Editing (inline or Edit budget) writes a row effective from the **current**
+ month, so past months keep what they were; approving a draft "for November" writes rows effective from November.
+ Past months are read-only in the UI. History therefore compares each month with the limits that applied then; months
+ before any budget show actual spend only (no outline). `User.monthly_budget` stays the overall total fallback.
+- **Saved recurring states are keyed by the normalised vendor** (`ai/recurring._norm`, the same key `find_recurring`
+ groups by); the route takes the display name and normalises it. `not_a_bill` hides the vendor from every recurring
+ list (a "Hidden" tab or footer link lets you undo). `cancelled` keeps the row in a dimmed "Cancelled" group at the
+ bottom; if a charge from that vendor appears **after** the cancel date, the state is ignored and the row shows a
+ "charged again after you cancelled" pill.
+- **Library "Statements" filter** lists the new import-history rows as library items (type Statement, came from
+ Import, no file to open since CSVs aren't kept). The chip shows only when there is at least one.
+- **Import history starts with the new table** (older imports don't appear). Counts are stored as the import returned
+ them; a re-import of the same file is a new history row and its "matched" count may repeat, labelled "at import".
+
+## 6. Deferred (hidden until built; recorded in §12)
+Paid / paid-late / overdue matching per bill cycle and the overdue strip; "Set reminder"; usage-based "unused";
+quarterly cadence detection; credit-card bill due dates from statements; per-field extraction confidence; persisted
+Reading/Failed + Retry; PDF statement import; "Approve all high-confidence"; category rules ("Always file X here");
+median/outlier budget drafts (eval set first); Spending's "first time at a merchant" finding.
+
+## 7. §12 items folded in
+Duplicate calendar/reminders and the double `
` on Spending; Export/Upload restyle + light upload dialog; draft
+buttons' accessible names + `basis.months`; `POST /chat` single commit; generic 409 text; thread rail (disable
+Keep/Delete while busy, focus "Keep" when confirm opens, trash visible on touch); AskPalette rendered outside
+`` so Ctrl+K works on tablets with the sheet closed.
+
+## 8. Delivery
+Stacked PRs into `refactor`, each green and checkable on its own, in this order (later ones may depend on earlier):
+1. **5a** shared skeleton + Home v2 + Export/Upload restyle + light upload dialog + Ask/§12 frontend fixes.
+2. **5b** Spending.
+3. **5c** Bills (+ recurring API and saved states).
+4. **5d** Budget page + Plan group (+ month/history API, draft panel, §12 draft items).
+5. **5e** Receipts & statements (+ import history API, review cards replacing the Review tab).
+Each PR: TDD per task, layout-shift tests for its controls, a manual-check list for the owner, a STORY entry, and the
+codebase guide updated. One whole-branch review at the end of the phase; Critical/Important fixed test-first, Minors to
+§12.
+
+## 9. Left to the builder
+Component boundaries and file layout; chart implementation (existing chart code vs a small SVG helper); exact
+breakpoints (pages must work at phone width with no sideways scroll); empty and loading states in the established
+skeleton style; whether Activity stays on Home; how the brief sentence is phrased per page as long as it uses real
+numbers only.
From d13376c822ecff9fca0b0888e9103f48ec81f4df Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Thu, 22 Oct 2026 01:28:26 +0530
Subject: [PATCH 02/18] docs: Phase 5 implementation plan (5a detailed, 5b-5e
outlined)
---
.../plans/2026-10-22-ui-revamp-phase5.md | 203 ++++++++++++++++++
1 file changed, 203 insertions(+)
create mode 100644 docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md
diff --git a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md
new file mode 100644
index 0000000..6143227
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md
@@ -0,0 +1,203 @@
+# UI revamp Phase 5 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build Home v2 and the Spending, Bills & subscriptions, Budget and Receipts & statements pages to the approved mockups, on one shared page skeleton, with the cheap backend each page needs.
+
+**Architecture:** A small set of shared page pieces (`components/page/*`, `lib/chart.ts`) is built first and used by every page so they can't drift. Each sub-phase (5a–5e) is one stacked PR into `refactor`. Pages keep fetching through `lib/api/client.ts`; new backend routes are FastAPI routers in `backend/api/` like `budget.py`.
+
+**Tech Stack:** Next.js (app router), Tailwind v4 + shadcn tokens (Sky theme), motion, Playwright e2e; FastAPI + SQLAlchemy (Flask-SQLAlchemy models), pytest.
+
+**Spec:** `docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md` (mockups in `docs/superpowers/specs/2026-10-22-phase5/`), parent `docs/superpowers/specs/2026-10-20-ui-revamp-design.md`.
+
+## Global Constraints
+
+- Labels: `.label-ui` (Funnel Sans, sentence case). No all-caps mono, no shadcn default blue `Button` on these pages, no dark dialog in the app.
+- Nothing the data can't back is shown: deferred elements are hidden, never faked (spec §2, §6).
+- A control whose label is an action does that action; it never sends its text to the chat.
+- Zero layout shift: every range/filter/month change, hover-edit and dialog keeps its box; each gets a bounding-box test.
+- Pages work at 375px wide with no sideways scroll; leave `md:pr-12` room for the floating account menu in header rows.
+- Inside `.bui` (Ask) use bui tokens (`bg-hover`, `bg-surface`, `border-line`, `text-ink-2`), never shadcn `accent`.
+- Git: branch names without "claude", no attribution lines in commits or PRs; PRs into `refactor` on coderconnoisseur/Lumen.
+- Backend test env: `OPENROUTER_API_KEY=test-key SECRET_KEY=test-secret-key-for-ci-only SUPABASE_URL=https://example.supabase.co python -m pytest -q` from `backend/`.
+- Frontend lint/build env: `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY=e2e-dummy NEXT_PUBLIC_BACKEND_URL=http://127.0.0.1:9`.
+
+## Review Focus
+
+1. **Empty account** (no transactions, no budget, no bills): every Home card shows its empty state at its loading height; no NaN, "₹0 of ₹0", or a chart line at zero. Pinned by Task 3 and Task 6 empty-state tests.
+2. **Phone width (375px)**: chart ticks never overlap (the reported "01 OC09 OCT"), calendar cells keep amounts readable, header actions wrap under the title without covering the account menu. Pinned by Task 2 (`pickTicks`) and Task 3 / Task 1 bounding-box tests.
+3. **Forecast missing or outside the month** (backend forecasts from the last transaction date): no "heading for" text and no dotted line. Pinned in Task 3.
+4. **Ask prefill** (`/ask?draft=`) must never send by itself, also after a reload or when Ask is already mounted. Pinned in Task 5.
+5. **Ask busy state**: Keep/Delete in the thread rail are disabled while an answer is in flight; deleting the open thread mid-answer must not orphan the answer. Pinned in Task 8.
+
+---
+
+## Phase 5a — shared skeleton, Home v2, dialogs, Ask/§12 fixes (detailed)
+
+Branch: `ui-revamp/phase5-pages` (contains the spec commit; stacked on `ui-revamp/polish`).
+
+### Task 1: Page header, page card, quiet buttons
+
+**Files:**
+- Create: `frontend/src/components/page/page-header.tsx`, `frontend/src/components/page/page-card.tsx`
+- Modify: `frontend/src/components/ui/button.tsx` (variants), `frontend/src/app/(app)/home/_components/card.tsx` (delete; callers import `PageCard`), all Home cards that import `HomeCard`, `frontend/src/app/(app)/home/homeContent.tsx`
+- Test: `frontend/e2e/home.spec.ts`
+
+**Interfaces:**
+- Produces: `PageHeader({ title: ReactNode; brief?: ReactNode; actions?: ReactNode; children?: ReactNode })` — renders `
`, actions right-aligned in the same row with `md:pr-12`, brief below in `font-serif text-[19px] text-ink-2`-equivalent (`text-foreground/80`), `children` = a control row under the brief.
+- Produces: `PageCard({ title: ReactNode; href?: string; action?: ReactNode; extra?: ReactNode; loading?: boolean; skeletonHeight: number; className?; "data-testid"?; children })` — same contract as today's `HomeCard` (skeleton at final height, `minHeight` after), restyled: `rounded-2xl border border-border/70 bg-card px-5 py-[18px] shadow-[0_1px_2px_rgba(28,28,26,.04),0_4px_16px_-8px_rgba(28,28,26,.08)]`; title row = `.label-ui` title, `›` after it when `href`, `extra` (legend, pill) then `action` at the right.
+- Produces: `Button` variants `quiet` (`border border-border bg-card/70 text-foreground/80 hover:bg-card hover:text-foreground backdrop-blur`) and `ink` (`bg-foreground text-background hover:bg-foreground/90`); size `pill-icon` (`size-[34px] rounded-full`).
+
+- [ ] **Step 1: Write the failing test** `home header keeps clear of the account menu` in `e2e/home.spec.ts`: at 1440×900 and 1024×800, `signIn`, goto `/home`; the bounding box of `getByRole("button", { name: "Account menu" })` does not intersect the boxes of `getByRole("button", { name: /upload/i })` and `getByRole("button", { name: "Export" })`; `getByRole("heading", { level: 1 })` has `font-size` `26px`.
+- [ ] **Step 2: Run** `npx playwright test e2e/home.spec.ts -g "account menu"` — Expected: FAIL (font-size 24px / names differ).
+- [ ] **Step 3: Implement** `PageHeader`, `PageCard`, the button variants; switch Home's greeting row to `PageHeader` (title = greeting, brief = ``), replace every `HomeCard` import with `PageCard` and delete `card.tsx`.
+- [ ] **Step 4: Run** the new test and the whole `e2e/home.spec.ts` — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: shared page header and card for the Phase 5 pages`.
+
+### Task 2: Chart helpers
+
+**Files:**
+- Create: `frontend/src/lib/chart.ts`
+- Test: `frontend/e2e/home-math.spec.ts` (pure-function tests live here)
+
+**Interfaces:**
+- Produces: `smoothPath(points: [number, number][]): string` — SVG path through every point; monotone cubic (Fritsch–Carlson) so a running total never dips or overshoots between points; 0 points → `""`, 1 point → `"M x y"`.
+- Produces: `pickTicks(candidates: { i: number; x: number; label: string; keep?: boolean }[], minGap: number): typeof candidates` — keeps `keep` ticks (today, first, last) and drops any other tick closer than `minGap` px to a kept one, left to right.
+
+- [ ] **Step 1: Write failing tests** in `e2e/home-math.spec.ts`:
+ - `smoothPath([[0,10],[10,20],[20,20],[30,40]])` starts with `"M0 10"`, contains `"20 20"` and ends with `"30 40"`.
+ - Sampling the cubic segments of `smoothPath([[0,0],[10,100],[20,100],[30,101]])` at t=0.25/0.5/0.75 never gives y > 101 or a y lower than the previous sample (monotone, no overshoot). Parse the `C` commands to compute the samples in the test.
+ - `pickTicks` with x = 0, 20, 40, 300, 310 (`keep` on 0, 300, 310) and `minGap: 48` returns labels for 0, 300, 310 only.
+- [ ] **Step 2: Run** `npx playwright test e2e/home-math.spec.ts` — Expected: FAIL (module not found).
+- [ ] **Step 3: Implement** both in `lib/chart.ts`.
+- [ ] **Step 4: Run** — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: smooth monotone chart path and tick picking`.
+
+### Task 3: Hero spend card v2
+
+**Files:**
+- Modify: `frontend/src/app/(app)/home/_components/spend-card.tsx`
+- Test: `frontend/e2e/home.spec.ts`
+
+**Interfaces:**
+- Consumes: `PageCard`, `smoothPath`, `pickTicks`; existing props of `SpendCard` plus `onUpload: () => void`.
+- Title (`TITLE` map): `"Spent in "` for 1M (month from `period.start`), `"Spent this week"`, `"Spent this quarter"`, `"Spent in "`; `href="/spending"`.
+
+- [ ] **Step 1: Write failing tests** in `e2e/home.spec.ts` (use the existing home fixture with transactions and forecast):
+ - `hero shows delta, percent and the forecast total`: text matches `/\+₹[\d,]+ \(\d+%\) vs \w+ by the \d+(st|nd|rd|th)/` and `/heading for ₹[\d,]+/`.
+ - `no forecast, no heading-for`: fixture forecast dates all in an earlier month → no `heading for` text and no `[data-series="forecast"]`.
+ - `ticks never overlap at phone width`: viewport 375×812; the bounding boxes of all `[data-testid="tick"]` are pairwise non-overlapping.
+ - `the + button opens upload`: click `getByRole("button", { name: "Upload a receipt" })` inside `[data-testid="spend-card"]` → a dialog is visible.
+ - keep the existing range-change bounding-box test green.
+- [ ] **Step 2: Run** `npx playwright test e2e/home.spec.ts -g "hero|ticks|\\+ button"` — Expected: FAIL.
+- [ ] **Step 3: Implement**: number 36px medium; delta line `+₹X (Y%) vs by the ` (bad colour when up, good when down; for 3M/YTD "vs last period"/"vs "); `· heading for ` only when the forecast is drawn; legend (Month / Previous / Forecast) in `extra`; `+` icon button (`aria-label="Upload a receipt"`) in `action`; paths via `smoothPath`; chart height 200px; bill pins become 22px round markers on the x-axis with the vendor's first letter and `title={vendor}`; ticks via `pickTicks` with `data-testid="tick"`, `minGap` 48; range chips centred below.
+- [ ] **Step 4: Run** `e2e/home.spec.ts` — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: Home hero card in the Origin style`.
+
+### Task 4: "Month, day by day" calendar + recent transactions
+
+**Files:**
+- Modify: `frontend/src/app/(app)/home/_components/calendar-card.tsx`, `frontend/src/lib/home-math.ts` (export `ordinal(n)` if Task 3 didn't)
+- Test: `frontend/e2e/home.spec.ts`
+
+**Interfaces:**
+- Consumes: `PageCard`; new prop `reminders: Reminder[]`, `today: Date`.
+- Produces: `vendorChip(name: string): { initials: string; hue: number }` in `lib/home-math.ts` (2 initials, stable hue from the name) — reused by Spending/Bills.
+
+- [ ] **Step 1: Write failing tests**: `calendar marks today and names future bills` — the cell for today's date has `data-today="true"`; a fixture reminder dated after today puts its vendor text in that day's cell; future cells have `data-future="true"`; the card title links to `/spending`; `See all` in Recent transactions links to `/spending`. `vendorChip("Swiggy Instamart")` → `initials: "SI"` and the same hue on two calls (home-math.spec).
+- [ ] **Step 2: Run** — Expected: FAIL.
+- [ ] **Step 3: Implement** per `home.html`: title `", day by day"`, month total + "across N days" (days with spend), 50px cells with day number top and short amount bottom, heat shade as today, today outlined in primary, future cells dashed with up to one bill name; recent rows with a 28px initials chip from `vendorChip`.
+- [ ] **Step 4: Run** — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: day-by-day calendar with amounts and bill names`.
+
+### Task 5: Lumen found as a list, Ask prefill
+
+**Files:**
+- Modify: `frontend/src/app/(app)/home/_components/lumen-found.tsx`, `frontend/src/app/(app)/ask/chatbotContent.tsx`, `frontend/src/components/ask/prompt-bar.tsx` (accept an initial value if it doesn't)
+- Test: `frontend/e2e/home.spec.ts`, `frontend/e2e/ask.spec.ts`
+
+**Interfaces:**
+- Produces: `/ask?draft=` — opens a fresh chat with the composer filled with ``, focused, **not sent**; the URL is cleaned (`router.replace("/ask")`) so a reload doesn't refill. Used by Lumen found "Ask why", Spending, and Bills "How to cancel".
+- Produces: `askWhy(item: FoundItem, money): string` in `lumen-found.tsx` — `"Why " + headline (filled, lower-cased first letter, no trailing period) + "?"`.
+
+- [ ] **Step 1: Write failing tests**:
+ - home: `Lumen found is a list with doing links` — each item is a row with an icon, headline, detail, a primary link to `item.href` and an "Ask why" link whose href starts with `/ask?draft=`.
+ - ask: `draft prefills without sending` — goto `/ask?draft=Why%20is%20Food%20up%3F`; composer value is `Why is Food up?`; no `/chat` POST happened (count requests in the fixture route); URL becomes `/ask`; reload → composer empty.
+- [ ] **Step 2: Run** — Expected: FAIL.
+- [ ] **Step 3: Implement** list layout per `home.html` (32px tinted icon by `kind`: duplicate=bad, spike=warn, price_rise/review=primary; title row pill "N to look at"); `?draft=` handling next to the existing `?q=` effect, guarded by the same `lastQ`-style ref.
+- [ ] **Step 4: Run** — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: Lumen found list with real actions; /ask?draft= prefills`.
+
+### Task 6: Budget card v2, Coming up, Home layout
+
+**Files:**
+- Modify: `frontend/src/app/(app)/home/_components/budget-card.tsx`, `bills-card.tsx`, `homeContent.tsx`
+- Test: `frontend/e2e/home.spec.ts`
+
+**Interfaces:**
+- Consumes: `BudgetState` from `lib/api/client.ts` (unchanged), `PageCard`, `vendorChip`.
+
+- [ ] **Step 1: Write failing tests**:
+ - `budget card shows spent of total, percent and top categories`: with a fixture budget of 4+ categories → text `/₹[\d,]+ of ₹[\d,]+/`, a `%` label, exactly 4 `[data-testid="budget-cat"]` rows ordered by spent desc; an over-budget category row has `data-over="true"`.
+ - `empty account Home`: fixture with no transactions/budget/reminders → no text matching `/NaN|of ₹0\b/`; every card's box height equals its loading skeleton height ±1px (record during loading via a delayed route, compare after).
+ - `coming up lists the next four bills`: 6 upcoming reminders → 4 rows with date chips (weekday + day).
+ - existing draft-budget tests stay green (promo still opens the draft dialog in 5a; 5d moves it).
+- [ ] **Step 2: Run** — Expected: FAIL.
+- [ ] **Step 3: Implement** per `home.html`: layout = hero full width, then `grid lg:grid-cols-[minmax(0,1fr)_380px]`: left = calendar card, Lumen found; right = Budget in , Coming up, Activity (kept at the bottom for now). Budget: big spent of total, green bar + %, then top 4 category bars (green / amber ≥90% / red over).
+- [ ] **Step 4: Run** full `e2e/home.spec.ts` — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: Home v2 layout, budget and coming-up cards`.
+
+### Task 7: Upload and Export restyle, light upload dialog
+
+**Files:**
+- Modify: `frontend/src/components/uploadDialog.tsx`, `frontend/src/components/exportDialog.tsx`, `homeContent.tsx`
+- Test: `frontend/e2e/home.spec.ts`
+
+**Interfaces:**
+- Produces: `DialogDemo` gains `trigger?: ReactNode` (default: ``) and `open`/`onOpenChange` passthrough so the hero `+` (Task 3) and `?upload=1` open the same dialog. `ExportDialog` default trigger = `Button variant="quiet" size="pill-icon" aria-label="Export"` with a download icon.
+
+- [ ] **Step 1: Write failing tests**: `upload dialog is light` — open it; computed `background-color` of `[role="dialog"]` has luminance > 0.9 (parse rgb); no element inside has class matching `neutral-9|#0b0612`. `Home buttons are quiet` — the Upload button's computed background is not `rgb(37, 99, 235)` (blue-600).
+- [ ] **Step 2: Run** — Expected: FAIL.
+- [ ] **Step 3: Implement**: dialog on Sky tokens (`bg-card`, `border-border`, `text-foreground/muted-foreground`), drop zone `border-dashed border-border bg-muted/40`, file rows `bg-muted/50`; keep all upload logic untouched.
+- [ ] **Step 4: Run** — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: quiet Upload/Export and a light upload dialog`.
+
+### Task 8: Ask and chat §12 fixes
+
+**Files:**
+- Modify: `frontend/src/app/(app)/ask/thread-rail.tsx`, `frontend/src/components/app-sidebar.tsx` (render `AskPalette` outside ``; lift `paletteOpen` into `app-shell.tsx` or a tiny context), `backend/routes/chat.py` (`_thread_for` + `_save_exchange` in one commit), `backend/api/agent.py` (409 text)
+- Test: `frontend/e2e/ask.spec.ts`, `frontend/e2e/shell.spec.ts`, `backend/tests/test_chat_threads.py` (or the existing chat test module), `backend/tests/test_agent_api.py` (existing proposals tests)
+
+**Interfaces:**
+- Backend: `_thread_for(user_id, thread_id, question)` no longer commits; the `/chat` handler commits once after `_save_exchange`. On a lost claim, both 409s say `"This proposal was already decided."`.
+
+- [ ] **Step 1: Write failing tests**:
+ - e2e ask: `rail actions wait for the answer` — with a delayed `/chat` route, while pending the thread's Delete button is disabled; opening the delete confirm moves focus to "Keep"; at 375px with the history sheet open the trash button is visible without hover.
+ - e2e shell: `ctrl+k works on a tablet with the sheet closed` — viewport 768×1024 (below the md sheet breakpoint if 768 is still sheet; else 767), press Control+k → palette dialog visible.
+ - backend: `test_chat_save_failure_leaves_no_empty_thread` — monkeypatch the message save to raise; after the failed POST, the user has 0 threads.
+ - backend: `test_lost_claim_says_already_decided` — approve twice concurrently (or claim then approve) → 409 with detail `"This proposal was already decided."`.
+- [ ] **Step 2: Run** them — Expected: FAIL.
+- [ ] **Step 3: Implement** the four fixes.
+- [ ] **Step 4: Run** `e2e/ask.spec.ts e2e/shell.spec.ts` and backend `pytest -q` — Expected: PASS.
+- [ ] **Step 5: Commit** `fix: Ask rail busy/focus/touch, palette outside the sheet, one-commit chat save, clear 409`.
+
+### Task 9: 5a docs, full checks, PR
+
+- [ ] **Step 1:** STORY.md entry `## 32.` (owner review round + Home v2: what was flagged, what changed, test counts), CODEBASE-GUIDE: `components/page/*`, `lib/chart.ts`, `/ask?draft=`; spec §12: tick the Phase 5 items done in 5a.
+- [ ] **Step 2:** Run backend `pytest -q`, `pytest -q -m eval`; frontend `npm run lint`, `npm run build` (dummy env), `npm run test:e2e`. Expected: all green.
+- [ ] **Step 3:** Screenshot `/home` at 1440 and 375 with seeded fixture data (throwaway `e2e/tmp/shot.spec.ts`) and compare with `home.html` side by side; fix visible drift.
+- [ ] **Step 4:** Push `ui-revamp/phase5-pages`, open PR into `refactor` (or stacked on `ui-revamp/polish` until #96 merges) with manual checks: Home matches the mockup on your seeded account; hero `+` and Upload open the same light dialog; "Ask why" fills Ask without sending; phone width has no overlapping ticks.
+
+---
+
+## Phase 5b — Spending (outline; detail when reached)
+Backend: none expected (transactions, categories, `/api/home/summary` findings exist); confirm category-update endpoint for the hover edit. Tasks: period control + compare toggle (layout-shift test); hero (cumulative + month bars inset; 3M/YTD bars as main chart); By category bars with click-to-filter (`?category=`); Lumen noticed (summary findings, no "first time" card); Transactions table (search, filters, hover category edit, `vendorChip`), Top merchants; Export CSV of the filtered view; remove the stacked old pages (single `
`, no calendar/reminders). STORY 33.
+
+## Phase 5c — Bills & subscriptions (outline)
+Backend (TDD): `RecurringState` table (`user_id`, `vendor_key`, `state`, `changed_at`); `GET /api/bills/recurring` (wraps `find_recurring`, applies states: `not_a_bill` hidden, `cancelled` dimmed unless charged after `changed_at`); `POST /api/bills/recurring/state {vendor, state}`; Home's Coming up switches to it. Frontend: tiles, month calendar + Upcoming agenda (This week / Next week / Later), subscriptions table with tabs and actions (Mark as cancelled, Not a bill + Hidden undo, How to cancel → `/ask?draft=How do I cancel ?`). STORY 34.
+
+## Phase 5d — Budget page (outline)
+Backend (TDD): `Budget.effective_from` (migration + create_all column list; existing rows → created month); `get_budget(month)` = latest row per category with `effective_from <= month`; `GET /api/budget?month=YYYY-MM`, `GET /api/budget/history?months=6`; edits write rows effective from the current month; draft approval writes rows effective from the draft's month; `basis.months` only months with spend; draft buttons' accessible names include the category. Frontend: `/budget` page + sidebar Plan group; hero with even-pace tick and pace chart; category table with inline edit (no row height change); draft panel on the page (Home promo links here); last 6 months bars. STORY 35.
+
+## Phase 5e — Receipts & statements (outline)
+Backend (TDD): `StatementImport` table + write on import; `GET /api/statements/imports`. Frontend: header actions; Needs-your-review cards from the review queue with highlights from validation flags (replaces the Review tab); library list with filter chips (Statements = import rows) and search; Statement imports card; From your inbox card; Activity moves here from Home if it no longer fits. Old `/documents`, `/import`, `/review` routes redirect into `/receipts`. STORY 36, then the whole-branch review for Phase 5.
From ff30d4ffa3c43bec1336bc7b827fba33dba9a461 Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Thu, 22 Oct 2026 01:46:34 +0530
Subject: [PATCH 03/18] feat: shared page header and card for the Phase 5 pages
---
frontend/e2e/home.spec.ts | 16 ++++++++++++
.../(app)/home/_components/activity-card.tsx | 6 ++---
.../app/(app)/home/_components/bills-card.tsx | 6 ++---
.../src/app/(app)/home/_components/brief.tsx | 6 ++---
.../(app)/home/_components/budget-card.tsx | 6 ++---
.../(app)/home/_components/calendar-card.tsx | 6 ++---
.../(app)/home/_components/lumen-found.tsx | 6 ++---
.../app/(app)/home/_components/spend-card.tsx | 6 ++---
frontend/src/app/(app)/home/homeContent.tsx | 24 ++++++++---------
.../page/page-card.tsx} | 25 +++++++++++++-----
frontend/src/components/page/page-header.tsx | 26 +++++++++++++++++++
frontend/src/components/ui/button.tsx | 4 +++
12 files changed, 96 insertions(+), 41 deletions(-)
rename frontend/src/{app/(app)/home/_components/card.tsx => components/page/page-card.tsx} (52%)
create mode 100644 frontend/src/components/page/page-header.tsx
diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts
index 9caaceb..308af4f 100644
--- a/frontend/e2e/home.spec.ts
+++ b/frontend/e2e/home.spec.ts
@@ -333,3 +333,19 @@ test("a replaced draft says so", async ({ page }) => {
await page.waitForTimeout(300);
expect(approvals).toBe(1);
});
+
+const overlaps = (a: { x: number; y: number; width: number; height: number }, b: typeof a) =>
+ a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
+
+test("home header keeps clear of the account menu", async ({ page }) => {
+ for (const [width, height] of [[1440, 900], [1024, 800]]) {
+ await page.setViewportSize({ width, height });
+ await openHome(page);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveCSS("font-size", "26px");
+ const menu = (await page.getByRole("button", { name: "Account menu" }).boundingBox())!;
+ for (const name of [/upload/i, /^export$/i]) {
+ const box = (await page.getByRole("button", { name }).first().boundingBox())!;
+ expect(overlaps(menu, box), `${String(name)} at ${width}px`).toBe(false);
+ }
+ }
+});
diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx
index 5287f27..7475e7a 100644
--- a/frontend/src/app/(app)/home/_components/activity-card.tsx
+++ b/frontend/src/app/(app)/home/_components/activity-card.tsx
@@ -2,13 +2,13 @@ import Link from "next/link";
import type { ActivityItem } from "@/lib/api/client";
import { useMoney } from "@/lib/format";
import { fill, relativeTime } from "@/lib/home-math";
-import { HomeCard } from "./card";
+import { PageCard } from "@/components/page/page-card";
// What happened lately: statement imports, receipts read, your decisions (GET /api/home/summary).
export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; now: Date; loading: boolean }) {
const money = useMoney();
return (
-
+
{items.length === 0 ? (
>
)}
-
+
);
}
diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx
index a10fbfa..b8bbe03 100644
--- a/frontend/src/app/(app)/home/_components/spend-card.tsx
+++ b/frontend/src/app/(app)/home/_components/spend-card.tsx
@@ -6,7 +6,7 @@ import { useMoney } from "@/lib/format";
import { dayIndex, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math";
import { cn } from "@/lib/utils";
import type { Forecast, Reminder } from "../use-home-data";
-import { HomeCard } from "./card";
+import { PageCard } from "@/components/page/page-card";
const RANGES: Range[] = ["1W", "1M", "3M", "YTD"];
const TITLE: Record = { "1W": "Spent this week", "1M": "Spent this month", "3M": "Spent this quarter", YTD: "Spent this year" };
@@ -95,7 +95,7 @@ export function SpendCard({
range === "1M" ? `vs ${short(previous.start)} at this point` : range === "YTD" ? `vs ${previous.start.getFullYear()} at this point` : "vs last period at this point";
return (
-
+
@@ -182,6 +182,6 @@ export function SpendCard({
))}
-
+
);
}
diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx
index ca06636..52ddc4e 100644
--- a/frontend/src/app/(app)/home/homeContent.tsx
+++ b/frontend/src/app/(app)/home/homeContent.tsx
@@ -8,6 +8,7 @@ import { ExportDialog } from "@/components/exportDialog";
import type { FoundItem } from "@/lib/api/client";
import { useMoney } from "@/lib/format";
import { briefSentence, cumulative, dayIndex, fill, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math";
+import { PageHeader } from "@/components/page/page-header";
import { Brief } from "./_components/brief";
import { ActivityCard } from "./_components/activity-card";
import { BillsCard } from "./_components/bills-card";
@@ -68,19 +69,16 @@ export default function HomeContent() {
- {/* md:pr-12 leaves room for the floating account menu (app-shell.tsx) */}
-
}
diff --git a/frontend/src/components/page/page-header.tsx b/frontend/src/components/page/page-header.tsx
new file mode 100644
index 0000000..38a4480
--- /dev/null
+++ b/frontend/src/components/page/page-header.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from "react";
+
+// Every Phase 5 page opens the same way (spec 2026-10-22 §3): title and quiet actions on one row, the serif brief
+// under it, then an optional control row. md:pr-12 leaves room for the floating account menu (app-shell.tsx).
+export function PageHeader({
+ title,
+ brief,
+ actions,
+ children,
+}: {
+ title: ReactNode;
+ brief?: ReactNode;
+ actions?: ReactNode;
+ children?: ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {actions &&
{actions}
}
+
+ {brief &&
{brief}
}
+ {children &&
{children}
}
+
+ );
+}
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
index 24e29f1..0968e6d 100644
--- a/frontend/src/components/ui/button.tsx
+++ b/frontend/src/components/ui/button.tsx
@@ -18,6 +18,9 @@ const buttonVariants = cva(
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
+ // Phase 5 pages: light hairline actions, and one dark primary per page (no loud blue)
+ quiet: "border border-border bg-card/70 text-foreground/80 backdrop-blur hover:bg-card hover:text-foreground",
+ ink: "bg-foreground text-background hover:bg-foreground/90",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
@@ -26,6 +29,7 @@ const buttonVariants = cva(
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
+ "pill-icon": "size-[34px] rounded-full",
},
},
defaultVariants: {
From ce1416e53516d62de33cf025bcf257c58233360a Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Thu, 22 Oct 2026 01:50:37 +0530
Subject: [PATCH 04/18] feat: smooth monotone chart path and tick picking
---
frontend/e2e/home-math.spec.ts | 40 ++++++++++++++++++++++++++++
frontend/src/lib/chart.ts | 48 ++++++++++++++++++++++++++++++++++
2 files changed, 88 insertions(+)
create mode 100644 frontend/src/lib/chart.ts
diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts
index 00fec18..c0a3979 100644
--- a/frontend/e2e/home-math.spec.ts
+++ b/frontend/e2e/home-math.spec.ts
@@ -1,5 +1,6 @@
// Node-only unit tests (no browser) for the Home calculations.
import { test, expect } from "@playwright/test";
+import { pickTicks, smoothPath } from "../src/lib/chart";
import {
briefSentence,
fill,
@@ -153,3 +154,42 @@ test("suggestedQuestions maps found items, dedupes, caps at 4", () => {
]);
expect(suggestedQuestions([], ["A", "B"])).toEqual(["A", "B"]);
});
+
+// ---- lib/chart.ts (Phase 5) ----
+
+// y of each cubic segment of a path "M x y C x1 y1 x2 y2 x y C …" sampled at t
+function samples(d: string, ts = [0.25, 0.5, 0.75]): number[] {
+ const n = d.match(/-?\d+(\.\d+)?/g)!.map(Number);
+ let [x0, y0] = [n[0], n[1]];
+ const out: number[] = [];
+ for (let i = 2; i + 5 < n.length + 1; i += 6) {
+ const [, c1, , c2, x, y] = n.slice(i, i + 6);
+ for (const t of ts) out.push((1 - t) ** 3 * y0 + 3 * (1 - t) ** 2 * t * c1 + 3 * (1 - t) * t ** 2 * c2 + t ** 3 * y);
+ [x0, y0] = [x, y];
+ }
+ void x0;
+ return out;
+}
+
+test("smoothPath passes through every point", () => {
+ const d = smoothPath([[0, 10], [10, 20], [20, 20], [30, 40]]);
+ expect(d.startsWith("M0 10")).toBe(true);
+ expect(d).toContain("20 20");
+ expect(d.endsWith("30 40")).toBe(true);
+ expect(smoothPath([])).toBe("");
+ expect(smoothPath([[5, 6]])).toBe("M5 6");
+});
+
+test("smoothPath never overshoots a running total", () => {
+ const ys = samples(smoothPath([[0, 0], [10, 100], [20, 100], [30, 101]]));
+ for (let i = 0; i < ys.length; i++) {
+ expect(ys[i]).toBeLessThanOrEqual(101 + 1e-9);
+ if (i) expect(ys[i]).toBeGreaterThanOrEqual(ys[i - 1] - 1e-9);
+ }
+});
+
+test("pickTicks drops ticks that crowd a kept one", () => {
+ const t = (x: number, keep = false) => ({ i: x, x, label: String(x), keep });
+ expect(pickTicks([t(0, true), t(20), t(40), t(300, true), t(310, true)], 48).map((k) => k.label)).toEqual(["0", "300", "310"]);
+ expect(pickTicks([t(0, true), t(60), t(120), t(200, true)], 48).map((k) => k.label)).toEqual(["0", "60", "120", "200"]);
+});
diff --git a/frontend/src/lib/chart.ts b/frontend/src/lib/chart.ts
new file mode 100644
index 0000000..77e59e9
--- /dev/null
+++ b/frontend/src/lib/chart.ts
@@ -0,0 +1,48 @@
+// Small chart helpers shared by the Phase 5 pages (spec 2026-10-22 §3).
+
+const n = (v: number) => +v.toFixed(2);
+
+// A smooth SVG path through every point: monotone cubic (Fritsch–Carlson), so a running total never dips or
+// overshoots between two days the way a plain Catmull-Rom curve does.
+export function smoothPath(points: [number, number][]): string {
+ if (!points.length) return "";
+ const [x0, y0] = points[0];
+ if (points.length === 1) return `M${n(x0)} ${n(y0)}`;
+ const last = points.length - 1;
+ const m = points.slice(0, last).map(([x, y], k) => (points[k + 1][1] - y) / (points[k + 1][0] - x || 1));
+ const t = points.map((_, k) => (k === 0 ? m[0] : k === last ? m[last - 1] : m[k - 1] * m[k] <= 0 ? 0 : (m[k - 1] + m[k]) / 2));
+ for (let k = 0; k < last; k++) {
+ if (m[k] === 0) {
+ t[k] = t[k + 1] = 0;
+ continue;
+ }
+ const a = t[k] / m[k];
+ const b = t[k + 1] / m[k];
+ const s = a * a + b * b;
+ if (s > 9) {
+ const tau = 3 / Math.sqrt(s);
+ t[k] = tau * a * m[k];
+ t[k + 1] = tau * b * m[k];
+ }
+ }
+ let d = `M${n(x0)} ${n(y0)}`;
+ for (let k = 0; k < last; k++) {
+ const [xa, ya] = points[k];
+ const [xb, yb] = points[k + 1];
+ const h = (xb - xa) / 3;
+ d += ` C${n(xa + h)} ${n(ya + t[k] * h)} ${n(xb - h)} ${n(yb - t[k + 1] * h)} ${n(xb)} ${n(yb)}`;
+ }
+ return d;
+}
+
+// Axis labels that never overlap: `keep` ticks (first, today, last) always stay; any other tick is dropped when it
+// sits closer than `minGap` px to a tick already shown.
+export function pickTicks(candidates: T[], minGap: number): T[] {
+ const kept = candidates.filter((c) => c.keep);
+ const shown = [...kept];
+ for (const c of candidates) {
+ if (c.keep) continue;
+ if (shown.every((s) => Math.abs(s.x - c.x) >= minGap)) shown.push(c);
+ }
+ return candidates.filter((c) => shown.includes(c));
+}
From 877f4447394ad446d53cfbbfffe4794114d3e3f8 Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Thu, 22 Oct 2026 02:02:20 +0530
Subject: [PATCH 05/18] feat: Home hero card in the Origin style
---
frontend/e2e/home.spec.ts | 39 ++-
.../app/(app)/home/_components/spend-card.tsx | 249 ++++++++++++------
frontend/src/app/(app)/home/homeContent.tsx | 9 +-
frontend/src/components/uploadDialog.tsx | 20 +-
frontend/src/lib/home-math.ts | 3 +
5 files changed, 233 insertions(+), 87 deletions(-)
diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts
index 308af4f..e8d32e7 100644
--- a/frontend/e2e/home.spec.ts
+++ b/frontend/e2e/home.spec.ts
@@ -68,20 +68,23 @@ test("old dashboard is gone", async ({ page }) => {
});
const spend = (page: Page) => page.getByTestId("spend-card");
-const prevShort = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "short" });
+const prevLong = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "long" });
+const monthLong = new Date(Y, M, 1).toLocaleString("en-US", { month: "long" });
+const ord = (n: number) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th")}`;
const lastDay = new Date(Y, M + 1, 0).getDate();
test("spend card shows total and delta", async ({ page }) => {
await openHome(page);
await expect(spend(page).getByText("₹18,412", { exact: true })).toBeVisible();
- await expect(spend(page).getByTestId("spend-delta")).toHaveText(`↓ ₹1,868 vs ${prevShort} at this point`);
+ await expect(spend(page).getByTestId("spend-delta")).toHaveText(`−₹1,868 (9%) vs ${prevLong} by the ${ord(now.getDate())}`);
});
test("range chips switch period", async ({ page }) => {
await openHome(page);
- await expect(spend(page)).toContainText("SPENT THIS MONTH", { ignoreCase: true });
+ await expect(spend(page)).toContainText(`Spent in ${monthLong}`);
+ await expect(spend(page).getByRole("link", { name: new RegExp(`Spent in ${monthLong}`) })).toHaveAttribute("href", "/spending");
await spend(page).getByRole("button", { name: "1W" }).click();
- await expect(spend(page)).toContainText("SPENT THIS WEEK", { ignoreCase: true });
+ await expect(spend(page)).toContainText("Spent this week");
await expect(spend(page).getByRole("button", { name: "1W" })).toHaveAttribute("aria-pressed", "true");
});
@@ -96,6 +99,9 @@ test("forecast draws to month end", async ({ page }) => {
const dates = Array.from({ length: 31 }, (_, i) => ymd(Y, M, now.getDate() + 1 + i));
await openHome(page, { ...typical, forecast: { dates, values: dates.map(() => 100) } });
await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(1);
+ // 18,412 so far + 100 for each day left in the month
+ const left = lastDay - now.getDate();
+ await expect(spend(page)).toContainText(`heading for ₹${(18412 + 100 * left).toLocaleString("en-IN")}`);
});
test("bill pins show upcoming bills", async ({ page }) => {
@@ -104,7 +110,8 @@ test("bill pins show upcoming bills", async ({ page }) => {
...typical,
reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: thisMonth(now.getDate() + 1), type: "upcoming" }],
});
- await expect(spend(page).getByTestId("bill-pin")).toHaveText("Netflix");
+ await expect(spend(page).getByTestId("bill-pin")).toHaveAttribute("title", "Netflix");
+ await expect(spend(page).getByTestId("bill-pin")).toHaveText("N");
});
test("new user sees empty states", async ({ page }) => {
@@ -228,6 +235,7 @@ test("a forecast with no matching days draws no line", async ({ page }) => {
await openHome(page, { ...typical, forecast: { dates: [lastMonth(2), lastMonth(3)], values: [500, 500] } });
await expect(spend(page).locator('[data-series="current"]')).toHaveCount(1);
await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(0);
+ await expect(spend(page)).not.toContainText("heading for");
});
const dupe = { kind: "duplicate", tag: "DUPLICATES", headline: "Possible double charge: Croma {0}", detail: "Charged on 14 Oct and 15 Oct.",
@@ -349,3 +357,24 @@ test("home header keeps clear of the account menu", async ({ page }) => {
}
}
});
+
+test("hero shows an increase with its percent", async ({ page }) => {
+ await openHome(page, { txns: [tx("a", "Croma", thisMonth(1), 30000), tx("b", "Rent", lastMonth(1), 20000)] });
+ await expect(spend(page).getByTestId("spend-delta")).toHaveText(`+₹10,000 (50%) vs ${prevLong} by the ${ord(now.getDate())}`);
+});
+
+test("chart ticks never overlap at phone width", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ await openHome(page);
+ await expect(spend(page).getByTestId("tick").first()).toBeVisible();
+ const boxes = await spend(page).getByTestId("tick").evaluateAll((els) => els.map((e) => e.getBoundingClientRect().toJSON()));
+ expect(boxes.length).toBeGreaterThanOrEqual(2);
+ for (let i = 0; i < boxes.length; i++)
+ for (let j = i + 1; j < boxes.length; j++) expect(overlaps(boxes[i], boxes[j]), `ticks ${i} and ${j}`).toBe(false);
+});
+
+test("the hero + opens upload", async ({ page }) => {
+ await openHome(page);
+ await spend(page).getByRole("button", { name: "Upload a receipt" }).click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+});
diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx
index b8bbe03..2419abd 100644
--- a/frontend/src/app/(app)/home/_components/spend-card.tsx
+++ b/frontend/src/app/(app)/home/_components/spend-card.tsx
@@ -2,18 +2,31 @@
import { useEffect, useState } from "react";
import { animate } from "motion/react";
+import { Plus } from "lucide-react";
+import { PageCard } from "@/components/page/page-card";
+import { pickTicks, smoothPath } from "@/lib/chart";
import { useMoney } from "@/lib/format";
-import { dayIndex, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math";
+import { dayIndex, ordinal, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math";
import { cn } from "@/lib/utils";
import type { Forecast, Reminder } from "../use-home-data";
-import { PageCard } from "@/components/page/page-card";
const RANGES: Range[] = ["1W", "1M", "3M", "YTD"];
-const TITLE: Record = { "1W": "Spent this week", "1M": "Spent this month", "3M": "Spent this quarter", YTD: "Spent this year" };
-const W = 860;
-const H = 160;
-const short = (d: Date) => d.toLocaleString("en-US", { month: "short" }); // "Sep", not en-GB's "Sept"
-const tick = (d: Date) => d.toLocaleString("en-GB", { day: "2-digit", month: "short" });
+const W = 1000;
+const H = 200;
+const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" });
+const dayMonth = (d: Date) => d.toLocaleString("en-GB", { day: "numeric", month: "short" });
+
+function title(range: Range, period: Period) {
+ if (range === "1M") return `Spent in ${monthLong(period.start)}`;
+ if (range === "YTD") return `Spent in ${period.start.getFullYear()}`;
+ return range === "1W" ? "Spent this week" : "Spent this quarter";
+}
+
+function compared(range: Range, previous: Period, today: Date) {
+ if (range === "1M") return `vs ${monthLong(previous.start)} by the ${ordinal(today.getDate())}`;
+ if (range === "YTD") return `vs ${previous.start.getFullYear()} at this point`;
+ return range === "1W" ? "vs last week at this point" : "vs last quarter at this point";
+}
// Counts up once per browser session, then shows the number straight away (motion rule: no replay on revisits).
function CountUp({ value, format }: { value: number; format: (n: number) => string }) {
@@ -32,9 +45,26 @@ function CountUp({ value, format }: { value: number; format: (n: number) => stri
return <>{format(shown)}>;
}
+// The width the tick labels are laid out in, so they can be spaced in real pixels at any screen size. A callback
+// ref: the axis only exists once the card has data, after the first render.
+function useWidth() {
+ const [el, setEl] = useState(null);
+ const [width, setWidth] = useState(800);
+ useEffect(() => {
+ if (!el) return;
+ const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, [el]);
+ return [setEl, width] as const;
+}
+
+// Home's hero (spec 2026-10-22 §4.1, Origin style): the period's total, how it compares, where it's heading, and the
+// running total as a smooth line against the previous period, with upcoming bills pinned on the axis.
export function SpendCard({
range,
onRange,
+ onUpload,
period,
previous,
cur,
@@ -46,6 +76,7 @@ export function SpendCard({
}: {
range: Range;
onRange: (r: Range) => void;
+ onUpload: () => void;
period: Period;
previous: Period;
cur: number[];
@@ -56,6 +87,7 @@ export function SpendCard({
loading: boolean;
}) {
const money = useMoney();
+ const [axis, width] = useWidth();
const t = Math.min(Math.max(dayIndex(period, today), 0), period.days - 1);
const total = cur[t] ?? 0;
const delta = sameDayDelta(cur, prev, t);
@@ -78,9 +110,9 @@ export function SpendCard({
const empty = total === 0 && !prev.some((v) => v > 0);
const max = Math.max(total, prev[prev.length - 1] ?? 0, fc[fc.length - 1] ?? 0, 1);
const x = (i: number) => (period.days > 1 ? (i / (period.days - 1)) * W : 0);
- const y = (v: number) => H - (v / max) * (H - 20);
- const line = (vals: number[], from = 0) => vals.map((v, i) => `${i ? "L" : "M"}${x(i + from).toFixed(1)} ${y(v).toFixed(1)}`).join(" ");
- const actual = cur.slice(0, t + 1);
+ const y = (v: number) => H - (v / max) * (H - 16);
+ const pts = (vals: number[], from = 0) => vals.map((v, i) => [x(i + from), y(v)] as [number, number]);
+ const actual = pts(cur.slice(0, t + 1));
const pins = reminders.filter((r) => {
if (r.type !== "upcoming") return false;
@@ -88,83 +120,146 @@ export function SpendCard({
return i > t && i < period.days;
});
- const ticks = [0, Math.round((period.days - 1) * 0.25), Math.round((period.days - 1) * 0.5), t, period.days - 1]
- .filter((v, i, a) => a.indexOf(v) === i)
- .sort((a, b) => a - b);
- const vsLabel =
- range === "1M" ? `vs ${short(previous.start)} at this point` : range === "YTD" ? `vs ${previous.start.getFullYear()} at this point` : "vs last period at this point";
+ // first, today and last always show unless they'd crowd today; the weekly ones in between only where they fit
+ const px = (i: number) => (x(i) / W) * width;
+ const minGap = 48;
+ const ticks = pickTicks(
+ Array.from({ length: period.days }, (_, i) => i)
+ .filter((i) => i === 0 || i === t || i === period.days - 1 || i % 7 === 0)
+ .map((i) => ({
+ i,
+ x: px(i),
+ keep: i === t || ((i === 0 || i === period.days - 1) && Math.abs(px(i) - px(t)) >= minGap),
+ label: i === t ? "Today" : i === 0 || i === period.days - 1 ? dayMonth(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i)) : String(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i).getDate()),
+ })),
+ minGap,
+ );
+
+ const legend = (
+
+
+
+ Now
+
+
+
+ Before
+
+ {fc.length > 0 && (
+
+
+ Forecast
+
+ )}
+
+ );
return (
-
-
Nothing spent yet this {range === "1W" ? "week" : range === "1M" ? "month" : range === "3M" ? "quarter" : "year"}.
Upload a receipt or import a statement and your spending will show up here.
) : (
- <>
-
-
- {/* HTML overlays keep text and dots crisp while the svg stretches */}
-
- {pins.map((p) => {
- const i = dayIndex(period, parseDay(p.date));
- return (
-
-
- {p.vendor}
+ <>
+
+
+ {/* HTML overlays keep text and dots crisp while the svg stretches */}
+
+ {pins.map((p) => {
+ const i = dayIndex(period, parseDay(p.date));
+ return (
+
+ {p.vendor.charAt(0).toUpperCase()}
+
+ );
+ })}
+