diff --git a/backend/ai/analytics_orchestrator.py b/backend/ai/analytics_orchestrator.py
index ea3be6d..1eae5bb 100644
--- a/backend/ai/analytics_orchestrator.py
+++ b/backend/ai/analytics_orchestrator.py
@@ -209,9 +209,9 @@ def get_anomalies(self, user_id: int, risk_level: str = None) -> List[Dict]:
return anomalies
- def get_forecast(self, user_id: int, days_ahead: int = 30) -> Dict:
- """Get spending forecast"""
- return self.forecast_agent.forecast_spending(user_id, days_ahead)
+ def get_forecast(self, user_id: int, days_ahead: int = 30, use_llm: bool = True) -> Dict:
+ """Get spending forecast (use_llm=False skips the LLM-written insight text, e.g. for Home's chart)"""
+ return self.forecast_agent.forecast_spending(user_id, days_ahead, use_llm=use_llm)
def _risk(self, user_id) -> Dict:
"""The risk score with the user's own budget and income from onboarding, when given (SPEC-ONBOARD)."""
diff --git a/backend/routes/ai_analytics.py b/backend/routes/ai_analytics.py
index 71e7ab3..5df53a0 100644
--- a/backend/routes/ai_analytics.py
+++ b/backend/routes/ai_analytics.py
@@ -147,6 +147,7 @@ def get_forecast():
Query Params:
- days_ahead: Forecast period (default: 30)
+ - insights: 0 to skip the LLM-written insight text (Home only needs the numbers)
"""
try:
init_analytics() # Ensure orchestrator is initialized
@@ -154,7 +155,8 @@ def get_forecast():
user_id = g.user_id
days_ahead = request.args.get('days_ahead', 30, type=int)
- forecast = orchestrator.get_forecast(user_id, days_ahead)
+ use_llm = request.args.get('insights', '1') != '0'
+ forecast = orchestrator.get_forecast(user_id, days_ahead, use_llm=use_llm)
return jsonify({
'success': forecast.get('success', True),
diff --git a/backend/tests/test_ai_analytics.py b/backend/tests/test_ai_analytics.py
index 5e0d87b..45dd6b7 100644
--- a/backend/tests/test_ai_analytics.py
+++ b/backend/tests/test_ai_analytics.py
@@ -1087,3 +1087,23 @@ def test_require_auth_routes_are_all_registered_as_views():
missing = decorated_names - registered_names
assert not missing, f"@require_auth handlers not registered as routes: {missing}"
+
+
+@pytest.mark.parametrize("query, expected", [("", True), ("&insights=0", False)])
+def test_forecast_route_can_skip_llm_insights(authed_client, monkeypatch, query, expected):
+ """Home asks for the forecast numbers only (?insights=0): no LLM call per page load. Default keeps insights."""
+ import routes.ai_analytics as route
+
+ seen = {}
+
+ class FakeOrchestrator:
+ def get_forecast(self, user_id, days_ahead, use_llm=True):
+ seen["use_llm"] = use_llm
+ return {"success": True, "forecast": {"dates": [], "values": []}}
+
+ monkeypatch.setattr(route, "init_analytics", lambda: None)
+ monkeypatch.setattr(route, "orchestrator", FakeOrchestrator())
+ resp = authed_client.get(f"/api/analytics/forecast?days_ahead=31{query}", headers={"Authorization": "Bearer t"})
+
+ assert resp.status_code == 200
+ assert seen["use_llm"] is expected
diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md
index bf3652c..ebb9ca2 100644
--- a/docs/CODEBASE-GUIDE.md
+++ b/docs/CODEBASE-GUIDE.md
@@ -279,6 +279,15 @@ embeds the demo documents once per server and uploads the files in the backgroun
`npm run test:e2e` (Playwright, local only), which drives the dev-only harness `app/dev/layout-shift` (404 in
production). Firefox is opt-in with `E2E_FIREFOX=1`. `html, body` use `overflow-x: clip`, never `hidden`:
`hidden` makes `
` a scroll container whose scrollbar disappears when a menu opens.
+- **Home** (`app/(app)/home/`, UI revamp Phase 2): greeting and a serif brief over a sky band, then the cards.
+ The main column has Spent this period (a hand-drawn SVG: cumulative now vs the previous period, a forecast to month-end, bill pins,
+ 1W/1M/3M/YTD), Lumen found, and Spending calendar + Recent transactions. The side column has Budget, Upcoming bills, the draft-budget promo and
+ Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, reminders, forecast, review
+ queue). A failed call empties only its card. Skeletons show only on the first load, so changing the chart range
+ never blanks other cards. All the maths is pure, in `lib/home-math.ts` (unit tests: `e2e/home-math.spec.ts`).
+ **Interim until Phase 3:** the brief is one rule-based sentence, Lumen found only shows the review queue, the
+ Budget card is an empty state, and Activity is derived from transactions' `created_at`. `(app)/layout.tsx` is a
+ tiny server file exporting the light `theme-color`; the client shell is `components/app-shell.tsx`.
- **Signed-in e2e:** `e2e/fixtures.ts` `signIn(page)` fakes a Supabase session (localStorage) plus the
`lumen_session` cookie, and stubs every backend call, so `e2e/shell.spec.ts` drives the real shell without servers.
- **Motion:** use `motion/react` only (framer-motion was removed). Shared timings live in `lib/motion.ts`
diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md
index 18abd75..3dda25d 100644
--- a/docs/direction/STORY.md
+++ b/docs/direction/STORY.md
@@ -326,3 +326,17 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in
while typing, a live review badge and the mobile sheet closing. Avatar-menu shift checks passed 24/24 on repeat
(failed 1 in 4 before). Story point: a flaky test is a bug report. Here it caught the half of the original bug
that the first fix missed.
+
+## 28. A Home that says something (UI revamp, Phase 2)
+- **S:** The signed-in home was a stock "Financial Overview": four KPI tiles, an invoice list and a "What to watch"
+ card of static tips. Nothing on it was about *your* month.
+- **T:** Build the approved Origin-inspired Home: a brief, a spend-vs-last-month chart, "Lumen found", a calendar,
+ bills, budget and activity. Use only real data, keep it light on the free Vercel tier, and keep the owner's
+ "nothing moves unless you asked" rule.
+- **A:** Put all the maths in pure functions with 13 unit tests (periods, cumulative spend, the same-day comparison
+ clamped to month length, the brief sentence). Drew the chart as one SVG instead of pulling in the chart library.
+ Cards with no backend yet show honest empty states, not invented numbers. A test caught that changing the chart's
+ range blanked the brief; Home now keeps its old numbers until new ones arrive.
+- **R:** `/home` is static at 15.7 kB of route JS. 65 checks pass, including zero layout shift with a 1.5 s slow
+ backend, a failed forecast, a brand-new user with no data, and 15-lakh amounts with long vendor names. Story point:
+ an honest empty state beats a convincing placeholder.
diff --git a/docs/superpowers/plans/2026-10-20-ui-revamp-phase2-home-ui.md b/docs/superpowers/plans/2026-10-20-ui-revamp-phase2-home-ui.md
new file mode 100644
index 0000000..58634a1
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-20-ui-revamp-phase2-home-ui.md
@@ -0,0 +1,231 @@
+# UI Revamp Phase 2 (Home UI) 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:** Replace the generic "Financial Overview" dashboard with the approved Home from the mockup. It uses only data that exists today. Cards that need Phase 3 backend show honest empty states.
+
+**Architecture:**
+- **Data.** One client hook `useHomeData()` fetches existing endpoints in parallel: transactions for this period and the previous one, reminders, forecast and the review queue.
+- **Logic.** Everything the cards compute lives in pure functions in `lib/home-math.ts`, so it can be unit-tested with Playwright's node runner (no browser) and later replaced by the Phase 3 summary endpoint.
+- **Card components** are colocated in `app/(app)/home/_components/`.
+- **Chart.** A hand-rolled SVG, as in the mockup. No recharts on Home, which keeps the Home bundle small (spec 11 leaves the chart library to the builder).
+
+**Tech Stack:** Next.js 14, Tailwind v4 Sky tokens (Phase 0), `motion/react`, Playwright (unit tests and e2e with the `signIn` fixture from Phase 1).
+
+**Spec:** `docs/superpowers/specs/2026-10-20-ui-revamp-design.md` §4 (Home), §5 (visual system), §6 (motion, feedback states, zero shift) and §12 backlog items tagged Phase 2. Visual reference: `docs/superpowers/specs/2026-10-20-ui-revamp/home-mockup.html`, Sky variant. Builds on `ui-revamp/phase1-shell` (PR #91).
+
+## Global Constraints
+
+- **Card set and order (spec §4, owner-approved):**
+ - **Main column:** Brief → Spent this month → Lumen found → Spending calendar + Recent transactions.
+ - **Side column (~330px):** Budget → Upcoming bills → Draft-budget promo → Activity.
+ - **Deferred:** the "+" ask-about-this button on cards (owner: "maybe later").
+- **Honesty rule (spec anti-goal):** no invented numbers or scores. A card without real data shows an empty state that suggests a next step.
+- **Type:**
+ - Brief and action headlines use `font-serif` (Newsreader).
+ - Card titles, tags and axis ticks use `label-mono`.
+ - Numbers use Funnel Sans.
+ - `₹` formatting goes through the existing `useMoney()` in `lib/format.ts`.
+- **Motion:** 150–250ms with the `@/lib/motion` constants. Numbers count up **once per session** and not on revisits. Nothing animates layout.
+- **Zero layout shift (spec 6.2/6.3):** every async card renders a skeleton at its final size. Arriving data must not move anything.
+- **Sky band:** `--band` behind the greeting and brief, Home only. Text on the band uses `foreground`, never `muted-foreground` (backlog: about 4.2:1 contrast).
+- **No new dependencies.**
+- **Git:** no "claude" in branch names and no attribution lines. Open one PR (base `ui-revamp/phase1-shell` until #91 merges, then `refactor`). Keep `docs/CODEBASE-GUIDE.md` current and tick the §12 backlog items this phase closes.
+
+## Data sources (existing endpoints, shapes verified)
+
+| Need | Call | Shape used |
+|---|---|---|
+| This period's and the previous period's spend, calendar, recent list, activity | `transactionApi.getTransactions({ date_from, date_to, page_size: 500, sort_by: "date", sort_order: "desc" })` | `{ data: { id, vendor_name, date: "YYYY-MM-DD", total_amount, category, created_at }[] }` |
+| Upcoming bills, bill pins on the chart | `aiAnalyticsApi.getReminders(31)` | `{ reminders: { id, vendor, amount, date, type: "upcoming" \| …, category }[] }` |
+| Forecast to month end | `aiAnalyticsApi.getForecast(31)` | `{ forecast: { success, forecast?: { dates: string[], values: number[], total_predicted } } }`. When `success` is false, no forecast line is drawn |
+| "Receipts need a glance" action card | `reviewApi.list()` | length |
+
+## Decisions this plan makes (owner may override)
+
+- **Brief (interim until the Phase 3 summary endpoint).**
+ - It is a single rule-based sentence computed client-side: `briefSentence(...)`.
+ - With last-month data: "October is running 9% lighter than September", or "…heavier…" when it is up.
+ - Otherwise: "You've spent ₹18,412 so far in October."
+ - The next bill is appended when one is known: "Netflix renews on Friday."
+- **Lumen found (interim).** The only Phase 2 item is the review count ("3 receipts need a glance" → `/receipts?tab=review`). With nothing to show, the card says "Nothing needs you right now." Phase 3 adds subscriptions, spikes and duplicates.
+- **Budget card.** An empty state ("No budget yet") plus the promo card, whose button is disabled and labelled "Coming soon". Phase 3 wires it up.
+- **Activity (interim).** Derived from the 5 newest transactions by `created_at`, shown as "Added Swiggy ₹486 · 2h ago". Phase 3 may replace this with a real feed.
+- **Home sub-tabs** (Overview / This month / Year) **are not built.** Spec §11 left this open, and the range chips on the chart cover the need. Recorded in the backlog.
+- **The upload and export buttons** move into the greeting row as two icon buttons, reusing the existing `DialogDemo` (upload, still opened by `?upload=1`) and `ExportDialog`.
+- **`theme-color`** (backlog item). `(app)/layout.tsx` becomes a 6-line server file that exports `viewport = { themeColor: "#f4f3ef", colorScheme: "light" }` and renders the current client layout, moved to `components/app-shell.tsx` unchanged. A client file cannot export `viewport`.
+
+## Review Focus
+
+1. **A brand-new user with zero transactions:** every card shows its empty state, with no `NaN`, `₹0 of ₹0`, empty chart axes or division by zero. Task 1 test `brief with no data` and Task 3 e2e `new user sees empty states`.
+2. **Month boundaries:** on the 1st of a month, the chart and "vs last month" use the right periods. On the 31st, the previous month has only 30 days, so compare against the last day that exists, not a missing one. Task 1 test `same-day comparison clamps to previous month length`.
+3. **Slow backend (Render cold start):** the cards' skeletons hold their final size and nothing moves when data arrives. Task 2 e2e `home has no layout shift while loading`.
+4. **The forecast endpoint fails or reports `success: false`:** the chart still renders actuals without the dotted line. Task 3 e2e `chart survives a failed forecast`.
+5. **Large amounts and long vendor names (₹15,00,000, "Bharat Sanchar Nigam Limited Postpaid"):** text truncates and doesn't wrap the layout. Task 4 e2e `long names truncate`.
+
+## File map
+
+| File | Responsibility |
+|---|---|
+| `frontend/src/lib/home-math.ts` (new) | Pure functions: periods, cumulative series, same-day comparison, brief sentence, calendar buckets, relative time |
+| `frontend/e2e/home-math.spec.ts` (new) | Node-only unit tests for `home-math` (no `page` fixture) |
+| `frontend/src/app/(app)/home/use-home-data.ts` (new) | `useHomeData(range)` parallel fetch → `{ status, txns, prevTxns, reminders, forecast, toReview }` |
+| `frontend/src/app/(app)/home/_components/*.tsx` (new) | `brief.tsx`, `spend-card.tsx`, `lumen-found.tsx`, `calendar-card.tsx`, `bills-card.tsx`, `budget-card.tsx`, `activity-card.tsx`, `card.tsx` (shared shell: title row and skeleton) |
+| `frontend/src/app/(app)/home/homeContent.tsx` (new) | Replaces `dashboardContent.tsx` (deleted) |
+| `frontend/src/app/(app)/layout.tsx` → `frontend/src/components/app-shell.tsx` | Server/client split for `viewport` |
+| `frontend/e2e/fixtures.ts` | `signIn(page, { home?: HomeFixture })` adds stubs for `/transactions`, `/api/analytics/reminders` and `/api/analytics/forecast`, with an optional delay |
+| `frontend/e2e/home.spec.ts` (new) | Home e2e |
+| Delete if unused afterwards | `components/section-cards.tsx`, `components/animatedListItemUse.tsx`. Confirm by grep. Keep `uploadDialog.tsx` and `exportDialog.tsx` |
+
+---
+
+### Task 1: `home-math` pure functions with unit tests
+
+**Files:** create `frontend/src/lib/home-math.ts` and `frontend/e2e/home-math.spec.ts`.
+
+**Interfaces (produced, used by Tasks 2–5):**
+
+```ts
+export type Txn = { id: string | number; vendor_name: string | null; date: string; total_amount: number | null; category: string | null; created_at: string | null };
+export type Range = "1W" | "1M" | "3M" | "YTD";
+export type Period = { start: Date; end: Date; days: number }; // inclusive, local dates at 00:00
+export function periods(range: Range, today: Date): { current: Period; previous: Period };
+// 1W: Mon..Sun week containing today vs the week before; 1M: calendar month vs previous calendar month;
+// 3M: last 3 calendar months incl. current vs the 3 before; YTD: Jan 1..Dec 31 this year vs last year.
+export function cumulative(txns: Txn[], p: Period): number[]; // length p.days, running sum by day index
+export function sameDayDelta(cur: number[], prev: number[], dayIndex: number): { abs: number; pct: number } | null;
+// compares cur[dayIndex] with prev[min(dayIndex, prev.length-1)]; null if prev total at that point is 0
+export function briefSentence(a: { monthName: string; prevMonthName: string; spent: number; delta: { pct: number } | null;
+ nextBill?: { vendor: string; date: Date } ; money: (n: number) => string; today: Date }): string;
+export function calendarBuckets(txns: Txn[], p: Period): { day: number; amount: number }[]; // one per day of p
+export function heat(amount: number, max: number): number; // 0 when amount 0, else 0.15..0.85
+export function relativeTime(iso: string, now: Date): string; // "just now" | "5m ago" | "2h ago" | "3d ago" | "14 Oct"
+```
+
+- [ ] **Step 1: Write the failing tests** in `e2e/home-math.spec.ts`. Import with the relative path `../src/lib/home-math`, because Playwright doesn't resolve the `@/` alias. Use exact values. Test names:
+ - `periods 1M on Oct 20 is Oct 1..31 vs Sep 1..30`: the days are 31 and 30.
+ - `periods 1W uses Monday weeks`: 2026-10-20 is a Tuesday, so the week is Oct 19..25.
+ - `cumulative sums by day and ignores out-of-period and null amounts`: transactions on Oct 1 (100), Oct 3 (50), Sep 30 (999) and Oct 3 (null) give `[100,100,150,…]` with length 31.
+ - `same-day comparison clamps to previous month length`: cur has length 31, prev has length 30, and day index 30 is compared with prev[29].
+ - `sameDayDelta is null when previous is 0`.
+ - `brief lighter`: spent 18412, pct -9.2, Sep→Oct, gives `"October is running 9% lighter than September."`.
+ - `brief heavier with next bill`: pct 12, next bill Netflix 2026-10-23 with today 2026-10-20, gives `"October is running 12% heavier than September. Netflix renews on Friday."`.
+ - `brief with no data`: spent 0, delta null, gives `"Nothing spent yet in October."`.
+ - `brief with no comparison`: spent 18412, delta null, gives `"You've spent ₹18,412 so far in October."` (money stubbed as `n => "₹" + n.toLocaleString("en-IN")`).
+ - `next bill more than 6 days out uses a date`: 2026-11-01 with today 2026-10-20 gives `"… Rent renews on 1 Nov."`.
+ - `heat`: `heat(0,100)=0`, `heat(100,100)=0.85`, and a small positive value is ≥0.15.
+ - `relativeTime`: 30s gives "just now", 5min gives "5m ago", 2h gives "2h ago", 3d gives "3d ago", and 10d gives "10 Oct" for 2026-10-10 with now 2026-10-20.
+- [ ] **Step 2: Run** `npx playwright test e2e/home-math.spec.ts --project=chromium`. The expected result is FAIL, because the module isn't found.
+- [ ] **Step 3: Implement** `home-math.ts`. Use local dates throughout. Parse `YYYY-MM-DD` as a local date, not UTC: `new Date(y, m-1, d)`.
+- [ ] **Step 4: Run it again.** All tests should pass.
+- [ ] **Step 5: Commit** with `feat: home math (periods, cumulative spend, brief sentence) with unit tests`.
+
+### Task 2: Home frame, data hook, theme-color, no-shift skeletons
+
+**Files:**
+- Move `(app)/layout.tsx` → `components/app-shell.tsx` (`export function AppShell`), and write a new server `(app)/layout.tsx`.
+- Create `use-home-data.ts`, `_components/card.tsx`, `_components/brief.tsx` and `homeContent.tsx`. Point `home/page.tsx` at `HomeContent`.
+- Delete `dashboardContent.tsx`.
+- Extend `fixtures.ts`. Create `e2e/home.spec.ts`.
+
+**Interfaces:**
+- `useHomeData(range: Range)` returns `{ status: "loading" | "ready", txns: Txn[], prevTxns: Txn[], reminders: Reminder[], forecast: { dates: string[]; values: number[] } | null, toReview: number }`.
+ - It uses `Promise.allSettled`, so a failed call yields an empty value for that card, not an error page.
+ - `type Reminder = { id: string|number; vendor: string; amount: number|null; date: string; type: string; category?: string }`.
+- `HomeCard({ title, href?, children, loading, skeletonHeight })` renders the `label-mono` title row (with `›` when `href` is set) and a card. While `loading` it shows a `Skeleton` of `skeletonHeight` px instead of `children`.
+- `signIn(page, { home: { txns?, prevTxns?, reminders?, forecast?: object | "fail", delayMs? } })`. Route the transactions stub by its `date_from` query param, returning the current or previous set.
+- Layout from the mockup:
+ - **Greeting row:** "Good {morning|afternoon|evening}, {first name}" at 24px/500, with the upload and export icon buttons on the right.
+ - **Brief:** below the greeting, `font-serif` 21px, max-w-[820px].
+ - **Band:** `absolute inset-x-0 top-0 h-[260px] bg-(--band) pointer-events-none` behind both.
+ - **Grid:** `grid gap-3.5 lg:grid-cols-[1fr_330px]`.
+
+- [ ] **Step 1: Write the failing e2e tests** in `home.spec.ts`:
+ - **`home greets and briefs`:** with the fixture's txns totalling 18412 this month and 20280 for the same days last month, the page title starts "Home", the heading matches `/^Good (morning|afternoon|evening), E2E$/`, and the brief contains "lighter than".
+ - **`app pages set a light theme-color`:** `meta[name=theme-color]` on `/home` has content `#f4f3ef`, and on `/login` it is still `#050505`.
+ - **`home has no layout shift while loading`:**
+ 1. Use `delayMs: 1500`.
+ 2. Add an init script that sums `PerformanceObserver({type:"layout-shift", buffered:true})` values, excluding entries with `hadRecentInput`.
+ 3. After the brief is visible and 500 ms more have passed, expect the sum to be `< 0.01`.
+ - **`old dashboard is gone`:** no text "Financial Overview" and no "What to watch".
+- [ ] **Step 2: Run them.** The expected result is FAIL: the old dashboard, a dark theme-color and the old heading.
+- [ ] **Step 3: Implement** according to the Interfaces above. The brief uses `briefSentence` with `useMoney()`. Month names come from `toLocaleString("en-IN", { month: "long" })`.
+- [ ] **Step 4: Run** `npm run test:e2e`. The expected result is that everything passes, including Phases 0 and 1.
+- [ ] **Step 5: Commit** with `feat: Home frame (greeting, sky band, brief) with no-shift skeletons and light theme-color`.
+
+### Task 3: Spent card (cumulative chart, forecast, bill pins, range chips)
+
+**Files:** create `_components/spend-card.tsx` and wire it into `homeContent.tsx`. Tests go in `home.spec.ts`.
+
+**Interfaces:**
+- `SpendCard({ range, onRange, cur: number[], prev: number[], forecast, reminders, today, loading })`. `HomeContent` owns `range` state, defaulting to `"1M"`, and passes it to `useHomeData`.
+- **Header:** the title "SPENT THIS {WEEK|MONTH|QUARTER|YEAR}" (3M → QUARTER), the big total `cur[todayIndex]` with a one-time count-up, and the delta line "↓ ₹1,830 vs Sep at this point". The delta line is `text-good` when spending is down and `text-bad` when up, and is hidden when `sameDayDelta` is null. A legend sits on the right.
+- **SVG** (`viewBox 0 0 860 170`, `preserveAspectRatio="none"` on the paths only; text stays crisp in a separate HTML axis row):
+ - The current series drawn up to today as a solid `--primary` line with a gradient area fill.
+ - The previous series as a dashed `muted-foreground` line at 60% opacity, across the full period.
+ - Forecast, only for the 1M range when `forecast` is present: a dotted `--primary` line from today's cumulative value, adding `forecast.values` day by day until the month ends.
+ - Bill pins from reminders with `type === "upcoming"` and a date inside the future part of the period. Each pin is a `--bad` stroked circle with a mono label.
+ - A today marker line.
+- **Axis:** 5 ticks (first day, ~25%, ~50%, today, last day). Dates use `label-mono`.
+- **Chips:** 1W, 1M, 3M and YTD, as `button`s with `aria-pressed`.
+
+- [ ] **Step 1: Write the failing tests:**
+ - **`spend card shows total and delta`:** the text "₹18,412" is visible and the delta is "↓ ₹1,868 vs Sep at this point".
+ - **`range chips switch period`:** clicking "1W" changes the title to "SPENT THIS WEEK" and gives "1W" `aria-pressed="true"`.
+ - **`chart survives a failed forecast`:** with `forecast: "fail"`, the card renders, its svg has the solid series path and has no `[data-series=forecast]`.
+ - **`bill pins show upcoming bills`:** a reminder for Netflix 3 days ahead gives an svg text "Netflix".
+ - **`new user sees empty states`:** with no txns, the card shows "Nothing spent yet in {Month}" and no `NaN` appears anywhere on the page.
+- [ ] **Step 2: Run them.** The expected result is FAIL.
+- [ ] **Step 3: Implement.** Map x as `i / (days-1) * 860` and y as `160 - v / max * 140`, where max is the largest of cur, prev and forecast end values (and 1 if all are 0). Give each path `data-series="current|previous|forecast"` for the tests.
+- [ ] **Step 4: Run** `npm run test:e2e`. Everything should pass.
+- [ ] **Step 5: Commit** with `feat: Home spend card with last-period comparison, forecast and bill pins`.
+
+### Task 4: Lumen found, spending calendar, recent transactions, upcoming bills
+
+**Files:** create `_components/lumen-found.tsx`, `calendar-card.tsx` and `bills-card.tsx`. Tests go in `home.spec.ts`.
+
+**Interfaces:**
+- **`LumenFound({ toReview })`.** Title "LUMEN FOUND". When `toReview > 0`, it shows one action tile: tag "RECEIPTS", a serif headline "{n} receipt(s) need a glance", the line "Lumen wasn't sure about these. Nothing is saved until you approve.", and a link "REVIEW →" to `/receipts?tab=review`. When `toReview` is 0, it shows "Nothing needs you right now." The tile grid is `grid-cols-1 sm:grid-cols-3`, ready for Phase 3's three tiles.
+- **`CalendarCard({ txns, period })`** has two halves:
+ - **Left: "SPENDING CALENDAR".** A grid with 7 columns, Monday first, with leading blanks. Each cell shows the day number and the amount (`₹1.2k` style when it's 1000 or more). The background is `rgb(from var(--primary) r g b / heat)`. Cells use white text when heat > 0.5. This card always shows the current calendar month, whatever range is chosen.
+ - **Right: "RECENT TRANSACTIONS".** The 5 newest transactions by date: vendor, then "Category · 20 Oct", then the amount. Each row uses `truncate` with `min-w-0`.
+- **`BillsCard({ reminders, today })`.** Title "UPCOMING BILLS". It lists the next 3 reminders with `type === "upcoming"` and a date ≥ today. Each row has a date chip (weekday in mono plus the day number), the vendor and the amount. The empty state is "No bills coming up in the next month."
+
+- [ ] **Step 1: Write the failing tests:**
+ - **`lumen found links to review`:** with `reviewCount: 2`, a tile "2 receipts need a glance" links to `/receipts?tab=review`. With 0, the text is "Nothing needs you right now."
+ - **`calendar shows amounts by day`:** with a fixture of ₹2,210 on the 4th, the cell for "4" contains "₹2.2k".
+ - **`recent transactions list newest first`:** the first row shows "Swiggy".
+ - **`long names truncate`:** with the vendor "Bharat Sanchar Nigam Limited Postpaid Services India" at ₹15,00,000, the row's height equals a short row's height, ±1px.
+ - **`bills card lists next three`:** given 4 upcoming reminders, 3 rows are shown and the soonest is first.
+- [ ] **Step 2: Run them.** The expected result is FAIL.
+- [ ] **Step 3: Implement** according to the Interfaces above.
+- [ ] **Step 4: Run** `npm run test:e2e`. Everything should pass.
+- [ ] **Step 5: Commit** with `feat: Home cards for Lumen found, spending calendar, recent transactions and bills`.
+
+### Task 5: Budget empty state, draft-budget promo, activity; cleanup and ship
+
+**Files:** create `_components/budget-card.tsx` and `activity-card.tsx`; delete the unused old components; update docs.
+
+**Interfaces:**
+- **`BudgetCard()`.** Title "BUDGET · {MONTH}". The empty state is "No budget yet", plus the line "Lumen can draft one from your last 3 months."
+- **`DraftBudgetPromo()`.**
+ - The card background is `bg-accent-soft` with no border.
+ - The heading is "Let Lumen draft your budget".
+ - The body is "Built from 3 months of your actual spending. You approve every line before it's saved."
+ - A disabled pill button reads "COMING SOON" with `aria-disabled`.
+- **`ActivityCard({ txns, now })`.** Title "ACTIVITY". It shows the 5 newest transactions by `created_at` as "Added {vendor} {₹amount}" with `relativeTime` on the right. Empty state: "Nothing imported yet. Upload a receipt or a statement to get started." Include a link to `/receipts?tab=import`.
+
+- [ ] **Step 1: Write the failing tests:**
+ - **`budget is honest until Phase 3`:** "No budget yet" is visible, and the "COMING SOON" button is disabled.
+ - **`activity shows newest imports`:** with `created_at` set 2h ago, the text "2h ago" is visible.
+ - **`side column order`:** the side-column card titles are, in order, Budget, Upcoming bills, Let Lumen draft your budget, Activity.
+- [ ] **Step 2: Run them.** The expected result is FAIL.
+- [ ] **Step 3: Implement** according to the Interfaces above.
+- [ ] **Step 4:** Delete `section-cards.tsx` and `animatedListItemUse.tsx` if `grep -rn` shows no other importers. Then run:
+ - `npx tsc --noEmit`, `npm run lint` and `npm run build` with the dummy env. All should be clean, and `/home` should stay static (○).
+ - `npm run test:e2e`: everything passes.
+- [ ] **Step 5:**
+ - Update `docs/CODEBASE-GUIDE.md` §7b2 with Home's cards, data sources, the interim rules and where Phase 3 plugs in.
+ - In spec §12, tick the theme-color and band-contrast items, and add "Phase 3: replace interim brief, Lumen found, activity; wire budget" and "Later: Home sub-tabs (Overview / This month / Year)".
+ - Add a STORY entry.
+- [ ] **Step 6:** Run the whole-branch review (most capable model) with this plan's Review Focus. Fix Critical and Important findings with RED→GREEN tests. Push `ui-revamp/phase2-home` and open the PR.
diff --git a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
index 5029257..2a1ec73 100644
--- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
+++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
@@ -208,8 +208,8 @@ Keep `docs/CODEBASE-GUIDE.md` current in each PR.
Small items found while building, parked here so they aren't lost. Tick them off in the PR that fixes them.
-- [ ] **Phase 2.** The `theme-color` meta is still `#050505` inside the light app, so the mobile browser bar stays black. Make it per route group (`viewport` export in `(app)/layout`). *(Phase 0 review)*
-- [ ] **Phase 2.** Muted text on the `#cfe2fb` end of `--band` is about 4.2:1. Use `foreground` for text on the band. *(Phase 0 review)*
+- [x] **Phase 2.** The `theme-color` meta is still `#050505` inside the light app, so the mobile browser bar stays black. Make it per route group (`viewport` export in `(app)/layout`). *(Phase 0 review)*
+- [x] **Phase 2.** Muted text on the `#cfe2fb` end of `--band` is about 4.2:1. Use `foreground` for text on the band. *(Phase 0 review)*
- [ ] **Phase 4.** The Ask Lumen chat still uses the dark `.bui` palette inside the light shell. Restyle it to Sky. *(Phase 0)*
- [ ] **Phase 6.** The global reduced-motion rule also stops loading spinners. Consider exempting spinners with `motion-safe:` or giving them a static "Loading…" label. *(Phase 0 review)*
- [ ] **Phase 6.** The e2e suite is local and Chromium-only. Add Firefox once Windows allows Playwright's build (`E2E_FIREFOX=1`), and consider running it in CI with the `signIn` fixture. *(Phase 0)*
@@ -220,4 +220,12 @@ Small items found while building, parked here so they aren't lost. Tick them off
- [ ] **Phase 6.** iOS Safari 15 and older don't support `overflow-x: clip`, so wide landing marquees could scroll sideways there. If that matters, add an `@supports not (overflow-x: clip)` fallback (not `hidden` on body). *(Phase 1 review)*
- [ ] **Phase 4.** Ctrl/⌘+K checks `e.key === "k"`, so Caps Lock or Shift misses it. Use `e.code === "KeyK"`. *(Phase 1 review)*
- [ ] **Phase 6.** The sidebar cookie expires after 7 days (shadcn default), so a collapsed rail reopens after a week away. Extend it to about a year. *(Phase 1 review)*
+- [ ] **Phase 3.** Replace Home's interim parts: the brief (summary endpoint), Lumen found (subscriptions, spikes, duplicates), Activity (a real feed, including statement imports dated earlier than this month), and wire up Budget and the draft-budget promo. *(Phase 2)*
+- [ ] **Later.** Home sub-tabs (Overview / This month / Year). The chart's range chips cover this for now. *(Phase 2 decision)*
+- [ ] **Phase 5.** The Export and Upload buttons on Home still use the old loud blue `Button` and the dark upload dialog. Restyle them with the dialogs. *(Phase 2)*
+- [ ] **Phase 3.** The calendar's `kShort` hardcodes `₹`; use the user's currency symbol like `useMoney`. *(Phase 2 review)*
+- [ ] **Phase 3.** `sameDayDelta` only guards a zero previous total. A refund-heavy start of month (negative running total) flips the brief's "lighter"/"heavier". Use `then <= 0`. *(Phase 2 review)*
+- [ ] **Phase 6.** `CountUp` paints the final number for one frame before counting from 0. Start at 0 when it will animate. *(Phase 2 review)*
+- [ ] **Phase 6.** Measure each Home card's real content height against its `skeletonHeight` (the spend card's is estimated at about 270px vs 262px); the CLS test threshold of 0.01 can hide a small shift. *(Phase 2 review)*
+- [ ] **Phase 3.** Confirm statement imports write rows before the POST returns. Otherwise a Home visit in that window caches stale totals for 5 minutes. *(Phase 2 review)*
- [ ] **Owner, manual.** In Firefox, check that the profile/avatar menu doesn't shift the Ask Lumen column, and click through the old pages on light in a demo session. *(Phase 0 PR #89)*
diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts
index 08c2f73..e1efc5f 100644
--- a/frontend/e2e/fixtures.ts
+++ b/frontend/e2e/fixtures.ts
@@ -5,7 +5,16 @@ import type { Page } from "@playwright/test";
// (key = sb--auth-token; the e2e host is 127.0.0.1).
// - the `lumen_session` cookie gets past middleware.
// - every backend call (NEXT_PUBLIC_BACKEND_URL in playwright.config.ts) is stubbed.
-export async function signIn(page: Page, opts: { reviewCount?: number } = {}): Promise {
+export type HomeFixture = {
+ txns?: { id: string; vendor_name: string; date: string; total_amount: number; category: string | null; created_at: string | null }[];
+ reminders?: { id: string; vendor: string; amount: number; date: string; type: string; category?: string }[];
+ forecast?: { dates: string[]; values: number[] } | "fail";
+ delayMs?: number;
+ forecastDelayMs?: number;
+};
+
+export async function signIn(page: Page, opts: { reviewCount?: number; home?: HomeFixture } = {}): Promise {
+ const home = opts.home ?? {};
await page.context().addCookies([{ name: "lumen_session", value: "1", domain: "localhost", path: "/" }]);
await page.addInitScript(() => {
const now = Math.floor(Date.now() / 1000);
@@ -32,8 +41,28 @@ export async function signIn(page: Page, opts: { reviewCount?: number } = {}): P
note: null,
created_at: null,
}));
- await page.route("http://127.0.0.1:9/**", (route) => {
- const path = new URL(route.request().url()).pathname;
+ await page.route("http://127.0.0.1:9/**", async (route) => {
+ const url = new URL(route.request().url());
+ const path = url.pathname;
+ if (home.delayMs) await new Promise((r) => setTimeout(r, home.delayMs));
+ if (path === "/transactions") {
+ const from = url.searchParams.get("date_from") ?? "0000";
+ const to = url.searchParams.get("date_to") ?? "9999";
+ const all = (home.txns ?? []).filter((t) => t.date >= from && t.date <= to).sort((a, b) => b.date.localeCompare(a.date));
+ // like the real API: at most 100 rows per page
+ const size = Math.min(Number(url.searchParams.get("page_size") ?? 10), 100);
+ const page = Number(url.searchParams.get("page") ?? 1);
+ const data = all.slice((page - 1) * size, page * size);
+ return route.fulfill({ json: { success: true, data, pagination: { page, has_next: page * size < all.length, total_items: all.length } } });
+ }
+ if (path === "/api/analytics/reminders")
+ return route.fulfill({ json: { success: true, reminders: home.reminders ?? [], count: 0 } });
+ if (path === "/api/analytics/forecast") {
+ if (home.forecastDelayMs) await new Promise((r) => setTimeout(r, home.forecastDelayMs));
+ if (home.forecast === "fail") return route.fulfill({ status: 500, json: { error: "boom" } });
+ const f = home.forecast;
+ return route.fulfill({ json: { success: true, forecast: f ? { success: true, forecast: f } : { success: false } } });
+ }
if (path === "/api/review") return route.fulfill({ json: { items: queue } });
const decided = path.match(/^\/api\/review\/([^/]+)\/(approve|reject)$/);
if (decided) {
@@ -44,7 +73,10 @@ export async function signIn(page: Page, opts: { reviewCount?: number } = {}): P
if (path === "/api/agent/proposals") return route.fulfill({ json: { proposals: [] } });
if (path === "/chat/history") return route.fulfill({ json: { success: true, messages: [] } });
if (path === "/chat/suggestions") return route.fulfill({ json: { suggestions: [] } });
- if (path === "/api/profile") return route.fulfill({ json: { display_name: "E2E User", avatar_key: null } });
+ if (path === "/api/profile")
+ return route.fulfill({
+ json: { profile: { display_name: "E2E User", avatar_key: null, currency: "INR", onboarded: true, demo: false } },
+ });
return route.fulfill({ json: {} });
});
// supabase signOut and any other auth call
diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts
new file mode 100644
index 0000000..bf33c7e
--- /dev/null
+++ b/frontend/e2e/home-math.spec.ts
@@ -0,0 +1,126 @@
+// Node-only unit tests (no browser) for the Home calculations.
+import { test, expect } from "@playwright/test";
+import {
+ briefSentence,
+ cumulative,
+ heat,
+ periods,
+ relativeTime,
+ sameDayDelta,
+ type Txn,
+} from "../src/lib/home-math";
+
+const d = (s: string) => {
+ const [y, m, day] = s.split("-").map(Number);
+ return new Date(y, m - 1, day);
+};
+const iso = (x: Date) =>
+ `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}`;
+const txn = (date: string, total_amount: number | null): Txn => ({
+ id: date + total_amount,
+ vendor_name: "V",
+ date,
+ total_amount,
+ category: null,
+ created_at: null,
+});
+const money = (n: number) => "₹" + n.toLocaleString("en-IN");
+const today = d("2026-10-20");
+
+test("periods 1M on Oct 20 is Oct 1..31 vs Sep 1..30", () => {
+ const { current, previous } = periods("1M", today);
+ expect([iso(current.start), iso(current.end), current.days]).toEqual(["2026-10-01", "2026-10-31", 31]);
+ expect([iso(previous.start), iso(previous.end), previous.days]).toEqual(["2026-09-01", "2026-09-30", 30]);
+});
+
+test("periods 1W uses Monday weeks", () => {
+ const { current, previous } = periods("1W", today); // 2026-10-20 is a Tuesday
+ expect([iso(current.start), iso(current.end), current.days]).toEqual(["2026-10-19", "2026-10-25", 7]);
+ expect([iso(previous.start), iso(previous.end)]).toEqual(["2026-10-12", "2026-10-18"]);
+});
+
+test("periods 3M and YTD", () => {
+ const q = periods("3M", today);
+ expect([iso(q.current.start), iso(q.current.end), iso(q.previous.start), iso(q.previous.end)]).toEqual([
+ "2026-08-01",
+ "2026-10-31",
+ "2026-05-01",
+ "2026-07-31",
+ ]);
+ const y = periods("YTD", today);
+ expect([iso(y.current.start), iso(y.current.end), y.current.days, iso(y.previous.start)]).toEqual([
+ "2026-01-01",
+ "2026-12-31",
+ 365,
+ "2025-01-01",
+ ]);
+});
+
+test("cumulative sums by day and ignores out-of-period and null amounts", () => {
+ const { current } = periods("1M", today);
+ const c = cumulative([txn("2026-10-01", 100), txn("2026-10-03", 50), txn("2026-09-30", 999), txn("2026-10-03", null)], current);
+ expect(c.length).toBe(31);
+ expect(c.slice(0, 4)).toEqual([100, 100, 150, 150]);
+ expect(c[30]).toBe(150);
+});
+
+test("same-day comparison clamps to previous month length", () => {
+ const cur = Array.from({ length: 31 }, (_, i) => (i + 1) * 10);
+ const prev = Array.from({ length: 30 }, (_, i) => (i + 1) * 20);
+ expect(sameDayDelta(cur, prev, 30)).toEqual({ abs: 310 - 600, pct: ((310 - 600) / 600) * 100 });
+});
+
+test("sameDayDelta is null when previous is 0", () => {
+ expect(sameDayDelta([5, 10], [0, 0], 1)).toBeNull();
+});
+
+const base = { monthName: "October", prevMonthName: "September", money, today };
+
+test("brief lighter", () => {
+ expect(briefSentence({ ...base, spent: 18412, delta: { pct: -9.2 } })).toBe(
+ "October is running 9% lighter than September.",
+ );
+});
+
+test("brief heavier with next bill", () => {
+ expect(
+ briefSentence({ ...base, spent: 18412, delta: { pct: 12 }, nextBill: { vendor: "Netflix", date: d("2026-10-23") } }),
+ ).toBe("October is running 12% heavier than September. Netflix renews on Friday.");
+});
+
+test("brief with no data", () => {
+ expect(briefSentence({ ...base, spent: 0, delta: null })).toBe("Nothing spent yet in October.");
+});
+
+test("brief with no comparison", () => {
+ expect(briefSentence({ ...base, spent: 18412, delta: null })).toBe("You've spent ₹18,412 so far in October.");
+});
+
+test("next bill more than 6 days out uses a date", () => {
+ expect(
+ briefSentence({ ...base, spent: 18412, delta: null, nextBill: { vendor: "Rent", date: d("2026-11-01") } }),
+ ).toBe("You've spent ₹18,412 so far in October. Rent renews on 1 Nov.");
+});
+
+test("heat", () => {
+ expect(heat(0, 100)).toBe(0);
+ expect(heat(100, 100)).toBe(0.85);
+ expect(heat(1, 100)).toBeGreaterThanOrEqual(0.15);
+});
+
+test("relativeTime", () => {
+ const now = new Date(2026, 9, 20, 12, 0, 0);
+ const ago = (ms: number) => new Date(now.getTime() - ms).toISOString();
+ expect(relativeTime(ago(30_000), now)).toBe("just now");
+ expect(relativeTime(ago(5 * 60_000), now)).toBe("5m ago");
+ expect(relativeTime(ago(2 * 3_600_000), now)).toBe("2h ago");
+ expect(relativeTime(ago(3 * 86_400_000), now)).toBe("3d ago");
+ expect(relativeTime(ago(10 * 86_400_000), now)).toBe("10 Oct");
+});
+
+test("relativeTime treats offset-less backend timestamps as UTC", () => {
+ const now = new Date(Date.UTC(2026, 9, 20, 12, 0, 0));
+ expect(relativeTime("2026-10-20T10:00:00", now)).toBe("2h ago"); // backend: datetime.utcnow().isoformat()
+ expect(relativeTime("2026-10-20T10:00:00Z", now)).toBe("2h ago");
+ expect(relativeTime("2026-10-20T15:30:00+05:30", now)).toBe("2h ago");
+});
diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts
new file mode 100644
index 0000000..d747b3a
--- /dev/null
+++ b/frontend/e2e/home.spec.ts
@@ -0,0 +1,228 @@
+import { test, expect, type Page } from "@playwright/test";
+import { signIn, type HomeFixture } from "./fixtures";
+
+// Dates relative to the real "today" so the Home's period maths sees them in this / last month.
+const now = new Date();
+export const ymd = (y: number, m: number, d: number) => {
+ const x = new Date(y, m, d);
+ return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}`;
+};
+const Y = now.getFullYear();
+const M = now.getMonth();
+export const thisMonth = (day: number) => ymd(Y, M, day);
+export const lastMonth = (day: number) => ymd(Y, M - 1, day);
+export const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
+
+export const tx = (id: string, vendor_name: string, date: string, total_amount: number, category = "Food") => ({
+ id,
+ vendor_name,
+ date,
+ total_amount,
+ category,
+ created_at: hoursAgo(48),
+});
+
+// 18,412 so far this month vs 20,280 by the same day last month → "9% lighter"
+export const typical: HomeFixture = {
+ txns: [tx("a", "Swiggy", thisMonth(1), 18412), tx("b", "Rent", lastMonth(1), 20280)],
+};
+
+export async function openHome(page: Page, home: HomeFixture = typical, reviewCount = 0) {
+ await signIn(page, { home, reviewCount });
+ await page.goto("/home");
+}
+
+test("home greets and briefs", async ({ page }) => {
+ await openHome(page);
+ await expect(page).toHaveTitle(/^Home/);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText(/^Good (morning|afternoon|evening), E2E$/);
+ await expect(page.getByTestId("brief")).toContainText("lighter than");
+});
+
+test("app pages set a light theme-color", async ({ page, request }) => {
+ await openHome(page);
+ await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute("content", "#f4f3ef");
+ const login = await (await request.get("/login")).text();
+ expect(login.includes(''), "login keeps the dark bar").toBe(true);
+});
+
+test("home has no layout shift while loading", async ({ page }) => {
+ await page.addInitScript(() => {
+ (window as unknown as { cls: number }).cls = 0;
+ new PerformanceObserver((list) => {
+ for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[])
+ if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value;
+ }).observe({ type: "layout-shift", buffered: true });
+ });
+ await openHome(page, { ...typical, delayMs: 1500 });
+ await expect(page.getByTestId("brief")).toContainText("lighter than", { timeout: 15_000 });
+ await page.waitForTimeout(500);
+ expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01);
+});
+
+test("old dashboard is gone", async ({ page }) => {
+ await openHome(page);
+ await expect(page.getByTestId("brief")).toBeVisible();
+ await expect(page.getByText("Financial Overview")).toHaveCount(0);
+ await expect(page.getByText("What to watch")).toHaveCount(0);
+});
+
+const spend = (page: Page) => page.getByTestId("spend-card");
+const prevShort = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "short" });
+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`);
+});
+
+test("range chips switch period", async ({ page }) => {
+ await openHome(page);
+ await expect(spend(page)).toContainText("SPENT THIS MONTH", { ignoreCase: true });
+ await spend(page).getByRole("button", { name: "1W" }).click();
+ await expect(spend(page)).toContainText("SPENT THIS WEEK", { ignoreCase: true });
+ await expect(spend(page).getByRole("button", { name: "1W" })).toHaveAttribute("aria-pressed", "true");
+});
+
+test("chart survives a failed forecast", async ({ page }) => {
+ await openHome(page, { ...typical, forecast: "fail" });
+ await expect(spend(page).locator('[data-series="current"]')).toHaveCount(1);
+ await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(0);
+});
+
+test("forecast draws to month end", async ({ page }) => {
+ test.skip(now.getDate() === lastDay, "no future days left this month");
+ 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);
+});
+
+test("bill pins show upcoming bills", async ({ page }) => {
+ test.skip(now.getDate() === lastDay, "no future days left this month");
+ await openHome(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");
+});
+
+test("new user sees empty states", async ({ page }) => {
+ await openHome(page, {});
+ await expect(page.getByTestId("brief")).toContainText("Nothing spent yet");
+ await expect(spend(page)).toContainText("₹0");
+ await expect(page.getByText("NaN")).toHaveCount(0);
+ await expect(spend(page).getByTestId("spend-delta")).toHaveCount(0);
+});
+
+test("changing the chart range never blanks other cards", async ({ page }) => {
+ await openHome(page, { ...typical, delayMs: 800 });
+ await expect(page.getByTestId("brief")).toContainText("lighter than", { timeout: 15_000 });
+ await spend(page).getByRole("button", { name: "1W" }).click();
+ await page.waitForTimeout(200); // mid-refetch
+ await expect(page.getByTestId("brief")).toContainText("lighter than");
+ await expect(spend(page).locator('[data-slot="skeleton"]')).toHaveCount(0);
+});
+
+const inDays = (n: number) => ymd(Y, M, now.getDate() + n);
+
+test("lumen found links to review", async ({ page }) => {
+ await openHome(page, typical, 2);
+ const found = page.getByTestId("lumen-found");
+ await expect(found.getByText("2 receipts need a glance")).toBeVisible();
+ await expect(found.getByRole("link", { name: /review/i })).toHaveAttribute("href", "/receipts?tab=review");
+});
+
+test("lumen found is honest when there is nothing", async ({ page }) => {
+ await openHome(page, typical, 0);
+ await expect(page.getByTestId("lumen-found")).toContainText("Nothing needs you right now.");
+});
+
+test("calendar shows amounts by day", async ({ page }) => {
+ await openHome(page, { txns: [tx("c", "Croma", thisMonth(4), 2210)] });
+ await expect(page.getByTestId("cal-day-4")).toContainText("₹2.2k");
+});
+
+test("recent transactions list newest first", async ({ page }) => {
+ await openHome(page, { txns: [tx("o", "Older", thisMonth(1), 100), tx("n", "Swiggy", thisMonth(2), 200)] });
+ await expect(page.getByTestId("recent-row").first()).toContainText("Swiggy");
+});
+
+test("long names truncate", async ({ page }) => {
+ await openHome(page, {
+ txns: [
+ tx("l", "Bharat Sanchar Nigam Limited Postpaid Services India", thisMonth(2), 1_500_000, "Bills"),
+ tx("s", "Uber", thisMonth(1), 312, "Travel"),
+ ],
+ });
+ const rows = page.getByTestId("recent-row");
+ await expect(rows).toHaveCount(2);
+ const [a, b] = [await rows.nth(0).boundingBox(), await rows.nth(1).boundingBox()];
+ expect(Math.abs(a!.height - b!.height)).toBeLessThanOrEqual(1);
+});
+
+test("bills card lists next three", async ({ page }) => {
+ const r = (id: string, vendor: string, n: number) => ({ id, vendor, amount: 100, date: inDays(n), type: "upcoming" });
+ await openHome(page, { ...typical, reminders: [r("4", "Rent", 9), r("1", "Netflix", 1), r("3", "Gym", 6), r("2", "Spotify", 3)] });
+ const rows = page.getByTestId("bill-row");
+ await expect(rows).toHaveCount(3);
+ await expect(rows.first()).toContainText("Netflix");
+});
+
+test("budget is honest until Phase 3", async ({ page }) => {
+ await openHome(page);
+ await expect(page.getByText("No budget yet")).toBeVisible();
+ await expect(page.getByRole("button", { name: "COMING SOON" })).toBeDisabled();
+});
+
+test("activity shows newest imports", async ({ page }) => {
+ await openHome(page, { txns: [{ ...tx("r", "Swiggy", thisMonth(1), 486), created_at: hoursAgo(2) }] });
+ await expect(page.getByTestId("activity")).toContainText("2h ago");
+ await expect(page.getByTestId("activity")).toContainText("Added Swiggy");
+});
+
+test("side column order", async ({ page }) => {
+ await openHome(page);
+ const side = page.getByTestId("home-side");
+ await expect(side.locator(":scope > section").first()).toBeVisible();
+ const titles = await side.locator(":scope > section").evaluateAll((els) =>
+ els.map((e) => (e.querySelector("h2, h3, a")?.textContent ?? "").replace(" ›", "").trim().toLowerCase()),
+ );
+ expect(titles.map((t) => t.split(" · ")[0])).toEqual(["budget", "upcoming bills", "let lumen draft your budget", "activity"]);
+});
+
+test("sky band is drawn behind the greeting", async ({ page }) => {
+ await openHome(page);
+ const bg = await page.getByTestId("sky-band").evaluate((e) => getComputedStyle(e).backgroundImage);
+ expect(bg).toContain("linear-gradient");
+});
+
+test("totals include every transaction, not just the first page", async ({ page }) => {
+ const many = Array.from({ length: 140 }, (_, i) => tx(`m${i}`, "Kirana", thisMonth(1), 100));
+ await openHome(page, { txns: many });
+ await expect(spend(page).getByText("₹14,000", { exact: true })).toBeVisible();
+});
+
+test("a slow forecast never holds up the rest of Home", async ({ page }) => {
+ const forecastReq = page.waitForRequest((r) => r.url().includes("/api/analytics/forecast"));
+ await openHome(page, { ...typical, forecastDelayMs: 6000 });
+ await expect(page.getByTestId("brief")).toContainText("lighter than", { timeout: 2500 });
+ expect((await forecastReq).url()).toContain("insights=0");
+});
+
+test("calendar shading works without relative colour syntax", async ({ page }) => {
+ await openHome(page, { txns: [tx("c", "Croma", thisMonth(4), 2210)] });
+ expect(await page.getByTestId("cal-day-4").getAttribute("style")).toContain("color-mix");
+});
+
+test("spend card has a real empty state for a new user", async ({ page }) => {
+ await openHome(page, {});
+ await expect(spend(page)).toContainText("Nothing spent yet this month");
+ await expect(spend(page).locator('[data-series="current"]')).toHaveCount(0);
+});
+
+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);
+});
diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx
new file mode 100644
index 0000000..76a17a4
--- /dev/null
+++ b/frontend/src/app/(app)/home/_components/activity-card.tsx
@@ -0,0 +1,35 @@
+import Link from "next/link";
+import { useMoney } from "@/lib/format";
+import { relativeTime, type Txn } from "@/lib/home-math";
+import { HomeCard } from "./card";
+
+// Interim activity feed: the newest imports by when they arrived (Phase 3 may replace it with a real feed).
+export function ActivityCard({ txns, now, loading }: { txns: Txn[]; now: Date; loading: boolean }) {
+ const money = useMoney();
+ const items = txns
+ .filter((t) => t.created_at)
+ .sort((a, b) => b.created_at!.localeCompare(a.created_at!))
+ .slice(0, 5);
+ return (
+
+ {items.length === 0 ? (
+
+ Nothing imported yet.{" "}
+
+ Upload a receipt or a statement
+ {" "}
+ to get started.
+
+ — now
+ ┄ before
+ {fc.length > 0 && · forecast}
+
+
+
+ {empty ? (
+
+
+ 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}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/app/(app)/home/page.tsx b/frontend/src/app/(app)/home/page.tsx
index 1a31dc8..1ad007a 100644
--- a/frontend/src/app/(app)/home/page.tsx
+++ b/frontend/src/app/(app)/home/page.tsx
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
-import DashboardContent from "./dashboardContent";
+import HomeContent from "./homeContent";
export const metadata: Metadata = { title: "Home" };
export default function Page() {
- return ;
+ return ;
}
diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts
new file mode 100644
index 0000000..f0613f6
--- /dev/null
+++ b/frontend/src/app/(app)/home/use-home-data.ts
@@ -0,0 +1,109 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { aiAnalyticsApi, reviewApi, transactionApi } from "@/lib/api/client";
+import { periods, type Range, type Txn } from "@/lib/home-math";
+
+export type Reminder = { id: string | number; vendor: string; amount: number | null; date: string; type: string; category?: string };
+export type Forecast = { dates: string[]; values: number[] } | null;
+export type HomeData = {
+ status: "loading" | "ready";
+ txns: Txn[];
+ prevTxns: Txn[];
+ monthTxns: Txn[];
+ prevMonthTxns: Txn[];
+ reminders: Reminder[];
+ forecast: Forecast;
+ toReview: number;
+};
+
+const ymd = (d: Date) =>
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+// The API returns at most 100 rows a page, so follow has_next; a period's totals need every row.
+// ponytail: client-side paging, capped at 50 pages (5,000 rows); Phase 3's summary endpoint sums on the server.
+async function fetchTxns(from: Date, to: Date): Promise {
+ const rows: Txn[] = [];
+ for (let page = 1; page <= 50; page++) {
+ const r: { data?: Txn[]; pagination?: { has_next?: boolean } } = await transactionApi.getTransactions({
+ date_from: ymd(from),
+ date_to: ymd(to),
+ page,
+ page_size: 100,
+ sort_by: "date",
+ sort_order: "desc",
+ });
+ rows.push(...(r.data ?? []));
+ if (!r.pagination?.has_next) break;
+ }
+ return rows;
+}
+const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fulfilled" ? r.value : fallback);
+
+// Everything Home shows, from endpoints that exist today (Phase 3 swaps parts of this for a summary endpoint).
+// A failed call empties its card instead of breaking the page. Three independent loads:
+// - transactions, per chart range (skeletons only on the very first load; later range changes keep the old
+// numbers until the new ones arrive, so a click on the chart never blanks the brief or another card)
+// - reminders + review count, once
+// - the forecast, once, numbers only (no LLM text); it never holds up the page, the dotted line just appears
+type Txns = Pick;
+type Extras = Pick;
+
+export function useHomeData(range: Range, today: Date): HomeData {
+ const [txns, setTxns] = useState(null);
+ const [extras, setExtras] = useState(null);
+ const [forecast, setForecast] = useState(null);
+ const day = today.toDateString();
+
+ useEffect(() => {
+ let live = true;
+ const { current, previous } = periods(range, today);
+ // the brief and calendar are always about this calendar month, whatever range the chart shows
+ const month = periods("1M", today);
+ Promise.allSettled([
+ fetchTxns(current.start, current.end),
+ fetchTxns(previous.start, previous.end),
+ range === "1M" ? Promise.resolve(null) : fetchTxns(month.current.start, month.current.end),
+ range === "1M" ? Promise.resolve(null) : fetchTxns(month.previous.start, month.previous.end),
+ ]).then(([cur, prev, monthCur, monthPrev]) => {
+ if (!live) return;
+ const t = value(cur, []);
+ const p = value(prev, []);
+ setTxns({ txns: t, prevTxns: p, monthTxns: value(monthCur, null) ?? t, prevMonthTxns: value(monthPrev, null) ?? p });
+ });
+ return () => {
+ live = false;
+ };
+ // today is a fresh Date each render; the day is what matters
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [range, day]);
+
+ useEffect(() => {
+ let live = true;
+ Promise.allSettled([
+ aiAnalyticsApi.getReminders(31).then((r: { reminders?: Reminder[] }) => r.reminders ?? []),
+ reviewApi.list().then((items) => items.length),
+ ]).then(([reminders, toReview]) => {
+ if (live) setExtras({ reminders: value(reminders, []), toReview: value(toReview, 0) });
+ });
+ aiAnalyticsApi
+ .getForecast(31, false)
+ .then((r: { forecast?: { success?: boolean; forecast?: Forecast } }) =>
+ r.forecast?.success === false ? null : (r.forecast?.forecast ?? null),
+ )
+ .then((f) => live && setForecast(f), () => {});
+ return () => {
+ live = false;
+ };
+ }, [day]);
+
+ return {
+ status: txns && extras ? "ready" : "loading",
+ txns: txns?.txns ?? [],
+ prevTxns: txns?.prevTxns ?? [],
+ monthTxns: txns?.monthTxns ?? [],
+ prevMonthTxns: txns?.prevMonthTxns ?? [],
+ reminders: extras?.reminders ?? [],
+ toReview: extras?.toReview ?? 0,
+ forecast,
+ };
+}
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx
index 45ebc15..9b98203 100644
--- a/frontend/src/app/(app)/layout.tsx
+++ b/frontend/src/app/(app)/layout.tsx
@@ -1,43 +1,10 @@
-"use client";
+import type { ReactNode } from "react";
+import type { Viewport } from "next";
+import { AppShell } from "@/components/app-shell";
-import { Suspense, type ReactNode } from "react";
-import { MotionConfig } from "motion/react";
-import { AppSidebar } from "@/components/app-sidebar";
-import { AuthGuard } from "@/components/auth/auth-guard";
-import { ProfileProvider } from "@/components/profile/profile-provider";
-import { AvatarMenu } from "@/components/avatar-menu";
-import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
+// Server file only so the light app can set its own browser-bar colour (a client layout can't export `viewport`).
+export const viewport: Viewport = { themeColor: "#f4f3ef", colorScheme: "light" };
-// The signed-in app's frame: auth check, sidebar and top bar are built once and stay mounted while you move between
-// pages (URLs are unchanged; "(app)" is a route group), so only the content area swaps, like tabs on X.
export default function AppLayout({ children }: { children: ReactNode }) {
- return (
- // Sky (globals.css) wraps everything, auth loader included, so the first server paint is already light.
-
-
-
-
-
- {/* Only ever rendered client-side (the auth guard shows a loader on the server), so reading the remembered
- state straight from the cookie gives the right width on the sidebar's first paint: no jump, no SSR read. */}
-
-
-
-
-
- );
-}
diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx
new file mode 100644
index 0000000..f6d215d
--- /dev/null
+++ b/frontend/src/components/app-shell.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { Suspense, type ReactNode } from "react";
+import { MotionConfig } from "motion/react";
+import { AppSidebar } from "@/components/app-sidebar";
+import { AuthGuard } from "@/components/auth/auth-guard";
+import { ProfileProvider } from "@/components/profile/profile-provider";
+import { AvatarMenu } from "@/components/avatar-menu";
+import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
+
+// The signed-in app's frame: auth check, sidebar and top bar are built once and stay mounted while you move between
+// pages, so only the content area swaps, like tabs on X. Rendered by app/(app)/layout.tsx.
+export function AppShell({ children }: { children: ReactNode }) {
+ return (
+ // Sky (globals.css) wraps everything, auth loader included, so the first server paint is already light.
+
+
+
+
+
+ {/* Only ever rendered client-side (the auth guard shows a loader on the server), so reading the remembered
+ state straight from the cookie gives the right width on the sidebar's first paint: no jump, no SSR read. */}
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/section-cards.tsx b/frontend/src/components/section-cards.tsx
deleted file mode 100644
index f81cd0d..0000000
--- a/frontend/src/components/section-cards.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-"use client";
-
-import { IconTrendingUp } from "@tabler/icons-react";
-import { Badge } from "@/components/ui/badge";
-import {
- Card,
- CardAction,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { useEffect, useState } from "react";
-import { EmailStatusCard } from "./emailStatusCard";
-import { eventBus, EVENTS } from "@/lib/events";
-import { transactionApi, tokenManager } from "@/lib/api/client";
-
-import { logger } from "@/lib/logger";
-interface Invoice {
- id: number;
- status: string;
- amount_due: number | null;
- confidence_score: number | null;
- created_at: string;
-}
-
-export function SectionCards() {
- const [invoices, setInvoices] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- const fetchInvoices = async () => {
- setLoading(true);
- try {
- logger.debug("📊 SectionCards: Fetching invoices from API...");
-
- // Always fetch from API with hardcoded user ID
- const response = await transactionApi.getTransactions({
- page: 1,
- page_size: 1000,
- sort_by: "created_at",
- sort_order: "desc",
- });
- if (response.success && response.data) {
- logger.debug(
- `📊 SectionCards: Fetched ${response.data.length} transactions`
- );
- // Map transaction data to invoice format for cards
- const mappedInvoices = response.data.map((t: any) => ({
- id: t.id,
- status: "completed",
- amount_due: t.total_amount,
- confidence_score: t.confidence_score,
- created_at: t.created_at,
- }));
- setInvoices(mappedInvoices);
- } else {
- console.warn("📊 SectionCards: No data received from API");
- setInvoices([]);
- }
- } catch (error) {
- console.error(
- "📊 SectionCards: Error fetching invoices:",
- error
- );
- // Fallback to localStorage on error
- const storedInvoices = localStorage.getItem("invoices");
- const invoicesData = storedInvoices
- ? JSON.parse(storedInvoices)
- : [];
- setInvoices(invoicesData);
- } finally {
- setLoading(false);
- }
- };
-
- fetchInvoices();
-
- // Listen for invoice updates and refresh
- const unsubscribe = eventBus.on(EVENTS.INVOICE_UPDATED, () => {
- logger.debug("📊 SectionCards: Refreshing due to invoice update");
- fetchInvoices();
- });
-
- // Cleanup listener on unmount
- return unsubscribe;
- }, []);
-
- // Calculate statistics based on database status only (no confidence filtering)
- const totalInvoices = invoices.length;
-
- // "completed" status means ready for payment (approved)
- const approvedInvoices = invoices.filter((inv) => {
- return inv.status === "completed";
- }).length;
-
- // "pending", "processing", or "failed" means needs review
- const pendingInvoices = invoices.filter((inv) => {
- return (
- inv.status === "pending" ||
- inv.status === "processing" ||
- inv.status === "failed"
- );
- }).length;
-
- if (loading) {
- return (
- <>
-
-
-
-
- >
- );
- }
-
- return (
- <>
- {/* Email Status Card */}
-
-
- {/* Total Invoices Card */}
-
-
-
- Total Invoices
-
-
- {totalInvoices}
-
-
-
-
- All time
-
-
-
-
-