From 0dca8c778399a76ff9ee06fc86c6df481bcd27b2 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:04:56 +0530 Subject: [PATCH 1/7] docs: UI revamp Phase 2 (Home UI) plan --- .../2026-10-20-ui-revamp-phase2-home-ui.md | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-20-ui-revamp-phase2-home-ui.md 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. From d598ef91e66465cb8613e21e856115c73b14bb01 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:10:13 +0530 Subject: [PATCH 2/7] feat: home math (periods, cumulative spend, brief sentence) with unit tests --- frontend/e2e/home-math.spec.ts | 119 +++++++++++++++++++++++++++++++++ frontend/src/lib/home-math.ts | 119 +++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 frontend/e2e/home-math.spec.ts create mode 100644 frontend/src/lib/home-math.ts diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts new file mode 100644 index 0000000..ca60b1f --- /dev/null +++ b/frontend/e2e/home-math.spec.ts @@ -0,0 +1,119 @@ +// 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"); +}); diff --git a/frontend/src/lib/home-math.ts b/frontend/src/lib/home-math.ts new file mode 100644 index 0000000..eb840bf --- /dev/null +++ b/frontend/src/lib/home-math.ts @@ -0,0 +1,119 @@ +// Pure calculations behind the Home cards (UI revamp Phase 2). No React, no fetching, so they're unit-tested in +// e2e/home-math.spec.ts and can later be swapped for the Phase 3 summary endpoint. Dates are local, day-precision. + +export type Txn = { + id: string | number; + vendor_name: string | null; + date: string; // YYYY-MM-DD + 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 midnight + +const DAY = 86_400_000; + +export const parseDay = (s: string) => { + const [y, m, d] = s.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); +}; +// round() absorbs the 23h/25h days around daylight-saving changes +export const dayIndex = (p: Period, day: Date) => Math.round((day.getTime() - p.start.getTime()) / DAY); + +const period = (start: Date, end: Date): Period => ({ start, end, days: Math.round((end.getTime() - start.getTime()) / DAY) + 1 }); +const months = (y: number, m: number, n: number) => period(new Date(y, m, 1), new Date(y, m + n, 0)); + +export function periods(range: Range, today: Date): { current: Period; previous: Period } { + const y = today.getFullYear(); + const m = today.getMonth(); + switch (range) { + case "1W": { + const monday = new Date(y, m, today.getDate() - ((today.getDay() + 6) % 7)); + const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6); + const prevMonday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() - 7); + const prevSunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() - 1); + return { current: period(monday, sunday), previous: period(prevMonday, prevSunday) }; + } + case "1M": + return { current: months(y, m, 1), previous: months(y, m - 1, 1) }; + case "3M": + return { current: months(y, m - 2, 3), previous: months(y, m - 5, 3) }; + case "YTD": + return { current: months(y, 0, 12), previous: months(y - 1, 0, 12) }; + } +} + +export function cumulative(txns: Txn[], p: Period): number[] { + const daily = new Array(p.days).fill(0); + for (const t of txns) { + if (t.total_amount == null || !t.date) continue; + const i = dayIndex(p, parseDay(t.date)); + if (i >= 0 && i < p.days) daily[i] += t.total_amount; + } + for (let i = 1; i < daily.length; i++) daily[i] += daily[i - 1]; + return daily; +} + +export function sameDayDelta(cur: number[], prev: number[], index: number): { abs: number; pct: number } | null { + if (!cur.length || !prev.length) return null; + const now = cur[Math.min(index, cur.length - 1)]; + const then = prev[Math.min(index, prev.length - 1)]; + if (!then) return null; + return { abs: now - then, pct: ((now - then) / then) * 100 }; +} + +const WEEKDAY = new Intl.DateTimeFormat("en-GB", { weekday: "long" }); +const DAY_MONTH = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short" }); + +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 { + let s: string; + if (a.spent <= 0) s = `Nothing spent yet in ${a.monthName}.`; + else if (!a.delta) s = `You've spent ${a.money(Math.round(a.spent))} so far in ${a.monthName}.`; + else { + const pct = Math.round(Math.abs(a.delta.pct)); + s = + pct === 0 + ? `${a.monthName} is running level with ${a.prevMonthName}.` + : `${a.monthName} is running ${pct}% ${a.delta.pct < 0 ? "lighter" : "heavier"} than ${a.prevMonthName}.`; + } + if (a.nextBill) { + const inDays = Math.round((a.nextBill.date.getTime() - a.today.getTime()) / DAY); + const when = inDays <= 6 ? WEEKDAY.format(a.nextBill.date) : DAY_MONTH.format(a.nextBill.date); + s += ` ${a.nextBill.vendor} renews on ${when}.`; + } + return s; +} + +export function calendarBuckets(txns: Txn[], p: Period): { day: number; amount: number }[] { + const out = Array.from({ length: p.days }, (_, i) => ({ day: i + 1, amount: 0 })); + for (const t of txns) { + if (t.total_amount == null || !t.date) continue; + const i = dayIndex(p, parseDay(t.date)); + if (i >= 0 && i < p.days) out[i].amount += t.total_amount; + } + return out; +} + +export function heat(amount: number, max: number): number { + if (amount <= 0 || max <= 0) return 0; + return Math.min(0.15 + (amount / max) * 0.7, 0.85); +} + +export function relativeTime(iso: string, now: Date): string { + const at = new Date(iso); + const s = Math.max(0, (now.getTime() - at.getTime()) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86_400) return `${Math.floor(s / 3600)}h ago`; + if (s < 7 * 86_400) return `${Math.floor(s / 86_400)}d ago`; + return DAY_MONTH.format(at); +} From db7418904bd5270aa40646924519266fff814c90 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:15:15 +0530 Subject: [PATCH 3/7] feat: Home frame (greeting, sky band, brief) with no-shift skeletons and light theme-color --- frontend/e2e/fixtures.ts | 34 +++++- frontend/e2e/home.spec.ts | 68 +++++++++++ .../src/app/(app)/home/_components/brief.tsx | 16 +++ .../src/app/(app)/home/_components/card.tsx | 39 ++++++ .../src/app/(app)/home/dashboardContent.tsx | 112 ------------------ frontend/src/app/(app)/home/homeContent.tsx | 77 ++++++++++++ frontend/src/app/(app)/home/page.tsx | 4 +- frontend/src/app/(app)/home/use-home-data.ts | 77 ++++++++++++ frontend/src/app/(app)/layout.tsx | 45 +------ frontend/src/components/app-shell.tsx | 43 +++++++ 10 files changed, 358 insertions(+), 157 deletions(-) create mode 100644 frontend/e2e/home.spec.ts create mode 100644 frontend/src/app/(app)/home/_components/brief.tsx create mode 100644 frontend/src/app/(app)/home/_components/card.tsx delete mode 100644 frontend/src/app/(app)/home/dashboardContent.tsx create mode 100644 frontend/src/app/(app)/home/homeContent.tsx create mode 100644 frontend/src/app/(app)/home/use-home-data.ts create mode 100644 frontend/src/components/app-shell.tsx diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 08c2f73..fe5a537 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -5,7 +5,15 @@ 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; +}; + +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 +40,23 @@ 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 data = (home.txns ?? []).filter((t) => t.date >= from && t.date <= to); + return route.fulfill({ json: { success: true, data, pagination: {} } }); + } + if (path === "/api/analytics/reminders") + return route.fulfill({ json: { success: true, reminders: home.reminders ?? [], count: 0 } }); + if (path === "/api/analytics/forecast") { + 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 +67,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.spec.ts b/frontend/e2e/home.spec.ts new file mode 100644 index 0000000..87fa674 --- /dev/null +++ b/frontend/e2e/home.spec.ts @@ -0,0 +1,68 @@ +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); +}); diff --git a/frontend/src/app/(app)/home/_components/brief.tsx b/frontend/src/app/(app)/home/_components/brief.tsx new file mode 100644 index 0000000..190c266 --- /dev/null +++ b/frontend/src/app/(app)/home/_components/brief.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +// The one- or two-sentence brief, in the serif. Reserved height = two lines, so it never pushes the cards down. +export function Brief({ text }: { text: string | null }) { + return ( +
+ {text === null ? ( + + ) : ( +

+ {text} +

+ )} +
+ ); +} diff --git a/frontend/src/app/(app)/home/_components/card.tsx b/frontend/src/app/(app)/home/_components/card.tsx new file mode 100644 index 0000000..7327963 --- /dev/null +++ b/frontend/src/app/(app)/home/_components/card.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +// Shared Home card: mono title row, and a skeleton at the card's final height while loading (no shift on arrival). +export function HomeCard({ + title, + href, + loading, + skeletonHeight, + className, + action, + children, +}: { + title: ReactNode; + href?: string; + loading?: boolean; + skeletonHeight: number; + className?: string; + action?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ {href ? ( + + {title} › + + ) : ( +

{title}

+ )} + {action &&
{action}
} +
+ {loading ? :
{children}
} +
+ ); +} diff --git a/frontend/src/app/(app)/home/dashboardContent.tsx b/frontend/src/app/(app)/home/dashboardContent.tsx deleted file mode 100644 index 84f8218..0000000 --- a/frontend/src/app/(app)/home/dashboardContent.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import AnimatedListItemUse from "@/components/animatedListItemUse"; -import { SectionCards } from "@/components/section-cards"; -import { DashboardShell } from "@/components/dashboard-shell"; -import { DialogDemo } from "@/components/uploadDialog"; -import { ExportDialog } from "@/components/exportDialog"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { useSearchParams } from "next/navigation"; -import { useProfile } from "@/components/profile/profile-provider"; - -const partOfDay = (h: number) => (h < 12 ? "morning" : h < 17 ? "afternoon" : "evening"); - -export default function DashboardContent() { - const searchParams = useSearchParams(); - const openUpload = searchParams.get("upload") === "1"; - const { profile } = useProfile(); - const firstName = profile?.display_name?.split(" ")[0]; - const greeting = firstName ? `Good ${partOfDay(new Date().getHours())}, ${firstName}` : "Operations"; - - return ( - - - - - } - > -
- -
- -
- - -
- - Invoice overview - - - Review recent uploads, open vendor records, and - move quickly from extraction to approval. - -
- - Latest activity - -
- - - -
- - - - - What to watch - - - The fastest path to the answers finance teams need - most often. - - - -
-

- Approval queue -

-

- Prioritize records with missing fields, unusual - spend, or OCR confidence issues before payment - approval. -

-
-
-

- Exports and reporting -

-

- Package the current ledger view for downstream - reporting without leaving the dashboard. -

-
-
-

- Recent imports -

-

- Use the upload action whenever a new batch needs - review or email polling is paused. -

-
-
-
-
-
- ); -} diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx new file mode 100644 index 0000000..ea3454b --- /dev/null +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { useProfile } from "@/components/profile/profile-provider"; +import { DialogDemo } from "@/components/uploadDialog"; +import { ExportDialog } from "@/components/exportDialog"; +import { useMoney } from "@/lib/format"; +import { briefSentence, cumulative, dayIndex, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math"; +import { Brief } from "./_components/brief"; +import { useHomeData, type Reminder } from "./use-home-data"; + +const partOfDay = (h: number) => (h < 12 ? "morning" : h < 17 ? "afternoon" : "evening"); +const monthName = (d: Date) => d.toLocaleString("en-IN", { month: "long" }); + +export function nextBill(reminders: Reminder[], today: Date) { + return reminders + .filter((r) => r.type === "upcoming" && parseDay(r.date) >= today) + .sort((a, b) => a.date.localeCompare(b.date))[0]; +} + +// Home (UI revamp spec §4): brief, then the money cards. Data is today's endpoints; see use-home-data.ts. +export default function HomeContent() { + const openUpload = useSearchParams().get("upload") === "1"; + const { profile } = useProfile(); + const money = useMoney(); + const [range] = useState("1M"); + const today = useMemo(() => { + const n = new Date(); + return new Date(n.getFullYear(), n.getMonth(), n.getDate()); + }, []); + const data = useHomeData(range, today); + + const month = periods("1M", today); + const monthCur = cumulative(data.monthTxns, month.current); + const monthPrev = cumulative(data.prevTxns, month.previous); + const todayIdx = dayIndex(month.current, today); + const bill = nextBill(data.reminders, today); + const brief = + data.status === "loading" + ? null + : briefSentence({ + monthName: monthName(today), + prevMonthName: monthName(month.previous.start), + spent: monthCur[todayIdx] ?? 0, + delta: range === "1M" ? sameDayDelta(monthCur, monthPrev, todayIdx) : null, + nextBill: bill ? { vendor: bill.vendor, date: parseDay(bill.date) } : undefined, + money: (n) => money(n), + today, + }); + + const firstName = profile?.display_name?.split(" ")[0]; + + return ( +
+
+
+
+

+ {firstName ? `Good ${partOfDay(new Date().getHours())}, ${firstName}` : "Welcome back"} +

+
+ + +
+
+
+ +
+
+
+
+
+
+
+ ); +} 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..6326bf2 --- /dev/null +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -0,0 +1,77 @@ +"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[]; + 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")}`; +const fetchTxns = (from: Date, to: Date) => + transactionApi + .getTransactions({ date_from: ymd(from), date_to: ymd(to), page_size: 500, sort_by: "date", sort_order: "desc" }) + .then((r: { data?: Txn[] }) => r.data ?? []); +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. +export function useHomeData(range: Range, today: Date): HomeData { + const [data, setData] = useState({ + status: "loading", + txns: [], + prevTxns: [], + monthTxns: [], + reminders: [], + forecast: null, + toReview: 0, + }); + + useEffect(() => { + let live = true; + const { current, previous } = periods(range, today); + const month = periods("1M", today).current; // the calendar always shows this month + setData((d) => ({ ...d, status: "loading" })); + Promise.allSettled([ + fetchTxns(current.start, current.end), + fetchTxns(previous.start, previous.end), + range === "1M" ? Promise.resolve(null) : fetchTxns(month.start, month.end), + aiAnalyticsApi.getReminders(31).then((r: { reminders?: Reminder[] }) => r.reminders ?? []), + aiAnalyticsApi + .getForecast(31) + .then((r: { forecast?: { success?: boolean; forecast?: Forecast } }) => + r.forecast?.success === false ? null : (r.forecast?.forecast ?? null), + ), + reviewApi.list().then((items) => items.length), + ]).then(([cur, prev, month, reminders, forecast, toReview]) => { + if (!live) return; + const txns = value(cur, []); + setData({ + status: "ready", + txns, + prevTxns: value(prev, []), + monthTxns: value(month, null) ?? txns, + reminders: value(reminders, []), + forecast: value(forecast, null), + toReview: value(toReview, 0), + }); + }); + return () => { + live = false; + }; + // today is a fresh Date each render; the day is what matters + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [range, today.toDateString()]); + + return data; +} 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. */} - - - -
-
- -
- -
-
-
- {children} -
-
-
-
-
-
-
- ); + return {children}; } 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} +
+
+
+
+
+
+
+ ); +} From 39a11659ef2c3c284ca3e9c5f1357bbd60b895b0 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:23:13 +0530 Subject: [PATCH 4/7] feat: Home spend card with last-period comparison, forecast and bill pins --- frontend/e2e/home.spec.ts | 57 ++++++ .../src/app/(app)/home/_components/card.tsx | 4 +- .../app/(app)/home/_components/spend-card.tsx | 171 ++++++++++++++++++ frontend/src/app/(app)/home/homeContent.tsx | 23 ++- frontend/src/app/(app)/home/use-home-data.ts | 19 +- 5 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/(app)/home/_components/spend-card.tsx diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 87fa674..2ed876f 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -66,3 +66,60 @@ test("old dashboard is gone", async ({ page }) => { 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-GB", { 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); +}); diff --git a/frontend/src/app/(app)/home/_components/card.tsx b/frontend/src/app/(app)/home/_components/card.tsx index 7327963..c8bd384 100644 --- a/frontend/src/app/(app)/home/_components/card.tsx +++ b/frontend/src/app/(app)/home/_components/card.tsx @@ -12,6 +12,7 @@ export function HomeCard({ className, action, children, + ...rest }: { title: ReactNode; href?: string; @@ -20,9 +21,10 @@ export function HomeCard({ className?: string; action?: ReactNode; children: ReactNode; + "data-testid"?: string; }) { return ( -
+
{href ? ( diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx new file mode 100644 index 0000000..d76d05a --- /dev/null +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { animate } from "motion/react"; +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"; + +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-GB", { month: "short" }); +const tick = (d: Date) => d.toLocaleString("en-GB", { day: "2-digit", month: "short" }); + +// 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 }) { + const [shown, setShown] = useState(value); + useEffect(() => { + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + let seen = false; + try { + seen = sessionStorage.getItem("home-countup") === "1"; + sessionStorage.setItem("home-countup", "1"); + } catch {} + if (reduce || seen || value <= 0) return setShown(value); + const c = animate(0, value, { duration: 0.6, ease: [0.22, 1, 0.36, 1], onUpdate: (v) => setShown(Math.round(v)) }); + return () => c.stop(); + }, [value]); + return <>{format(shown)}; +} + +export function SpendCard({ + range, + onRange, + period, + previous, + cur, + prev, + forecast, + reminders, + today, + loading, +}: { + range: Range; + onRange: (r: Range) => void; + period: Period; + previous: Period; + cur: number[]; + prev: number[]; + forecast: Forecast; + reminders: Reminder[]; + today: Date; + loading: boolean; +}) { + const money = useMoney(); + const t = Math.min(Math.max(dayIndex(period, today), 0), period.days - 1); + const total = cur[t] ?? 0; + const delta = sameDayDelta(cur, prev, t); + + // forecast: today's running total plus each forecast day still inside this month (1M only) + const fc: number[] = []; + if (range === "1M" && forecast?.dates?.length) { + let run = total; + for (let i = t + 1; i < period.days; i++) { + const day = new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i); + const k = forecast.dates.findIndex((d) => parseDay(d).getTime() === day.getTime()); + run += k >= 0 ? (forecast.values[k] ?? 0) : 0; + fc.push(run); + } + } + 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 pins = reminders.filter((r) => { + if (r.type !== "upcoming") return false; + const i = dayIndex(period, parseDay(r.date)); + 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"; + + return ( + +
+
+
+ money(n)} /> +
+
+ {delta && Math.round(delta.abs) !== 0 && ( + + {delta.abs < 0 ? "↓" : "↑"} {money(Math.abs(Math.round(delta.abs)))} {vsLabel} + + )} +
+
+
+ — now + ┄ before + {fc.length > 0 && · forecast} +
+
+ +
+ + + + + + + + {prev.length > 1 && ( + + )} + + + {fc.length > 0 && ( + + )} + + + {/* HTML overlays keep text and dots crisp while the svg stretches */} + + {pins.map((p) => { + const i = dayIndex(period, parseDay(p.date)); + return ( + + + {p.vendor} + + + + ); + })} +
+
+ {ticks.map((i) => ( + + {i === t ? "today" : tick(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i))} + + ))} +
+
+ {RANGES.map((r) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index ea3454b..e64af7d 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 { useMoney } from "@/lib/format"; import { briefSentence, cumulative, dayIndex, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math"; import { Brief } from "./_components/brief"; +import { SpendCard } from "./_components/spend-card"; import { useHomeData, type Reminder } from "./use-home-data"; const partOfDay = (h: number) => (h < 12 ? "morning" : h < 17 ? "afternoon" : "evening"); @@ -24,7 +25,7 @@ export default function HomeContent() { const openUpload = useSearchParams().get("upload") === "1"; const { profile } = useProfile(); const money = useMoney(); - const [range] = useState("1M"); + const [range, setRange] = useState("1M"); const today = useMemo(() => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate()); @@ -33,7 +34,7 @@ export default function HomeContent() { const month = periods("1M", today); const monthCur = cumulative(data.monthTxns, month.current); - const monthPrev = cumulative(data.prevTxns, month.previous); + const monthPrev = cumulative(data.prevMonthTxns, month.previous); const todayIdx = dayIndex(month.current, today); const bill = nextBill(data.reminders, today); const brief = @@ -43,13 +44,14 @@ export default function HomeContent() { monthName: monthName(today), prevMonthName: monthName(month.previous.start), spent: monthCur[todayIdx] ?? 0, - delta: range === "1M" ? sameDayDelta(monthCur, monthPrev, todayIdx) : null, + delta: sameDayDelta(monthCur, monthPrev, todayIdx), nextBill: bill ? { vendor: bill.vendor, date: parseDay(bill.date) } : undefined, money: (n) => money(n), today, }); const firstName = profile?.display_name?.split(" ")[0]; + const chart = periods(range, today); return (
@@ -68,7 +70,20 @@ export default function HomeContent() {
-
+
+ +
diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts index 6326bf2..52d6d1b 100644 --- a/frontend/src/app/(app)/home/use-home-data.ts +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -11,6 +11,7 @@ export type HomeData = { txns: Txn[]; prevTxns: Txn[]; monthTxns: Txn[]; + prevMonthTxns: Txn[]; reminders: Reminder[]; forecast: Forecast; toReview: number; @@ -32,6 +33,7 @@ export function useHomeData(range: Range, today: Date): HomeData { txns: [], prevTxns: [], monthTxns: [], + prevMonthTxns: [], reminders: [], forecast: null, toReview: 0, @@ -40,12 +42,15 @@ export function useHomeData(range: Range, today: Date): HomeData { useEffect(() => { let live = true; const { current, previous } = periods(range, today); - const month = periods("1M", today).current; // the calendar always shows this month - setData((d) => ({ ...d, status: "loading" })); + // the brief and calendar are always about this calendar month, whatever range the chart shows + const month = periods("1M", today); + // skeletons only on the first load; a range change keeps the old numbers until the new ones arrive + // (a click on the chart must not blank the brief or any other card) Promise.allSettled([ fetchTxns(current.start, current.end), fetchTxns(previous.start, previous.end), - range === "1M" ? Promise.resolve(null) : fetchTxns(month.start, month.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), aiAnalyticsApi.getReminders(31).then((r: { reminders?: Reminder[] }) => r.reminders ?? []), aiAnalyticsApi .getForecast(31) @@ -53,14 +58,16 @@ export function useHomeData(range: Range, today: Date): HomeData { r.forecast?.success === false ? null : (r.forecast?.forecast ?? null), ), reviewApi.list().then((items) => items.length), - ]).then(([cur, prev, month, reminders, forecast, toReview]) => { + ]).then(([cur, prev, monthCur, monthPrev, reminders, forecast, toReview]) => { if (!live) return; const txns = value(cur, []); + const prevTxns = value(prev, []); setData({ status: "ready", txns, - prevTxns: value(prev, []), - monthTxns: value(month, null) ?? txns, + prevTxns, + monthTxns: value(monthCur, null) ?? txns, + prevMonthTxns: value(monthPrev, null) ?? prevTxns, reminders: value(reminders, []), forecast: value(forecast, null), toReview: value(toReview, 0), From 8c5ae50016a384dd198308100c0aae6a4c70c057 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:27:47 +0530 Subject: [PATCH 5/7] feat: Home cards for Lumen found, spending calendar, recent transactions and bills --- frontend/e2e/home.spec.ts | 45 ++++++++++++++ .../app/(app)/home/_components/bills-card.tsx | 34 +++++++++++ .../(app)/home/_components/calendar-card.tsx | 59 +++++++++++++++++++ .../(app)/home/_components/lumen-found.tsx | 29 +++++++++ frontend/src/app/(app)/home/homeContent.tsx | 9 ++- 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/(app)/home/_components/bills-card.tsx create mode 100644 frontend/src/app/(app)/home/_components/calendar-card.tsx create mode 100644 frontend/src/app/(app)/home/_components/lumen-found.tsx diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 2ed876f..b9ce931 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -123,3 +123,48 @@ test("changing the chart range never blanks other cards", async ({ page }) => { 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"); +}); diff --git a/frontend/src/app/(app)/home/_components/bills-card.tsx b/frontend/src/app/(app)/home/_components/bills-card.tsx new file mode 100644 index 0000000..6e0192c --- /dev/null +++ b/frontend/src/app/(app)/home/_components/bills-card.tsx @@ -0,0 +1,34 @@ +import { useMoney } from "@/lib/format"; +import { parseDay } from "@/lib/home-math"; +import type { Reminder } from "../use-home-data"; +import { HomeCard } from "./card"; + +// The next three recurring charges Lumen expects. +export function BillsCard({ reminders, today, loading }: { reminders: Reminder[]; today: Date; loading: boolean }) { + const money = useMoney(); + const next = reminders + .filter((r) => r.type === "upcoming" && parseDay(r.date) >= today) + .sort((a, b) => a.date.localeCompare(b.date)) + .slice(0, 3); + return ( + + {next.length === 0 ? ( +

No bills coming up in the next month.

+ ) : ( + next.map((r) => { + const d = parseDay(r.date); + return ( +
+ + {d.toLocaleString("en-GB", { weekday: "short" })} + {String(d.getDate()).padStart(2, "0")} + + {r.vendor} + {r.amount == null ? "—" : money(r.amount)} +
+ ); + }) + )} +
+ ); +} diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx new file mode 100644 index 0000000..3073778 --- /dev/null +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -0,0 +1,59 @@ +import { useMoney } from "@/lib/format"; +import { calendarBuckets, heat, parseDay, type Period, type Txn } from "@/lib/home-math"; +import { HomeCard } from "./card"; + +const kShort = (n: number) => (n >= 1000 ? `₹${(n / 1000).toFixed(1)}k` : `₹${Math.round(n)}`); +const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" }); + +// This month as a heat grid (darker = more spent) beside the latest transactions. +export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: Period; loading: boolean }) { + const money = useMoney(); + const days = calendarBuckets(txns, period); + const max = Math.max(...days.map((d) => d.amount), 0); + const lead = (period.start.getDay() + 6) % 7; // Monday first + const recent = [...txns].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 5); + + return ( + +
+
+ {Array.from({ length: lead }, (_, i) => ( +
+ ))} + {days.map(({ day, amount }) => { + const h = heat(amount, max); + return ( +
0.5 ? "#fff" : undefined } : undefined} + > + {day} + {amount > 0 && {kShort(amount)}} +
+ ); + })} +
+
+
Recent transactions
+ {recent.length === 0 ? ( +

No transactions this month yet.

+ ) : ( + recent.map((t) => ( +
+ + {t.vendor_name ?? "Unknown"} + + {t.category ?? "Uncategorised"} · {dayMonth(t.date)} + + + {money(t.total_amount ?? 0)} +
+ )) + )} +
+
+ + ); +} diff --git a/frontend/src/app/(app)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx new file mode 100644 index 0000000..75a3910 --- /dev/null +++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import { HomeCard } from "./card"; + +// "Lumen found": things worth your attention. Phase 2 has one real source (the review queue); Phase 3 adds +// subscriptions, spending spikes and duplicate charges, so the tile grid is already three wide. +export function LumenFound({ toReview, loading }: { toReview: number; loading: boolean }) { + return ( + + {toReview > 0 ? ( +
+
+
Receipts
+

+ {toReview} {toReview === 1 ? "receipt needs" : "receipts need"} a glance +

+

+ Lumen wasn't sure about these. Nothing is saved until you approve. +

+ + Review → + +
+
+ ) : ( +

Nothing needs you right now.

+ )} +
+ ); +} diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index e64af7d..d0b44a3 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -8,6 +8,9 @@ import { ExportDialog } from "@/components/exportDialog"; import { useMoney } from "@/lib/format"; import { briefSentence, cumulative, dayIndex, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math"; import { Brief } from "./_components/brief"; +import { BillsCard } from "./_components/bills-card"; +import { CalendarCard } from "./_components/calendar-card"; +import { LumenFound } from "./_components/lumen-found"; import { SpendCard } from "./_components/spend-card"; import { useHomeData, type Reminder } from "./use-home-data"; @@ -83,8 +86,12 @@ export default function HomeContent() { today={today} loading={data.status === "loading"} /> + + +
+
+
-
From 201010ca3cda5004bdd70963bf1943b660205403 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:35:55 +0530 Subject: [PATCH 6/7] feat: Home budget, draft-budget and activity cards; remove old dashboard components; docs --- docs/CODEBASE-GUIDE.md | 9 + docs/direction/STORY.md | 14 + .../specs/2026-10-20-ui-revamp-design.md | 7 +- frontend/e2e/home.spec.ts | 30 +- .../(app)/home/_components/activity-card.tsx | 35 ++ .../(app)/home/_components/budget-card.tsx | 30 ++ .../app/(app)/home/_components/spend-card.tsx | 2 +- frontend/src/app/(app)/home/homeContent.tsx | 7 +- frontend/src/components/AnimatedList.tsx | 260 -------------- .../src/components/animatedListItemUse.tsx | 335 ------------------ frontend/src/components/section-cards.tsx | 235 ------------ 11 files changed, 129 insertions(+), 835 deletions(-) create mode 100644 frontend/src/app/(app)/home/_components/activity-card.tsx create mode 100644 frontend/src/app/(app)/home/_components/budget-card.tsx delete mode 100644 frontend/src/components/AnimatedList.tsx delete mode 100644 frontend/src/components/animatedListItemUse.tsx delete mode 100644 frontend/src/components/section-cards.tsx 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/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md index 5029257..6ea5c17 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,7 @@ 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)* - [ ] **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/home.spec.ts b/frontend/e2e/home.spec.ts index b9ce931..dad4e90 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -68,7 +68,7 @@ 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-GB", { month: "short" }); +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 }) => { @@ -168,3 +168,31 @@ test("bills card lists next three", async ({ page }) => { 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"); +}); 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. +

+ ) : ( + items.map((t) => ( +
+ + Added {t.vendor_name ?? "a transaction"} {money(t.total_amount ?? 0)} + + {relativeTime(t.created_at!, now)} +
+ )) + )} +
+ ); +} diff --git a/frontend/src/app/(app)/home/_components/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx new file mode 100644 index 0000000..2895abf --- /dev/null +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -0,0 +1,30 @@ +import { HomeCard } from "./card"; + +// Budgets arrive in Phase 3 (model + an AI draft you approve line by line). Until then: an honest empty state. +export function BudgetCard({ monthName }: { monthName: string }) { + return ( + +

No budget yet

+

Lumen can draft one from your last 3 months.

+
+ ); +} + +export function DraftBudgetPromo() { + return ( +
+

Let Lumen draft your budget

+

+ Built from 3 months of your actual spending. You approve every line before it's saved. +

+ +
+ ); +} diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index d76d05a..138dd68 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -12,7 +12,7 @@ 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-GB", { month: "short" }); +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" }); // Counts up once per browser session, then shows the number straight away (motion rule: no replay on revisits). diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index d0b44a3..2a07975 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -8,7 +8,9 @@ import { ExportDialog } from "@/components/exportDialog"; import { useMoney } from "@/lib/format"; import { briefSentence, cumulative, dayIndex, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math"; import { Brief } from "./_components/brief"; +import { ActivityCard } from "./_components/activity-card"; import { BillsCard } from "./_components/bills-card"; +import { BudgetCard, DraftBudgetPromo } from "./_components/budget-card"; import { CalendarCard } from "./_components/calendar-card"; import { LumenFound } from "./_components/lumen-found"; import { SpendCard } from "./_components/spend-card"; @@ -58,7 +60,7 @@ export default function HomeContent() { return (
-
+

@@ -90,7 +92,10 @@ export default function HomeContent() {

+ + +
diff --git a/frontend/src/components/AnimatedList.tsx b/frontend/src/components/AnimatedList.tsx deleted file mode 100644 index e38ceb3..0000000 --- a/frontend/src/components/AnimatedList.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React, { - useRef, - useState, - useEffect, - ReactNode, - MouseEventHandler, - UIEvent, -} from "react"; -import { motion, useInView } from "motion/react"; - -interface AnimatedItemProps { - children: ReactNode; - delay?: number; - index: number; - onMouseEnter?: MouseEventHandler; - onClick?: MouseEventHandler; -} - -const AnimatedItem: React.FC = ({ - children, - delay = 0, - index, - onMouseEnter, - onClick, -}) => { - const ref = useRef(null); - const inView = useInView(ref, { amount: 0.5, once: false }); - return ( - - {children} - - ); -}; - -interface AnimatedListProps { - items?: any[]; - onItemSelect?: (item: any, index: number) => void; - showGradients?: boolean; - enableArrowNavigation?: boolean; - className?: string; - itemClassName?: string; - displayScrollbar?: boolean; - initialSelectedIndex?: number; -} - -const AnimatedList: React.FC = ({ - items = [ - "Item 1", - "Item 2", - "Item 3", - "Item 4", - "Item 5", - "Item 6", - "Item 7", - "Item 8", - "Item 9", - "Item 10", - "Item 11", - "Item 12", - "Item 13", - "Item 14", - "Item 15", - ], - onItemSelect, - showGradients = true, - enableArrowNavigation = true, - className = "", - itemClassName = "", - displayScrollbar = true, - initialSelectedIndex = -1, -}) => { - const listRef = useRef(null); - const [selectedIndex, setSelectedIndex] = - useState(initialSelectedIndex); - const [keyboardNav, setKeyboardNav] = useState(false); - const [topGradientOpacity, setTopGradientOpacity] = useState(0); - const [bottomGradientOpacity, setBottomGradientOpacity] = - useState(1); - - const handleScroll = (e: UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = - e.target as HTMLDivElement; - setTopGradientOpacity(Math.min(scrollTop / 50, 1)); - const bottomDistance = scrollHeight - (scrollTop + clientHeight); - setBottomGradientOpacity( - scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1) - ); - }; - - useEffect(() => { - if (!enableArrowNavigation) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { - e.preventDefault(); - setKeyboardNav(true); - setSelectedIndex((prev) => - Math.min(prev + 1, items.length - 1) - ); - } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { - e.preventDefault(); - setKeyboardNav(true); - setSelectedIndex((prev) => Math.max(prev - 1, 0)); - } else if (e.key === "Enter") { - if (selectedIndex >= 0 && selectedIndex < items.length) { - e.preventDefault(); - if (onItemSelect) { - onItemSelect(items[selectedIndex], selectedIndex); - } - } - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [items, selectedIndex, onItemSelect, enableArrowNavigation]); - - useEffect(() => { - if (!keyboardNav || selectedIndex < 0 || !listRef.current) return; - const container = listRef.current; - const selectedItem = container.querySelector( - `[data-index="${selectedIndex}"]` - ) as HTMLElement | null; - if (selectedItem) { - const extraMargin = 50; - const containerScrollTop = container.scrollTop; - const containerHeight = container.clientHeight; - const itemTop = selectedItem.offsetTop; - const itemBottom = itemTop + selectedItem.offsetHeight; - if (itemTop < containerScrollTop + extraMargin) { - container.scrollTo({ - top: itemTop - extraMargin, - behavior: "smooth", - }); - } else if ( - itemBottom > - containerScrollTop + containerHeight - extraMargin - ) { - container.scrollTo({ - top: itemBottom - containerHeight + extraMargin, - behavior: "smooth", - }); - } - } - setKeyboardNav(false); - }, [selectedIndex, keyboardNav]); - - return ( -
-
- {items.map((item, index) => ( - setSelectedIndex(index)} - onClick={() => { - setSelectedIndex(index); - if (onItemSelect) { - onItemSelect(item, index); - } - }} - > -
- {typeof item === "object" ? ( -
-
-
- - Invoice: - -

- {item.invoiceNumber} -

-
-
- - Vendor: - -

- {item.vendorName} -

-
-
- - Amount: - -

- {item.currency}{" "} - {item.amountPayable} -

-
-
- - Due: - -

- {item.dueDate} -

-
-
-
- - Score: {item.ConfidenceScore} - - {item.actions} -
-
- ) : ( -

{item}

- )} -
-
- ))} -
- {showGradients && ( - <> -
-
- - )} -
- ); -}; - -export default AnimatedList; diff --git a/frontend/src/components/animatedListItemUse.tsx b/frontend/src/components/animatedListItemUse.tsx deleted file mode 100644 index 30a7443..0000000 --- a/frontend/src/components/animatedListItemUse.tsx +++ /dev/null @@ -1,335 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useMoney } from "@/lib/format"; -import { InvoiceEditDialog } from "@/components/invoiceEditDialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { RefreshCw } from "lucide-react"; -import { clearApiCache, transactionApi } from "@/lib/api/client"; -import { eventBus, EVENTS } from "@/lib/events"; - -import { logger } from "@/lib/logger"; -interface Invoice { - id: string; // UUID from database - vendor_name: string | null; - invoice_number: string | null; - date: string | null; - total_amount: number | null; - tax_amount: number | null; - payment_method: string | null; - address: string | null; - category: string | null; - currency?: string | null; // as printed on the invoice; null on old rows - created_at: string; - items?: Array<{ - item_name: string; - quantity: number; - unit_price: number; - total_price: number; - }>; -} - -export default function AnimatedListItemUse() { - const money = useMoney(); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selectedInvoice, setSelectedInvoice] = useState( - null - ); - const [dialogOpen, setDialogOpen] = useState(false); - - const fetchInvoices = async () => { - try { - setLoading(true); - setError(null); - - logger.debug( - "🔄 AnimatedList: Fetching invoices from database API..." - ); - - // Fetch from database API - const response = await transactionApi.getTransactions({ - page: 1, - page_size: 1000, - sort_by: "created_at", - sort_order: "desc", - }); - - logger.debug("📥 AnimatedList: API Response:", { - success: response.success, - count: response.data?.length, - }); - - if (response.success && response.data) { - logger.debug( - `✅ AnimatedList: Fetched ${response.data.length} transactions from database` - ); - setItems(response.data); - } else { - console.warn( - "⚠️ AnimatedList: API returned no data or success=false" - ); - setItems([]); - } - } catch (error) { - console.error("Error fetching transactions:", error); - - // Fallback to localStorage if API fails - try { - const storedInvoices = localStorage.getItem("invoices"); - const invoices = storedInvoices - ? JSON.parse(storedInvoices) - : []; - logger.debug("Using localStorage fallback:", invoices); - setItems(invoices); - } catch (localError) { - setItems([]); - } - - const errorMessage = - error instanceof Error - ? error.message - : "Failed to fetch transactions"; - setError(errorMessage); - } finally { - setLoading(false); - } - }; - - const handleManualRefresh = async () => { - clearApiCache(); // a manual refresh must reach the server - eventBus.emit(EVENTS.INVOICE_UPDATED); // the list and the dashboard's stat cards both reload - }; - - // Load invoices on component mount and listen for updates - useEffect(() => { - logger.debug( - "🔄 AnimatedListItemUse: Component mounted, fetching invoices..." - ); - fetchInvoices(); - - // Listen for invoice updates - const handleInvoiceUpdate = () => { - logger.debug( - "🔔 AnimatedListItemUse: Invoice update event received, refreshing..." - ); - fetchInvoices(); - }; - - const unsubscribe = eventBus.on( - EVENTS.INVOICE_UPDATED, - handleInvoiceUpdate - ); - logger.debug("👂 AnimatedListItemUse: Listening for invoice updates"); - - return () => { - logger.debug("🔇 AnimatedListItemUse: Cleaning up event listener"); - unsubscribe(); - }; - }, []); - - const handleOpenDialog = (invoice: Invoice) => { - setSelectedInvoice(invoice); - setDialogOpen(true); - }; - - const formatCurrency = (amount: number | null, currency?: string | null) => - amount == null ? "N/A" : money(amount, currency); - - const formatDate = (value: string | null) => - value - ? new Date(value).toLocaleDateString(undefined, { - day: "2-digit", - month: "short", - year: "numeric", - }) - : "N/A"; - - if (loading) { - return ( -
-
- - Loading invoices... - -
- ); - } - - if (error) { - return ( -
-

{error}

- -
- ); - } - - if (items.length === 0 && !loading) { - return ( -
-
- -
-

- No Invoices Yet -

-

- Get started by uploading your first invoice or configure - email polling to automatically import invoices from your - inbox. -

-
- - -
-
- ); - } - - return ( -
-
-
- - {items.length} records - - - Sorted by newest import - -
-
- -
-
- -
-
-
Vendor & Invoice
-
Recorded
-
Category
-
Amount
-
Action
-
-
- {items.map((item) => ( -
-
-
-
-

- {item.vendor_name || "Unknown vendor"} -

- - {item.invoice_number || "No invoice #"} - -
-

- Imported{" "} - {formatDate(item.created_at)} -

-
- -
-

- Invoice date -

-

- {formatDate(item.date)} -

-
- -
-

- Category -

-

- {item.category || "Unclassified"} -

-
- -
-

- Amount -

-

- {formatCurrency(item.total_amount, item.currency)} -

-
- -
- -
-
-
- ))} -
-
- - {/* Invoice Edit/Review Dialog */} - {selectedInvoice && ( - - )} -
- ); -} 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 - - - - -
- Total invoices processed -
-
- Invoices in your system -
-
-
- - {/* Approved Invoices Card */} - - - - Approved Invoices - - - {approvedInvoices} - - - - - {totalInvoices > 0 - ? Math.round( - (approvedInvoices / totalInvoices) * 100 - ) - : 0} - % - - - - -
- Ready for payment -
-
- Verified and approved -
-
-
- - {/* Pending Review Card */} - - - - Needs Review - - - {pendingInvoices} - - - - - {totalInvoices > 0 - ? Math.round( - (pendingInvoices / totalInvoices) * 100 - ) - : 0} - % - - - - -
- Requires attention -
-
- Pending verification -
-
-
- - ); -} - -function SkeletonCard() { - return ( - - -
-
-
- -
-
-
-
- ); -} From fce4244b54bdf7e6a3f30eb06009c0de8c8e2353 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:45:50 +0530 Subject: [PATCH 7/7] fix: Home totals page through all transactions; UTC times; forecast without LLM never blocks; empty chart state; backlog --- backend/ai/analytics_orchestrator.py | 6 +- backend/routes/ai_analytics.py | 4 +- backend/tests/test_ai_analytics.py | 20 ++++ .../specs/2026-10-20-ui-revamp-design.md | 5 + frontend/e2e/fixtures.ts | 10 +- frontend/e2e/home-math.spec.ts | 7 ++ frontend/e2e/home.spec.ts | 30 +++++ .../(app)/home/_components/calendar-card.tsx | 2 +- .../app/(app)/home/_components/spend-card.tsx | 16 +++ frontend/src/app/(app)/home/use-home-data.ts | 103 +++++++++++------- frontend/src/lib/api/client.ts | 12 +- frontend/src/lib/home-math.ts | 3 +- 12 files changed, 165 insertions(+), 53 deletions(-) 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/superpowers/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md index 6ea5c17..2a1ec73 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -223,4 +223,9 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **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 fe5a537..e1efc5f 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -10,6 +10,7 @@ export type HomeFixture = { 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 { @@ -47,12 +48,17 @@ export async function signIn(page: Page, opts: { reviewCount?: number; home?: Ho if (path === "/transactions") { const from = url.searchParams.get("date_from") ?? "0000"; const to = url.searchParams.get("date_to") ?? "9999"; - const data = (home.txns ?? []).filter((t) => t.date >= from && t.date <= to); - return route.fulfill({ json: { success: true, data, pagination: {} } }); + 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 } } }); diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts index ca60b1f..bf33c7e 100644 --- a/frontend/e2e/home-math.spec.ts +++ b/frontend/e2e/home-math.spec.ts @@ -117,3 +117,10 @@ test("relativeTime", () => { 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 index dad4e90..d747b3a 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -196,3 +196,33 @@ test("sky band is drawn behind the greeting", async ({ 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/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index 3073778..85013b2 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -27,7 +27,7 @@ export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: P key={day} data-testid={`cal-day-${day}`} className="h-[34px] overflow-hidden rounded-md border border-border px-1 py-0.5 font-mono text-[10px] leading-tight text-muted-foreground" - style={h ? { background: `rgb(from var(--primary) r g b / ${h})`, color: h > 0.5 ? "#fff" : undefined } : undefined} + style={h ? { background: `color-mix(in srgb, var(--primary) ${Math.round(h * 100)}%, transparent)`, color: h > 0.5 ? "#fff" : undefined } : undefined} > {day} {amount > 0 && {kShort(amount)}} diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index 138dd68..e56885a 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -64,13 +64,18 @@ export function SpendCard({ const fc: number[] = []; if (range === "1M" && forecast?.dates?.length) { let run = total; + let matched = 0; for (let i = t + 1; i < period.days; i++) { const day = new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i); const k = forecast.dates.findIndex((d) => parseDay(d).getTime() === day.getTime()); + if (k >= 0) matched++; run += k >= 0 ? (forecast.values[k] ?? 0) : 0; fc.push(run); } + // the backend forecasts from the last transaction's date; if none of its days land in this month, draw nothing + if (!matched) fc.length = 0; } + 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); @@ -111,6 +116,15 @@ export function SpendCard({
+ {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. +

+
+ ) : ( + <>
@@ -150,6 +164,8 @@ export function SpendCard({ ))}
+ + )}
{RANGES.map((r) => (