diff --git a/backend/api/agent.py b/backend/api/agent.py index 45b8e3d..8dd3eb2 100644 --- a/backend/api/agent.py +++ b/backend/api/agent.py @@ -120,7 +120,7 @@ def _save_invoice(proposal_id: str, user_id: str, deps: AgentDeps) -> dict | Non if not session.execute(update(Proposal).where( Proposal.id == proposal_id, Proposal.user_id == user_id, Proposal.status == "pending") .values(status="saving")).rowcount: - raise HTTPException(409, f"Proposal is already {p.status}") + raise HTTPException(409, "This proposal was already decided.") payload = json.loads(p.payload or "{}") try: item = submit_invoice(deps.engine, user_id, payload, warnings_seen=True) @@ -148,12 +148,13 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d if p is None: raise HTTPException(404, "Proposal not found") # claim it first (UPDATE ... WHERE status = 'pending'): of two concurrent decisions only one can apply; - # anything raised below rolls the claim back with the rest of the transaction + # anything raised below rolls the claim back with the rest of the transaction. The 409 doesn't name p.status: + # it was read before the claim, so the loser of a race would still see "pending" now = datetime.utcnow() if not session.execute(update(Proposal).where( Proposal.id == proposal_id, Proposal.user_id == user_id, Proposal.status == "pending") .values(status=decision, decided_at=now).execution_options(synchronize_session=False)).rowcount: - raise HTTPException(409, f"Proposal is already {p.status}") + raise HTTPException(409, "This proposal was already decided.") p.status, p.decided_at = decision, now applied = None if decision == "approved" and p.type == "set_budget": diff --git a/backend/routes/chat.py b/backend/routes/chat.py index 7ef7e04..c8ceb85 100644 --- a/backend/routes/chat.py +++ b/backend/routes/chat.py @@ -98,11 +98,12 @@ def _own_thread(user_id: str, thread_id) -> ChatThread: def _thread_for(user_id: str, thread_id: str | None, question: str) -> ChatThread: + """The thread to save into. A new one is only added to the session: _save_exchange commits it together with its + messages, so a failed save leaves no empty titled thread behind.""" if thread_id: return _own_thread(user_id, thread_id) thread = ChatThread(id=str(uuid.uuid4()), user_id=user_id, title=_title(question) or "New chat") db.session.add(thread) - db.session.commit() return thread @@ -193,8 +194,10 @@ def chat(): _save_exchange(user_id, thread.id, f"{query} [attached: {file.filename}]" if file else query, result["response"]) except Exception as e: - # Losing history shouldn't cost the user their answer. + # Losing history shouldn't cost the user their answer. A new thread was rolled back with it: don't hand out its id. db.session.rollback() + if not thread_id: + result.pop("thread_id", None) logger.exception("Failed to save chat history for user=%s: %s", user_id, e) return jsonify({"success": True, "data": result}), 200 diff --git a/backend/tests/test_api_agent.py b/backend/tests/test_api_agent.py index a1dff19..7f2932a 100644 --- a/backend/tests/test_api_agent.py +++ b/backend/tests/test_api_agent.py @@ -86,7 +86,8 @@ def category(): approved = client.post(f"/api/agent/proposals/{pid}/approve", headers=bearer(u1)).json() assert approved["proposal"]["status"] == "approved" and approved["applied"]["transactions_updated"] == 1 assert category() == "Shopping" - assert client.post(f"/api/agent/proposals/{pid}/reject", headers=bearer(u1)).status_code == 409 + late = client.post(f"/api/agent/proposals/{pid}/reject", headers=bearer(u1)) + assert late.status_code == 409 and late.json()["error"] == "This proposal was already decided." with engine.connect() as conn: actions = [r[0] for r in conn.execute(text( "SELECT action FROM audit_events WHERE proposal_id = :p ORDER BY id"), {"p": pid})] diff --git a/backend/tests/test_chat_attach.py b/backend/tests/test_chat_attach.py index dd8f117..80b99f4 100644 --- a/backend/tests/test_chat_attach.py +++ b/backend/tests/test_chat_attach.py @@ -84,7 +84,7 @@ def test_approve_save_invoice_records_once(env): assert out["proposal"]["status"] == "approved" with pytest.raises(HTTPException) as e: _decide(pid, "user-1", "approved", deps) - assert e.value.status_code == 409 + assert (e.value.status_code, e.value.detail) == (409, "This proposal was already decided.") assert _count(engine, ReviewItem) == 1 # one submission (auto-approved: its transaction, else queued for review) assert _count(engine, Transaction) == 1 diff --git a/backend/tests/test_chat_threads.py b/backend/tests/test_chat_threads.py index 842d9fe..30e336d 100644 --- a/backend/tests/test_chat_threads.py +++ b/backend/tests/test_chat_threads.py @@ -51,6 +51,21 @@ def test_post_without_thread_creates_one_titled_from_question(chat): assert not thread["title"].rstrip("…").endswith(" ") +def test_chat_save_failure_leaves_no_empty_thread(chat, monkeypatch): + import routes.chat as chat_routes + + client, post, _, _ = chat + + def boom(*a): + raise RuntimeError("db down") + + monkeypatch.setattr(chat_routes, "_save_exchange", boom) + res = post(A, "Lost to a failed save") + assert res.status_code == 200 # the answer still comes back + assert "thread_id" not in res.get_json()["data"] # no id for a thread that was never saved + assert client.get("/chat/threads", headers=bearer(A)).get_json()["threads"] == [] + + def test_post_with_thread_appends(chat): client, post, _, _ = chat tid = post(A, "First").get_json()["data"]["thread_id"] diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index acc3d61..8d9625d 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -279,13 +279,26 @@ embeds the demo documents once per server and uploads the files in the backgroun - **No layout shift from overlays.** `globals.css` sets `scrollbar-gutter: stable` and cancels Radix's scroll-lock margin (`body[data-scroll-locked]`), so opening a menu or dialog never moves the page. Guarded by `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`: + production). Firefox is opt-in with `E2E_FIREFOX=1`. The e2e dev server builds into `.next-e2e` (`NEXT_DIST_DIR`), + so running tests never breaks a `npm run dev` in the same checkout. `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 +- **Page skeleton (UI revamp Phase 5).** Every redesigned page is built from `components/page/`: `PageHeader` + (26px title, quiet actions, serif brief, optional control row; `md:pr-12` leaves room for the floating account + menu), `PageCard` (title row with optional `›` link, `extra` and `action`; a skeleton at the card's final height, + so nothing moves when data arrives) and `VendorChip` (initials on a stable per-vendor hue, `vendorChip` in + `lib/home-math.ts`). Buttons on these pages use the `quiet` and `ink` variants, never the default blue. Charts use + `lib/chart.ts`: `smoothPath` (monotone cubic, so a running total never dips between days) and `pickTicks` + (axis labels that never overlap at any width). Approved mockups: `docs/superpowers/specs/2026-10-22-phase5/`. +- **Home** (`app/(app)/home/`, Home v2 from Phase 5a, modelled on Origin's app home): greeting, quiet Upload and + Export, and a serif brief over a sky band. Then a full-width hero: Spent in (total, change vs the previous + period by the same day, "heading for" from the forecast, smooth now-vs-before lines, bill markers on the axis, + 1W/1M/3M/YTD, `+` opens upload). Below it, the main column has ", day by day" (amount per day, today + outlined, upcoming bill names on future days, always as tall as a six-week month) beside Recent transactions, then + Lumen found as a list whose links act ("Ask Lumen" opens `/ask?draft=` with the question typed but not sent). The + side column has Budget in (spent of total, % bar, top four categories by spend), Coming up (next four + bills), the draft-budget promo and Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, reminders, forecast, review + queue). `/api/analytics/reminders` sends `title`/`predicted_date`/`metadata`; `toReminders` turns that into bills + once (vendor patterns only, rows without a date skipped). 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`). **Phase 3 backend:** `GET /api/home/summary` (`api/home.py` over the pure `ai/home_signals.py`, no LLM) supplies Lumen found and Activity: @@ -324,7 +337,8 @@ embeds the demo documents once per server and uploads the files in the backgroun comparable. - **The page.** Opening Ask starts a fresh chat with suggestions (from Home's "Lumen found" via `suggestedQuestions`, then `/chat/suggestions`). Past chats sit in `thread-rail.tsx` (a column on desktop, a Sheet - on small screens). `/ask?q=…` starts a new chat with that question once, then cleans the URL. + on small screens). `/ask?q=…` starts a new chat with that question once, then cleans the URL. `/ask?draft=…` + starts a new chat with the question typed into the composer but not sent (Lumen found, Bills "How to cancel"). - **Answers.** `POST /chat` returns the markdown answer plus its evidence: `sources` (passage `text` and the original's `file_key`), `steps` (tool, summary, `latency_ms`) and `proposals`. The SQL the agent ran stays server-side. Saved messages keep only the text, so older answers show citation numbers without passages. diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index a80df55..93df1a9 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -385,3 +385,25 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in never on reload, a rail that doesn't move the conversation, and Caps-Lock-proof ⌘K. Screenshots caught two bugs the tests then pinned: an unreadable blue selected thread and a 24 px sidebar overflow left over from Phase 1. +## 32. The owner runs the revamp and Home becomes Origin-like (UI revamp, review round + Phase 5a) +- **S:** Running Phases 0–4 locally, the owner found the app still felt like the old one: Home didn't resemble the + inspiration (Origin's app home), labels used a generic all-caps mono font, only one sidebar group was labelled, the + collapse button sat in an empty top bar, and an "Upload an invoice…" chip sent that sentence to the AI as a question. + `/spending` had cards that never loaded, and Home then crashed on real data. +- **T:** Fix what the owner flagged, agree a look for Home and the four inner pages from mockups first, then rebuild + Home on one shared page skeleton without breaking the zero layout-shift rule. +- **A:** Labelled every sidebar group, moved the collapse control into the sidebar header (no desktop top bar), and + switched labels to Funnel Sans in sentence case. Upload follow-ups became an upload button; other "do it yourself" + steps stay in the answer text. Mocked Home plus Spending, Bills, Budget and Receipts in the Origin style (four + mockups built in parallel by subagents) and wrote the Phase 5 spec and plan. Built `PageHeader`/`PageCard`, a + monotone curve and collision-free axis ticks, then the new Home: hero chart with "heading for", a day-by-day calendar + with amounts and bill names, Lumen found as a list whose links act (`/ask?draft=` types a question without sending + it), budget categories and Coming up. Upload and Export went quiet and their dialogs light. Ask fixes ran in + parallel in a separate worktree. Root causes found while testing on the owner's data: Home expected a bills shape the + API never sent (so bills never showed, and the new calendar crashed), three Spending cards read fields no backend + code produces (removed until 5b/5c rebuild them), and the e2e server shared the dev server's `.next` folder. +- **R:** Backend 979 passed (chat thread and its messages saved in one commit; a clear 409 on a lost approval race). + Frontend 128 e2e checks (up from 102): every Home card measured to the same height loading and loaded, ticks that + never overlap at 375 px, light dialogs, a draft that never sends itself, and the real reminders shape in the + fixtures. Screenshots caught three bugs the tests then pinned: a width observer that never attached, cut-off + calendar amounts on phones, and a doubled close button. diff --git a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md new file mode 100644 index 0000000..6143227 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md @@ -0,0 +1,203 @@ +# UI revamp Phase 5 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build Home v2 and the Spending, Bills & subscriptions, Budget and Receipts & statements pages to the approved mockups, on one shared page skeleton, with the cheap backend each page needs. + +**Architecture:** A small set of shared page pieces (`components/page/*`, `lib/chart.ts`) is built first and used by every page so they can't drift. Each sub-phase (5a–5e) is one stacked PR into `refactor`. Pages keep fetching through `lib/api/client.ts`; new backend routes are FastAPI routers in `backend/api/` like `budget.py`. + +**Tech Stack:** Next.js (app router), Tailwind v4 + shadcn tokens (Sky theme), motion, Playwright e2e; FastAPI + SQLAlchemy (Flask-SQLAlchemy models), pytest. + +**Spec:** `docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md` (mockups in `docs/superpowers/specs/2026-10-22-phase5/`), parent `docs/superpowers/specs/2026-10-20-ui-revamp-design.md`. + +## Global Constraints + +- Labels: `.label-ui` (Funnel Sans, sentence case). No all-caps mono, no shadcn default blue `Button` on these pages, no dark dialog in the app. +- Nothing the data can't back is shown: deferred elements are hidden, never faked (spec §2, §6). +- A control whose label is an action does that action; it never sends its text to the chat. +- Zero layout shift: every range/filter/month change, hover-edit and dialog keeps its box; each gets a bounding-box test. +- Pages work at 375px wide with no sideways scroll; leave `md:pr-12` room for the floating account menu in header rows. +- Inside `.bui` (Ask) use bui tokens (`bg-hover`, `bg-surface`, `border-line`, `text-ink-2`), never shadcn `accent`. +- Git: branch names without "claude", no attribution lines in commits or PRs; PRs into `refactor` on coderconnoisseur/Lumen. +- Backend test env: `OPENROUTER_API_KEY=test-key SECRET_KEY=test-secret-key-for-ci-only SUPABASE_URL=https://example.supabase.co python -m pytest -q` from `backend/`. +- Frontend lint/build env: `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY=e2e-dummy NEXT_PUBLIC_BACKEND_URL=http://127.0.0.1:9`. + +## Review Focus + +1. **Empty account** (no transactions, no budget, no bills): every Home card shows its empty state at its loading height; no NaN, "₹0 of ₹0", or a chart line at zero. Pinned by Task 3 and Task 6 empty-state tests. +2. **Phone width (375px)**: chart ticks never overlap (the reported "01 OC09 OCT"), calendar cells keep amounts readable, header actions wrap under the title without covering the account menu. Pinned by Task 2 (`pickTicks`) and Task 3 / Task 1 bounding-box tests. +3. **Forecast missing or outside the month** (backend forecasts from the last transaction date): no "heading for" text and no dotted line. Pinned in Task 3. +4. **Ask prefill** (`/ask?draft=`) must never send by itself, also after a reload or when Ask is already mounted. Pinned in Task 5. +5. **Ask busy state**: Keep/Delete in the thread rail are disabled while an answer is in flight; deleting the open thread mid-answer must not orphan the answer. Pinned in Task 8. + +--- + +## Phase 5a — shared skeleton, Home v2, dialogs, Ask/§12 fixes (detailed) + +Branch: `ui-revamp/phase5-pages` (contains the spec commit; stacked on `ui-revamp/polish`). + +### Task 1: Page header, page card, quiet buttons + +**Files:** +- Create: `frontend/src/components/page/page-header.tsx`, `frontend/src/components/page/page-card.tsx` +- Modify: `frontend/src/components/ui/button.tsx` (variants), `frontend/src/app/(app)/home/_components/card.tsx` (delete; callers import `PageCard`), all Home cards that import `HomeCard`, `frontend/src/app/(app)/home/homeContent.tsx` +- Test: `frontend/e2e/home.spec.ts` + +**Interfaces:** +- Produces: `PageHeader({ title: ReactNode; brief?: ReactNode; actions?: ReactNode; children?: ReactNode })` — renders `

`, actions right-aligned in the same row with `md:pr-12`, brief below in `font-serif text-[19px] text-ink-2`-equivalent (`text-foreground/80`), `children` = a control row under the brief. +- Produces: `PageCard({ title: ReactNode; href?: string; action?: ReactNode; extra?: ReactNode; loading?: boolean; skeletonHeight: number; className?; "data-testid"?; children })` — same contract as today's `HomeCard` (skeleton at final height, `minHeight` after), restyled: `rounded-2xl border border-border/70 bg-card px-5 py-[18px] shadow-[0_1px_2px_rgba(28,28,26,.04),0_4px_16px_-8px_rgba(28,28,26,.08)]`; title row = `.label-ui` title, `›` after it when `href`, `extra` (legend, pill) then `action` at the right. +- Produces: `Button` variants `quiet` (`border border-border bg-card/70 text-foreground/80 hover:bg-card hover:text-foreground backdrop-blur`) and `ink` (`bg-foreground text-background hover:bg-foreground/90`); size `pill-icon` (`size-[34px] rounded-full`). + +- [ ] **Step 1: Write the failing test** `home header keeps clear of the account menu` in `e2e/home.spec.ts`: at 1440×900 and 1024×800, `signIn`, goto `/home`; the bounding box of `getByRole("button", { name: "Account menu" })` does not intersect the boxes of `getByRole("button", { name: /upload/i })` and `getByRole("button", { name: "Export" })`; `getByRole("heading", { level: 1 })` has `font-size` `26px`. +- [ ] **Step 2: Run** `npx playwright test e2e/home.spec.ts -g "account menu"` — Expected: FAIL (font-size 24px / names differ). +- [ ] **Step 3: Implement** `PageHeader`, `PageCard`, the button variants; switch Home's greeting row to `PageHeader` (title = greeting, brief = ``), replace every `HomeCard` import with `PageCard` and delete `card.tsx`. +- [ ] **Step 4: Run** the new test and the whole `e2e/home.spec.ts` — Expected: PASS. +- [ ] **Step 5: Commit** `feat: shared page header and card for the Phase 5 pages`. + +### Task 2: Chart helpers + +**Files:** +- Create: `frontend/src/lib/chart.ts` +- Test: `frontend/e2e/home-math.spec.ts` (pure-function tests live here) + +**Interfaces:** +- Produces: `smoothPath(points: [number, number][]): string` — SVG path through every point; monotone cubic (Fritsch–Carlson) so a running total never dips or overshoots between points; 0 points → `""`, 1 point → `"M x y"`. +- Produces: `pickTicks(candidates: { i: number; x: number; label: string; keep?: boolean }[], minGap: number): typeof candidates` — keeps `keep` ticks (today, first, last) and drops any other tick closer than `minGap` px to a kept one, left to right. + +- [ ] **Step 1: Write failing tests** in `e2e/home-math.spec.ts`: + - `smoothPath([[0,10],[10,20],[20,20],[30,40]])` starts with `"M0 10"`, contains `"20 20"` and ends with `"30 40"`. + - Sampling the cubic segments of `smoothPath([[0,0],[10,100],[20,100],[30,101]])` at t=0.25/0.5/0.75 never gives y > 101 or a y lower than the previous sample (monotone, no overshoot). Parse the `C` commands to compute the samples in the test. + - `pickTicks` with x = 0, 20, 40, 300, 310 (`keep` on 0, 300, 310) and `minGap: 48` returns labels for 0, 300, 310 only. +- [ ] **Step 2: Run** `npx playwright test e2e/home-math.spec.ts` — Expected: FAIL (module not found). +- [ ] **Step 3: Implement** both in `lib/chart.ts`. +- [ ] **Step 4: Run** — Expected: PASS. +- [ ] **Step 5: Commit** `feat: smooth monotone chart path and tick picking`. + +### Task 3: Hero spend card v2 + +**Files:** +- Modify: `frontend/src/app/(app)/home/_components/spend-card.tsx` +- Test: `frontend/e2e/home.spec.ts` + +**Interfaces:** +- Consumes: `PageCard`, `smoothPath`, `pickTicks`; existing props of `SpendCard` plus `onUpload: () => void`. +- Title (`TITLE` map): `"Spent in "` for 1M (month from `period.start`), `"Spent this week"`, `"Spent this quarter"`, `"Spent in "`; `href="/spending"`. + +- [ ] **Step 1: Write failing tests** in `e2e/home.spec.ts` (use the existing home fixture with transactions and forecast): + - `hero shows delta, percent and the forecast total`: text matches `/\+₹[\d,]+ \(\d+%\) vs \w+ by the \d+(st|nd|rd|th)/` and `/heading for ₹[\d,]+/`. + - `no forecast, no heading-for`: fixture forecast dates all in an earlier month → no `heading for` text and no `[data-series="forecast"]`. + - `ticks never overlap at phone width`: viewport 375×812; the bounding boxes of all `[data-testid="tick"]` are pairwise non-overlapping. + - `the + button opens upload`: click `getByRole("button", { name: "Upload a receipt" })` inside `[data-testid="spend-card"]` → a dialog is visible. + - keep the existing range-change bounding-box test green. +- [ ] **Step 2: Run** `npx playwright test e2e/home.spec.ts -g "hero|ticks|\\+ button"` — Expected: FAIL. +- [ ] **Step 3: Implement**: number 36px medium; delta line `+₹X (Y%) vs by the ` (bad colour when up, good when down; for 3M/YTD "vs last period"/"vs "); `· heading for ` only when the forecast is drawn; legend (Month / Previous / Forecast) in `extra`; `+` icon button (`aria-label="Upload a receipt"`) in `action`; paths via `smoothPath`; chart height 200px; bill pins become 22px round markers on the x-axis with the vendor's first letter and `title={vendor}`; ticks via `pickTicks` with `data-testid="tick"`, `minGap` 48; range chips centred below. +- [ ] **Step 4: Run** `e2e/home.spec.ts` — Expected: PASS. +- [ ] **Step 5: Commit** `feat: Home hero card in the Origin style`. + +### Task 4: "Month, day by day" calendar + recent transactions + +**Files:** +- Modify: `frontend/src/app/(app)/home/_components/calendar-card.tsx`, `frontend/src/lib/home-math.ts` (export `ordinal(n)` if Task 3 didn't) +- Test: `frontend/e2e/home.spec.ts` + +**Interfaces:** +- Consumes: `PageCard`; new prop `reminders: Reminder[]`, `today: Date`. +- Produces: `vendorChip(name: string): { initials: string; hue: number }` in `lib/home-math.ts` (2 initials, stable hue from the name) — reused by Spending/Bills. + +- [ ] **Step 1: Write failing tests**: `calendar marks today and names future bills` — the cell for today's date has `data-today="true"`; a fixture reminder dated after today puts its vendor text in that day's cell; future cells have `data-future="true"`; the card title links to `/spending`; `See all` in Recent transactions links to `/spending`. `vendorChip("Swiggy Instamart")` → `initials: "SI"` and the same hue on two calls (home-math.spec). +- [ ] **Step 2: Run** — Expected: FAIL. +- [ ] **Step 3: Implement** per `home.html`: title `", day by day"`, month total + "across N days" (days with spend), 50px cells with day number top and short amount bottom, heat shade as today, today outlined in primary, future cells dashed with up to one bill name; recent rows with a 28px initials chip from `vendorChip`. +- [ ] **Step 4: Run** — Expected: PASS. +- [ ] **Step 5: Commit** `feat: day-by-day calendar with amounts and bill names`. + +### Task 5: Lumen found as a list, Ask prefill + +**Files:** +- Modify: `frontend/src/app/(app)/home/_components/lumen-found.tsx`, `frontend/src/app/(app)/ask/chatbotContent.tsx`, `frontend/src/components/ask/prompt-bar.tsx` (accept an initial value if it doesn't) +- Test: `frontend/e2e/home.spec.ts`, `frontend/e2e/ask.spec.ts` + +**Interfaces:** +- Produces: `/ask?draft=` — opens a fresh chat with the composer filled with ``, focused, **not sent**; the URL is cleaned (`router.replace("/ask")`) so a reload doesn't refill. Used by Lumen found "Ask why", Spending, and Bills "How to cancel". +- Produces: `askWhy(item: FoundItem, money): string` in `lumen-found.tsx` — `"Why " + headline (filled, lower-cased first letter, no trailing period) + "?"`. + +- [ ] **Step 1: Write failing tests**: + - home: `Lumen found is a list with doing links` — each item is a row with an icon, headline, detail, a primary link to `item.href` and an "Ask why" link whose href starts with `/ask?draft=`. + - ask: `draft prefills without sending` — goto `/ask?draft=Why%20is%20Food%20up%3F`; composer value is `Why is Food up?`; no `/chat` POST happened (count requests in the fixture route); URL becomes `/ask`; reload → composer empty. +- [ ] **Step 2: Run** — Expected: FAIL. +- [ ] **Step 3: Implement** list layout per `home.html` (32px tinted icon by `kind`: duplicate=bad, spike=warn, price_rise/review=primary; title row pill "N to look at"); `?draft=` handling next to the existing `?q=` effect, guarded by the same `lastQ`-style ref. +- [ ] **Step 4: Run** — Expected: PASS. +- [ ] **Step 5: Commit** `feat: Lumen found list with real actions; /ask?draft= prefills`. + +### Task 6: Budget card v2, Coming up, Home layout + +**Files:** +- Modify: `frontend/src/app/(app)/home/_components/budget-card.tsx`, `bills-card.tsx`, `homeContent.tsx` +- Test: `frontend/e2e/home.spec.ts` + +**Interfaces:** +- Consumes: `BudgetState` from `lib/api/client.ts` (unchanged), `PageCard`, `vendorChip`. + +- [ ] **Step 1: Write failing tests**: + - `budget card shows spent of total, percent and top categories`: with a fixture budget of 4+ categories → text `/₹[\d,]+ of ₹[\d,]+/`, a `%` label, exactly 4 `[data-testid="budget-cat"]` rows ordered by spent desc; an over-budget category row has `data-over="true"`. + - `empty account Home`: fixture with no transactions/budget/reminders → no text matching `/NaN|of ₹0\b/`; every card's box height equals its loading skeleton height ±1px (record during loading via a delayed route, compare after). + - `coming up lists the next four bills`: 6 upcoming reminders → 4 rows with date chips (weekday + day). + - existing draft-budget tests stay green (promo still opens the draft dialog in 5a; 5d moves it). +- [ ] **Step 2: Run** — Expected: FAIL. +- [ ] **Step 3: Implement** per `home.html`: layout = hero full width, then `grid lg:grid-cols-[minmax(0,1fr)_380px]`: left = calendar card, Lumen found; right = Budget in , Coming up, Activity (kept at the bottom for now). Budget: big spent of total, green bar + %, then top 4 category bars (green / amber ≥90% / red over). +- [ ] **Step 4: Run** full `e2e/home.spec.ts` — Expected: PASS. +- [ ] **Step 5: Commit** `feat: Home v2 layout, budget and coming-up cards`. + +### Task 7: Upload and Export restyle, light upload dialog + +**Files:** +- Modify: `frontend/src/components/uploadDialog.tsx`, `frontend/src/components/exportDialog.tsx`, `homeContent.tsx` +- Test: `frontend/e2e/home.spec.ts` + +**Interfaces:** +- Produces: `DialogDemo` gains `trigger?: ReactNode` (default: ``) and `open`/`onOpenChange` passthrough so the hero `+` (Task 3) and `?upload=1` open the same dialog. `ExportDialog` default trigger = `Button variant="quiet" size="pill-icon" aria-label="Export"` with a download icon. + +- [ ] **Step 1: Write failing tests**: `upload dialog is light` — open it; computed `background-color` of `[role="dialog"]` has luminance > 0.9 (parse rgb); no element inside has class matching `neutral-9|#0b0612`. `Home buttons are quiet` — the Upload button's computed background is not `rgb(37, 99, 235)` (blue-600). +- [ ] **Step 2: Run** — Expected: FAIL. +- [ ] **Step 3: Implement**: dialog on Sky tokens (`bg-card`, `border-border`, `text-foreground/muted-foreground`), drop zone `border-dashed border-border bg-muted/40`, file rows `bg-muted/50`; keep all upload logic untouched. +- [ ] **Step 4: Run** — Expected: PASS. +- [ ] **Step 5: Commit** `feat: quiet Upload/Export and a light upload dialog`. + +### Task 8: Ask and chat §12 fixes + +**Files:** +- Modify: `frontend/src/app/(app)/ask/thread-rail.tsx`, `frontend/src/components/app-sidebar.tsx` (render `AskPalette` outside ``; lift `paletteOpen` into `app-shell.tsx` or a tiny context), `backend/routes/chat.py` (`_thread_for` + `_save_exchange` in one commit), `backend/api/agent.py` (409 text) +- Test: `frontend/e2e/ask.spec.ts`, `frontend/e2e/shell.spec.ts`, `backend/tests/test_chat_threads.py` (or the existing chat test module), `backend/tests/test_agent_api.py` (existing proposals tests) + +**Interfaces:** +- Backend: `_thread_for(user_id, thread_id, question)` no longer commits; the `/chat` handler commits once after `_save_exchange`. On a lost claim, both 409s say `"This proposal was already decided."`. + +- [ ] **Step 1: Write failing tests**: + - e2e ask: `rail actions wait for the answer` — with a delayed `/chat` route, while pending the thread's Delete button is disabled; opening the delete confirm moves focus to "Keep"; at 375px with the history sheet open the trash button is visible without hover. + - e2e shell: `ctrl+k works on a tablet with the sheet closed` — viewport 768×1024 (below the md sheet breakpoint if 768 is still sheet; else 767), press Control+k → palette dialog visible. + - backend: `test_chat_save_failure_leaves_no_empty_thread` — monkeypatch the message save to raise; after the failed POST, the user has 0 threads. + - backend: `test_lost_claim_says_already_decided` — approve twice concurrently (or claim then approve) → 409 with detail `"This proposal was already decided."`. +- [ ] **Step 2: Run** them — Expected: FAIL. +- [ ] **Step 3: Implement** the four fixes. +- [ ] **Step 4: Run** `e2e/ask.spec.ts e2e/shell.spec.ts` and backend `pytest -q` — Expected: PASS. +- [ ] **Step 5: Commit** `fix: Ask rail busy/focus/touch, palette outside the sheet, one-commit chat save, clear 409`. + +### Task 9: 5a docs, full checks, PR + +- [ ] **Step 1:** STORY.md entry `## 32.` (owner review round + Home v2: what was flagged, what changed, test counts), CODEBASE-GUIDE: `components/page/*`, `lib/chart.ts`, `/ask?draft=`; spec §12: tick the Phase 5 items done in 5a. +- [ ] **Step 2:** Run backend `pytest -q`, `pytest -q -m eval`; frontend `npm run lint`, `npm run build` (dummy env), `npm run test:e2e`. Expected: all green. +- [ ] **Step 3:** Screenshot `/home` at 1440 and 375 with seeded fixture data (throwaway `e2e/tmp/shot.spec.ts`) and compare with `home.html` side by side; fix visible drift. +- [ ] **Step 4:** Push `ui-revamp/phase5-pages`, open PR into `refactor` (or stacked on `ui-revamp/polish` until #96 merges) with manual checks: Home matches the mockup on your seeded account; hero `+` and Upload open the same light dialog; "Ask why" fills Ask without sending; phone width has no overlapping ticks. + +--- + +## Phase 5b — Spending (outline; detail when reached) +Backend: none expected (transactions, categories, `/api/home/summary` findings exist); confirm category-update endpoint for the hover edit. Tasks: period control + compare toggle (layout-shift test); hero (cumulative + month bars inset; 3M/YTD bars as main chart); By category bars with click-to-filter (`?category=`); Lumen noticed (summary findings, no "first time" card); Transactions table (search, filters, hover category edit, `vendorChip`), Top merchants; Export CSV of the filtered view; remove the stacked old pages (single `

`, no calendar/reminders). STORY 33. + +## Phase 5c — Bills & subscriptions (outline) +Backend (TDD): `RecurringState` table (`user_id`, `vendor_key`, `state`, `changed_at`); `GET /api/bills/recurring` (wraps `find_recurring`, applies states: `not_a_bill` hidden, `cancelled` dimmed unless charged after `changed_at`); `POST /api/bills/recurring/state {vendor, state}`; Home's Coming up switches to it. Frontend: tiles, month calendar + Upcoming agenda (This week / Next week / Later), subscriptions table with tabs and actions (Mark as cancelled, Not a bill + Hidden undo, How to cancel → `/ask?draft=How do I cancel ?`). STORY 34. + +## Phase 5d — Budget page (outline) +Backend (TDD): `Budget.effective_from` (migration + create_all column list; existing rows → created month); `get_budget(month)` = latest row per category with `effective_from <= month`; `GET /api/budget?month=YYYY-MM`, `GET /api/budget/history?months=6`; edits write rows effective from the current month; draft approval writes rows effective from the draft's month; `basis.months` only months with spend; draft buttons' accessible names include the category. Frontend: `/budget` page + sidebar Plan group; hero with even-pace tick and pace chart; category table with inline edit (no row height change); draft panel on the page (Home promo links here); last 6 months bars. STORY 35. + +## Phase 5e — Receipts & statements (outline) +Backend (TDD): `StatementImport` table + write on import; `GET /api/statements/imports`. Frontend: header actions; Needs-your-review cards from the review queue with highlights from validation flags (replaces the Review tab); library list with filter chips (Statements = import rows) and search; Statement imports card; From your inbox card; Activity moves here from Home if it no longer fits. Old `/documents`, `/import`, `/review` routes redirect into `/receipts`. STORY 36, then the whole-branch review for Phase 5. 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 306eef4..e933694 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -214,7 +214,7 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **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)* - [x] ~~**Phase 6.** `(app)/layout` reads the sidebar cookie on the server.~~ Not needed: the sidebar only renders client-side (behind the auth guard), so Phase 1 reads the cookie there and the app routes stay static. *(Phase 1)* -- [ ] **Phase 5.** Spending and Bills both show the payment calendar and reminders cards in the interim. Remove the duplicate when the pages are redesigned. *(Phase 1 decision)* +- [x] **Phase 5.** Spending and Bills both show the payment calendar and reminders cards in the interim. Remove the duplicate when the pages are redesigned. *(Phase 1 decision)* *(Done in 5a: those cards read fields the backend never sent and were removed; Bills shows upcoming bills until 5c.)* - [x] **Phase 3.** The sidebar's review badge downloads every flagged item just to count them. Add a count-only endpoint (e.g. `GET /api/review?status=flagged&count=1`). *(Phase 1 review)* - [ ] **Phase 5.** `/spending` stacks two old pages, so it has two `

`s and fetches the calendar and reminders twice. It goes away with the redesign. *(Phase 1 review)* - [ ] **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)* @@ -222,11 +222,11 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **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)* - [x] **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)* +- [x] **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)* *(Done in 5a.)* - [x] **Phase 3.** The calendar's `kShort` hardcodes `₹`; use the user's currency symbol like `useMoney`. *(Phase 2 review)* - [x] **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)* +- [x] **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)* *(Done in 5a at the desktop layout; see the stacked-layout item below.)* - [x] **Phase 3.** Confirm statement imports write rows before the POST returns. Confirmed: `api/statements.py` commits inside the request. *(Phase 2 review)* - [ ] **Later (owner request).** Make "Lumen found" and the budget draft smarter. A plain 3-month mean is skewed by one-off big purchases (a phone, a laptop). Ideas: a median or trimmed mean per category; treat single large purchases as one-offs and leave them out of the limit (or suggest a separate "planned purchases" line); use seasonality and income; let the agent (LLM) explain or adjust a draft line, while approval still stays with the user. Measure any new rule against an eval set before switching. *(Phase 3)* - [x] **Phase 4.** `_decide` (api/agent.py) checks `status == pending` without a conditional UPDATE. Two concurrent approvals on Postgres can both apply (duplicate audit lines), and two approvals for the same new category can return 500 on the unique constraint. Use the conditional `update(...).where(status=="pending")` pattern from `_save_invoice`. *(Phase 3 review)* @@ -234,10 +234,12 @@ Small items found while building, parked here so they aren't lost. Tick them off - [x] **Phase 4.** After approving draft lines, Home reloads only the budget. Also reload the summary so Activity shows "You approved…" at once. *(Phase 3 review)* - [ ] **Phase 6.** `/api/home/summary` and `/api/budget` each scan all of a user's transactions in Python on every Home visit. Fine for a few thousand rows; filter by date in SQL (and cache) if a user grows past that. *(Phase 3 review)* - [ ] **Phase 5.** Draft dialog: per-line buttons are named just "Approve" / "Skip". Add the category to their accessible names. The `basis.months` list always shows 3 months even when only 2 had spend. *(Phase 3 review)* -- [ ] **Phase 5.** `POST /chat` commits a new thread before saving its messages. If that save fails, the response points at an empty titled thread. Create both in one commit. *(Phase 4 review)* -- [ ] **Phase 5.** A lost approval race returns 409 "Proposal is already pending" (status read before the claim). Re-read the status or use a generic message. *(Phase 4 review)* +- [x] **Phase 5.** `POST /chat` commits a new thread before saving its messages. If that save fails, the response points at an empty titled thread. Create both in one commit. *(Phase 4 review)* *(Done in 5a.)* +- [x] **Phase 5.** A lost approval race returns 409 "Proposal is already pending" (status read before the claim). Re-read the status or use a generic message. *(Phase 4 review)* *(Done in 5a: generic "This proposal was already decided.")* - [ ] **Phase 6.** The `chat_messages.thread_id` index exists only on fresh databases (`_ADDED_COLUMNS` adds the column, not the index). Add `CREATE INDEX IF NOT EXISTS`. Fine at current scale because every query also filters on the indexed `user_id`. *(Phase 4 review)* -- [ ] **Phase 5.** Thread rail: disable Keep/Delete while an answer is in flight; move focus to "Keep" when the confirm row opens; the trash icon is hover-only, so it's invisible on touch (mobile Sheet). *(Phase 4 review)* -- [ ] **Phase 5.** Below 768px the sidebar is a Sheet, and `AskPalette` renders inside it, so Ctrl+K on a tablet keyboard does nothing until the sheet is open. Render the palette outside ``. *(Phase 4 review)* +- [x] **Phase 5.** Thread rail: disable Keep/Delete while an answer is in flight; move focus to "Keep" when the confirm row opens; the trash icon is hover-only, so it's invisible on touch (mobile Sheet). *(Phase 4 review)* *(Done in 5a.)* +- [x] **Phase 5.** Below 768px the sidebar is a Sheet, and `AskPalette` renders inside it, so Ctrl+K on a tablet keyboard does nothing until the sheet is open. Render the palette outside ``. *(Phase 4 review)* *(Done in 5a: the palette lives in app-shell.)* - [ ] **Later.** Suggested question "What's waiting for my review?": check the agent can answer it (no review-queue tool). `price_rise` could use its subject ("Why did Netflix go up?"). *(Phase 4 review)* +- [ ] **Phase 5c.** Home's "Coming up" and bill pins come from `/api/analytics/reminders`, which only has rows after the spending-pattern analysis has run (the old Spending page triggers it). Switch Home and Bills to the recurring-charges API in 5c. *(5a, owner report)* +- [ ] **Phase 5d.** Below `lg` the Home cards stack and their placeholders no longer match the loaded heights; the Budget card also grows once a budget exists. Size placeholders per breakpoint, or load the budget with the page. *(5a)* - [ ] **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/docs/superpowers/specs/2026-10-22-phase5/bills.html b/docs/superpowers/specs/2026-10-22-phase5/bills.html new file mode 100644 index 0000000..90c53c5 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/bills.html @@ -0,0 +1,230 @@ + +Bills mockup + + + + +
+ + +
+
+
+
+

Bills & subscriptions

+
+ ‹October 2026› + Upload + + NB +
+
+

Rent of ₹25,000 is due Friday, and your BESCOM bill is three days late. Two subscriptions look unused, about ₹1,618 a month.

+ +
+
Due in the next 30 days +
₹51,664
8 bills · biggest is rent on 24 Oct
+
Recurring every month +
₹32,383
9 regular charges · yearly ones spread per month
+
Subscriptions you may not use +
₹1,618 a month
2 subscriptions · Spotify, Cult.fit
+
+ +
+ +

BESCOM electricity, ₹1,860, was due Sun 19 Oct

No payment seen yet. It usually lands within 2 days of the bill.

+ +

Airtel postpaid was paid 3 days late on 14 Oct.

+
+ Mark as paid + Remind me tomorrow + Not a bill +
+
+ +
+
+
October, bill by bill +
PaidDuePaid lateOverdue
+
+
Paid so far ₹21,106Still due this month ₹25,999Overdue ₹1,860
+
+ +
+
UpcomingNext 30 days
+
This week₹25,000
+
Fri24RentMonthly · Rent & housing₹25,000
+
Next week₹1,767
+
Mon27Jio FiberMonthly · Broadband₹999
+
Sat1NetflixMonthly · Streaming₹649
+
Sun2Spotify unusedMonthly · Streaming₹119
+
Later₹24,897
+
Wed5Cult.fit unusedMonthly · Fitness₹1,499
+
Tue11Airtel postpaidMonthly · Phone₹599
+
Fri14Amazon PrimeYearly · Subscriptions₹1,499
+
Sun16HDFC credit cardStatement due · variesabout₹21,300
+
+
+ +
+
Subscriptions + Subscriptions 6Bills 5All 11 + Sorted by: needs a look first
+
NameLast chargedNextAmount
+ +
+ + Spotify unused 7 weeksMonthly · since Mar 2024 + 2 Oct2 Nov + ₹119a month + Mark as cancelledSet reminderHow to cancel +
+
+ + Cult.fit unused 5 weeksMonthly · since Jan 2026 + 5 Oct5 Nov + ₹1,499a month + Mark as cancelledSet reminderHow to cancel +
+
+ + Netflix ₹499 → ₹649Monthly · price went up in Aug + 1 Oct1 Nov + ₹649a month + ··· +
+
+ + Amazon PrimeYearly · ₹125 a month spread + 14 Nov 202514 Nov + ₹1,499a year + ··· +
+
+ + Jio FiberMonthly · broadband + 27 Sep27 Oct + ₹999a month + ··· +
+
+ + YouTube Premium looks stoppedMonthly · no charge since 9 Aug + 9 Aug— + ₹149a month + HideConfirm cancelled +
+
+
+
+
+ +
Bills: decisions + open questions +
    +
  1. Calendar shows bills, not spend (Home already has spend). Paid/due/late/overdue as soft fills, one bill per cell + "+1" if more.
  2. +
  3. Row actions only on flagged subscriptions; calm ones get a ··· menu. OK?
  4. +
  5. "How to cancel" opens Ask Lumen with a ready question; Mark cancelled / Set reminder act in place.
  6. +
  7. Needs backend: REST route for find_recurring (agent-only today); paid/late/overdue matching per cycle; "unused" (no usage data, only "looks stopped"); saved cancelled/reminder/not-a-bill state; card-bill due dates (amount varies, not detected).
  8. +
  9. Quarterly cadence (LIC) isn't detected (weekly/monthly/yearly only). Add?
  10. +
+ + + diff --git a/docs/superpowers/specs/2026-10-22-phase5/budget.html b/docs/superpowers/specs/2026-10-22-phase5/budget.html new file mode 100644 index 0000000..d484157 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/budget.html @@ -0,0 +1,253 @@ + +Budget mockup + + + + +
+ + +
+
+
+
+

Budget

+
‹October 2026›
+
+ Edit budget + Let Lumen draft +
+
+

You're on track: ₹8,786 under where an even pace would put you. Shopping is the one line over, mostly the Croma order.

+ +
+
+
Spent in OctoberOn track
+
₹62,182 of ₹1,00,000
+ +
62% spentEven pace today: 71%
+
+
Left to spend₹37,818
+
Days left9
+
Safe per day₹4,091after ₹999 Jio Fiber
+
+
+
+
Pace +
ActualEven pace to ₹1,00,000
+ +
+
+ +
+
+
+
By category1 over1 closeSort: most used ▾
+ + + + + + + + + + + + + +
CategoryBudgetSpentLeftUsed
Shopping₹8,000₹16,840₹8,840 over
210% · Croma ₹9,999 is most of it
Food delivery₹6,000₹5,930₹70
99%
Rent & housing₹25,000₹25,000₹0
Paid in full · 3 Oct
Groceries₹11,000Enter to save · Esc₹8,120₹2,880
74% at ₹11,000
Travel₹5,000₹2,140₹2,860
43%
Bills & utilities₹6,000₹1,998₹4,002
33% · ₹999 due 27 Oct
Entertainment₹3,000₹768₹2,232
26%
Health₹3,000₹640₹2,360
21%
Other₹5,000₹746₹4,254
15%
+
Categories add up to ₹73,000 · ₹27,000 of your ₹1,00,000 is unassigned+ Add category
+
+
+ +
+

Lumen's draft for November

2 of 6 decided
+

Built from Jul–Sep. Each line is a monthly limit; nothing changes until you approve it.

+ +
Rent & housing₹25,000no change + Same ₹25,000 every month.✓ Approved
+ +
Shoppingone-off excluded₹9,000was ₹8,000 + Based on the Jul–Sep median (₹8,410), excluding a one-off ₹9,999 Croma order on 18 Aug. + SkipApprove
+ +
Groceries₹11,000was ₹12,000 + Averaged ₹10,620 a month over Jul–Sep, rounded up. You've stayed under ₹12,000 all three months. + SkipApprove
+ +
Food delivery₹6,500was ₹6,000 + Averaged ₹6,230 over Jul–Sep and rising each month. Approving accepts the trend; skipping keeps ₹6,000. + SkipApprove
+ +
Travel₹4,000was ₹5,000 + Only Aug and Sep had travel (₹3,640 avg). July had none, so it's left out of the average. + SkipApprove
+ +
Health₹2,000was ₹3,000 + Averaged ₹1,720 over Jul–Sep, rounded up.Skipped · keeps ₹3,000
+ +
Approve remaining 4Nov total would be ₹73,500 of ₹1,00,000
+
+
+ +
+
Last 6 months› +
Within budgetOverOctober forecastBudget
+
+ +
+ Track record +
3 of 5 months under
+

Over in June (travel) and August (the Croma order). October is heading for ₹86,400.

+
+
+
+
+
+
+ +
+ Budget page: decisions + open questions
+ 1. Draft lives on the page (not a dialog); Approve/Skip carry the category in their accessible names.
+ 2. Outlier basis ("median, excluding a one-off Croma order") and "2 of 3 months had spend" need backend: draft_budget is mean-of-3 today and basis.months always lists 3.
+ 3. Month switcher, pace chart and 6-month history need backend: GET /api/budget is current month only, no history or daily series.
+ 4. Safe per day subtracts known upcoming bills. OK, or plain left ÷ days?
+ 5. Unassigned ₹27,000 shown honestly. Should it nudge to "Other", or stay? +
+ + + diff --git a/docs/superpowers/specs/2026-10-22-phase5/home.html b/docs/superpowers/specs/2026-10-22-phase5/home.html new file mode 100644 index 0000000..54f8359 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/home.html @@ -0,0 +1,196 @@ + +Home v2 mockup + + + + +
+ + +
+
+
+
+

Good evening, Nishant

+
+ Upload + + + NB +
+
+

October is running 34% heavier than September, mostly shopping. Rent of ₹25,000 is due Friday.

+ +
+
Spent in October› +
OctoberSeptemberForecast
+
+
₹62,182
+
+₹15,822 (34%) vs September by the 22nd · heading for ₹86,400
+ +
1W1M3MYTD
+
+ +
+
+
+
+
+
October, day by day›
+
₹62,182 across 21 days
+
+
+
+
Recent transactionsSee all
+
FreshMart21 Oct · Groceries₹1,284
+
Swiggy21 Oct · Food delivery₹436
+
Uber20 Oct · Travel₹212
+
Croma12 Oct · Shopping₹9,999
+
Jio Fiber9 Oct · Bills₹999
+
+
+
+ +
+
Lumen found3 to look atSee all
+
+ +

FreshMart charged you twice for one order

₹1,284 on 18 Oct, same items, 4 minutes apart.

+
+
+ +

Shopping is ₹11,400 over your usual

Mostly one Croma order on 12 Oct (₹9,999).

+
+
+ +

Spotify hasn't been used in 7 weeks

₹119 a month, renews 2 Nov.

+
+
+
+ +
+
+
Budget in October›
+
₹62,182 of ₹1,00,000
+
62%
+
+ Rent & housing₹25,000 / 25,000
+ Shopping₹16,840 / 8,000
+ Groceries₹8,120 / 12,000
+ Food delivery₹5,930 / 6,000
+
+
+ +
+
Coming up›All bills
+
Fri24RentMonthly₹25,000
+
Mon27Jio FiberMonthly₹999
+
Sat1NetflixMonthly₹649
+
Sun2Spotify unusedMonthly₹119
+
+
+
+
+
+
+ + diff --git a/docs/superpowers/specs/2026-10-22-phase5/receipts.html b/docs/superpowers/specs/2026-10-22-phase5/receipts.html new file mode 100644 index 0000000..1fbb7e0 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/receipts.html @@ -0,0 +1,249 @@ + +Receipts mockup + + + + +
+ + +
+
+
+
+

Receipts & statements

+
+ Upload + Import statement + Connect email +
+
+

Four receipts need your OK before they count. The other 42 files are filed.

+ + +
+
Needs your review4Highlighted fields are the ones Lumen wasn't sure aboutApprove all high-confidence
+
+
+
CromaInvoice · uploaded 12 Oct₹9,999
+
Vendor
Croma, Andheri W
Date
12 Oct 2026
Items + GST
₹8,474 + ₹1,525
Total
₹9,999
+

Totals don't add up. Items and GST add to ₹9,999; the printed total looks like ₹9,990.

+
ApproveEditReject
+
+
+
FreshMartReceipt · from email 18 Oct₹1,284
+
Vendor
FreshMart, Powai
Date
18 Oct 2026
Bill no.
FM-55120
Total
₹1,284
+

Looks like a duplicate. FreshMart FM-55120 is already recorded from 4 minutes earlier.

+
Keep bothCompareReject
+
+
+
Amazon.inInvoice · uploaded 20 Oct₹2,349
+
Vendor
Amazon Seller Services
Date
20/10/26 ?
Order
404-8812…
Total
₹2,349
+

Date unclear. Printed as 20/10/26; Lumen read it as 20 Oct 2026.

+
ApproveEditReject
+
+
+
Blue TokaiReceipt · photo 21 Oct₹640
+
Vendor
Blue Tokai Coffee
Date
21 Oct 2026
Currency
none printed
Total
₹640
+

New to you. No earlier payments to this vendor, and no currency on the bill (assumed ₹).

+
ApproveEditReject
+
+
+
+ +
+ +
+
Everything you've given Lumen46 files
+
+
AllReceiptsInvoicesStatementsContracts
+ +
+
+
FileCame fromDateAmountStatus
+
JPGUber trip receiptReceipt · uber-22oct.jpgUpload22 Oct₹212Reading
+
JPGBlue Tokai CoffeeReceipt · IMG_4412.jpgUpload21 Oct₹640Needs review
+
PDFAmazon.in order 404-8812…Invoice · amazon-invoice.pdfUpload20 Oct₹2,349Needs review
+
PNGzomato-order.pngReceipt · couldn't read the totalUpload19 Oct—FailedRetry
+
PDFFreshMart bill FM-55120Receipt · billing@freshmart.inGmail18 Oct₹1,284Needs review
+
PDFCroma tax invoiceInvoice · croma-CR2210.pdfUpload12 Oct₹9,999Needs review
+
PDFJio Fiber bill, OctoberInvoice · ebill@jio.comGmail9 Oct₹999Done
+
CSVHDFC Savings, SeptemberStatement · 142 linesImport3 Oct—Done
+
PDFRental agreement, Powai flatContract · 14 sections indexedUpload1 Oct₹25,000/moDone
+ +
+ +
+ +
+
Statement imports›Import statement
+
+
HDFC Savings ··44171–30 Sep · imported 3 Oct142 lines
+
+
97 new21 matched receipts3 duplicates21 credits skipped
+
+
+
ICICI Amazon Pay card1–30 Sep · imported 4 Oct58 lines
+
+
44 new11 matched3 credits skipped
+
+

October statements are usually out by 5 Nov. Lumen will remind you.

+
+ + +
+
From your inboxConnectedSettings
+
nishant.b@gmail.comChecked 12 min ago · 11 receipts this month
+
Check nowPause
+
+
+
+
+
+
+ + +
State: upload dialog open
+
+
+
+ +

Upload to Lumen

+

Lumen reads each file, fills in the details and asks you only when it isn't sure.

+
+
Receipts & invoicesRead the vendor, date and total, then add to spending
+
DocumentsContracts, policies, POs. Indexed so Ask Lumen can quote them
+
+
+
+ Drop files here +

or browse your computer · PDF, PNG or JPG, several at once

+
+
+
PDF
croma-CR2210.pdfCroma · ₹9,999 · 1 thing to check
Needs review
+
JPG
freshmart-21oct.jpgReading the receipt…
64%
+
PDF
amazon-invoice.pdfWaiting
Queued
+
HEIC
IMG_4420.heicHEIC isn't supported yet. Export it as JPG and try again.
Remove
+
+
Bank statement? Use Import statement (CSV).CloseUpload 2 more
+
+
Open questions for the owner
+1. Review cards replace the old Review tab; Approve on a duplicate becomes "Keep both". OK?
+2. Field highlights come from the existing validation flags (total, date, vendor, currency). True per-field confidence is needs-backend.
+3. Library merges RAG documents + invoices into one list with a status pill. Persisted "Reading" state and Retry are needs-backend (extraction is synchronous today).
+4. Statement imports history (bank, period, counts) is needs-backend; import returns counts but nothing is stored. PDF statements too: CSV only today.
+5. List view over grid: calmer and scans by amount. Grid toggle kept for photo receipts.
+
+ + + diff --git a/docs/superpowers/specs/2026-10-22-phase5/shell.css b/docs/superpowers/specs/2026-10-22-phase5/shell.css new file mode 100644 index 0000000..ebc5adb --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/shell.css @@ -0,0 +1,72 @@ +/* Shared mockup shell for the Phase 4.5 polish + Phase 5 pages. Sky palette from globals.css. */ +*{box-sizing:border-box;margin:0;padding:0} +:root{ + --bg:#f5f4f0;--side:#f0efea;--card:#fff;--line:#e7e5df;--line2:#efede8; + --text:#1c1c1a;--ink2:#46453f;--mute:#7a7871;--faint:#a8a69f; + --acc:#2f6fe0;--accSoft:#e4edfc;--acc2:#7fa8ee; + --good:#2e8b57;--goodSoft:#e3f1e8;--warn:#b7791f;--warnSoft:#faf0dc;--bad:#c4572f;--badSoft:#fae6dd; + --chip:#ecebe6; + --band:linear-gradient(180deg,#d3e4fb 0%,#e8f0fb 50%,rgba(245,244,240,0) 100%); + --shadow:0 1px 2px rgba(28,28,26,.04),0 4px 16px -8px rgba(28,28,26,.08); +} +body{background:#d9d8d3;font-family:'Funnel Sans',system-ui,sans-serif;color:var(--text);font-size:14px;-webkit-font-smoothing:antialiased} +.num{font-variant-numeric:tabular-nums} +.serif{font-family:Newsreader,serif;font-weight:400} + +.app{display:flex;width:1440px;min-height:1000px;background:var(--bg);margin:0 auto} + +/* sidebar */ +.side{width:232px;flex:none;background:var(--side);border-right:1px solid var(--line);padding:14px 10px 12px;display:flex;flex-direction:column;align-self:stretch} +.brand{display:flex;align-items:center;gap:9px;padding:4px 8px 18px;font-weight:600;font-size:16.5px;letter-spacing:-.01em} +.brand img{width:22px;height:22px} +.brand .col{margin-left:auto;width:26px;height:26px;border-radius:7px;display:grid;place-items:center;color:var(--mute);opacity:.0;transition:opacity .15s} +.side:hover .brand .col,.brand .col.show{opacity:1} +.brand .col:hover{background:var(--chip)} +.grp{padding:14px 10px 5px;font-size:11.5px;font-weight:500;color:var(--faint);letter-spacing:.02em} +.it{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;color:var(--ink2);font-size:13.5px} +.it svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.6;opacity:.75} +.it.on{background:var(--card);color:var(--text);font-weight:500;box-shadow:var(--shadow)} +.it.on svg{opacity:1;stroke:var(--acc)} +.it .n{margin-left:auto;font-size:11px;font-weight:600;background:var(--accSoft);color:var(--acc);border-radius:10px;padding:1px 7px} +.it .new{margin-left:auto;font-size:10.5px;color:var(--acc)} +.foot{margin-top:auto;display:flex;flex-direction:column;gap:8px} +.askbox{display:flex;align-items:center;gap:9px;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:9px 11px;color:var(--mute);font-size:13px} +.askbox svg{width:15px;height:15px;stroke:var(--acc);fill:none;stroke-width:1.6} +.askbox kbd{margin-left:auto;font:11px 'Funnel Sans';color:var(--faint);border:1px solid var(--line);border-radius:5px;padding:1px 5px} +.me{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:10px} +.me:hover{background:var(--chip)} +.av{width:28px;height:28px;border-radius:50%;background:linear-gradient(135deg,#6c93d8,#2f6fe0);color:#fff;display:grid;place-items:center;font-size:11.5px;font-weight:600} +.me b{font-weight:500;font-size:13px;display:block} +.me small{color:var(--mute);font-size:11.5px} +.me .chev{margin-left:auto;color:var(--faint)} + +/* main */ +.main{flex:1;min-width:0;position:relative} +.band{position:absolute;inset:0 0 auto 0;height:420px;background:var(--band);pointer-events:none} +.wrap{position:relative;padding:26px 36px 48px;max-width:1208px} +.label{font-size:12.5px;font-weight:500;color:var(--mute);letter-spacing:.01em} +.card{background:var(--card);border:1px solid var(--line2);border-radius:16px;padding:18px 20px;box-shadow:var(--shadow)} +.ch{display:flex;align-items:center;gap:8px;margin-bottom:12px} +.ch a{margin-left:auto;font-size:12.5px;color:var(--mute);text-decoration:none} +.ch a:hover{color:var(--text)} +.btn{display:inline-flex;align-items:center;gap:7px;height:34px;padding:0 13px;border-radius:9px;font:500 13px 'Funnel Sans';border:1px solid var(--line);background:rgba(255,255,255,.7);color:var(--ink2);backdrop-filter:blur(8px)} +.btn svg{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:1.6} +.btn.pri{background:var(--text);color:#fff;border-color:var(--text)} +.chips{display:inline-flex;gap:2px;background:rgba(255,255,255,.55);border:1px solid var(--line2);border-radius:9px;padding:2px} +.chips span{font-size:12px;font-weight:500;padding:4px 10px;border-radius:7px;color:var(--mute)} +.chips .on{background:var(--card);color:var(--text);box-shadow:var(--shadow)} +.pill{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:500;border-radius:20px;padding:2px 9px} +.pill.good{background:var(--goodSoft);color:var(--good)} +.pill.warn{background:var(--warnSoft);color:var(--warn)} +.pill.bad{background:var(--badSoft);color:var(--bad)} +.pill.acc{background:var(--accSoft);color:var(--acc)} +.mute{color:var(--mute)} +.row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--line2)} +.row:last-child{border:0} +.logo{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:12px;font-weight:600;flex:none} +.amt{margin-left:auto;font-variant-numeric:tabular-nums;font-weight:500} +.bar{height:6px;background:var(--chip);border-radius:6px;overflow:hidden} +.bar i{display:block;height:100%;border-radius:6px} +svg text{font:10.5px 'Funnel Sans';fill:var(--faint)} +.note{position:fixed;right:16px;bottom:16px;max-width:340px;background:#1c1c1a;color:#eee;font-size:12.5px;line-height:1.5;padding:12px 14px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.25)} +.note b{color:#fff} diff --git a/docs/superpowers/specs/2026-10-22-phase5/spending.html b/docs/superpowers/specs/2026-10-22-phase5/spending.html new file mode 100644 index 0000000..48a2993 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-phase5/spending.html @@ -0,0 +1,272 @@ + +Spending mockup + + + + +
+ + +
+
+
+
+

Spending

+
+ Upload + + + NB +
+
+

Shopping is most of the jump, and one Croma order is ₹9,999 of it.

+ +
+
This monthLast month3MYTDCustom
+ Compare with September + 1 – 22 Oct 2026 · vs 1 – 22 Sep +
+ +
+
+
Spent in October +
OctoberSeptember
+
₹62,182
+
+₹15,822 (34%) vs September by the 22nd · 214 transactions
+ +
+
+ Month by month +
₹71,720 avg, May–Sep
+

Shown as bars when you pick 3M or YTD. October so far is under your average.

+
+
+
+ +
+
+
By categoryFiltering transactions: ShoppingClear
+
CategoryShare of OctoberSpentSharevs Sep
+
+
3 transactions still need a categorySort them now
+
+ +
+
Lumen noticed4 to look at
+
+ +

FreshMart charged you twice

₹1,284 on 18 Oct, same items, 4 minutes apart.

+
+
+ +

Shopping is ₹11,400 over usual

Mostly one Croma order on 12 Oct (₹9,999).

+
+
+ +

Netflix went up to ₹649

Was ₹499 until September. Next charge 1 Nov.

+
+
+ +

First time at Decathlon

₹899 on 17 Oct. Filed under Shopping.

+
+
+
+ +
+
+
Transactions
+
+ Search merchant or amount + Shopping + All sources + 8 in Shopping · ₹16,840 +
+ + + +
DateMerchantCategorySourceAmount
+
Showing 8 of 214 in OctoberShow all categories
+
+ +
+
Top merchantsSee all
+
R. Kulkarni (rent)1 payment · Rent & housing₹25,000
+
Croma1 order · Shopping₹9,999
+
FreshMart6 visits · Groceries₹6,420
+
Amazon4 orders · Shopping₹3,744
+
Swiggy9 orders · Food delivery₹3,480
+
Zomato6 orders · Food delivery₹2,450
+
Uber11 rides · Travel₹2,160
+
+
Spending: decisions + questions
+1. Categories as horizontal bars, not a donut: rent (40%) would swallow a donut. Click a row to filter the table; Clear undoes.
+2. Calendar + reminders moved out to Bills. Spending has no due dates.
+3. Month bars live in the hero inset; for 3M/YTD they become the main chart and the cumulative line hides.
+4. "First time at merchant" is not a finding kind yet (backend has duplicate / spike / price_rise / review). Add it, or drop the card?
+5. Export: CSV of the filtered list only, or the whole period?
+
+
+
+
+
+ + + + + diff --git a/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md b/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md new file mode 100644 index 0000000..b956db2 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md @@ -0,0 +1,181 @@ +# UI revamp, Phase 5: Home v2 and the inner pages + +Addendum to `2026-10-20-ui-revamp-design.md` (IA, Sky palette, motion, zero layout-shift rule and §12 backlog still +apply). Approved by the owner on 2026-10-22 from the mockups in `2026-10-22-phase5/` (open the `.html` files in a +browser; `shell.css` is the shared style). The dark "decisions + questions" box on each mockup is a review aid, not UI. + +## 1. Why + +The owner ran Phases 0–4 locally and found the app still felt like the old one with a new coat: Home didn't resemble +the inspiration (Origin's app home), labels used a generic all-caps mono font, sidebar groups weren't marked, and some +chips sent text to the AI when they should have done something. The inner pages (Spending, Bills, Receipts) are still +the old pages stacked inside new URLs, and Budget has no page. Phase 5 makes every main page look and behave like one +calm product: "not too pushy with info, not too implicit". + +The shell fixes (labelled groups, collapse in the sidebar header, no desktop top bar, `.label-ui` sentence-case labels, +upload chip in Ask) shipped first in PR #96 and are the baseline for this phase. + +## 2. Goals, non-goals, anti-goals + +**Goals** +- Home v2 and four pages (Spending, Bills & subscriptions, Budget, Receipts & statements) built to the mockups. +- Budget becomes a page with a **Plan** sidebar group (Plan · Budget), between Track and Records. +- One shared page skeleton so the pages can't drift: header (title, one-line serif brief, quiet actions), cards, + labels, number style, the smooth area chart, the calendar grid. +- The cheap backend pieces each page needs (§5), and the §12 items tagged Phase 5 (§7). +- Zero layout shift: every range/filter/month change, hover-edit and dialog keeps its box (bounding-box or CLS tests, + as in Phases 0–4). + +**Non-goals** +- Forecast page (Plan · Forecast) and Phase 6 performance work. +- Smarter "Lumen found" and budget drafts (median/trimmed mean, one-off detection): stays §12 *Later*, gated on an eval + set first (owner request). The draft UI is built so a "basis" sentence can carry it later. +- Paid-vs-overdue matching of bills to payments, per-field extraction confidence, "unused" (usage) detection, PDF bank + statements, async extraction states (Reading / Failed + Retry): deferred, see §6. + +**Anti-goals** (would be a failure even if it "works") +- Showing anything the data can't back: a deferred element is **hidden**, never faked or filled with placeholders. +- Loud saturated blue buttons, all-caps mono labels, a dark dialog inside the light app. +- A chip or button whose label is an action ("Upload…", "Mark as cancelled") that sends text to the chat instead. +- Layout jumps when data loads, filters change or a row enters edit mode. + +## 3. Shared skeleton (all pages) + +As in `home.html`: +- **Header row:** sans 26px `h1`, quiet actions on the right (`Upload` as a light button, icon buttons for export/help), + leaving room for the floating account menu (`md:pr-12`). Below it a one-line Newsreader brief built from real numbers + (same style as Home's brief; one emphasised phrase in italic accent). +- **Cards:** white, hairline border, 16px radius, very soft shadow; title row = `.label-ui` + optional `›` link + optional + small `+` action on the right. One strong number per card, tabular numerals. +- **Charts:** smooth (monotone/Catmull-Rom) area line in accent with a soft gradient, previous period dashed grey, + forecast dotted light accent, today dot, bill pins as small round markers on the x-axis; y labels right-aligned; + sparse x ticks (no overlap at any width; fixes the "01 OC09 OCT" overlap on phones). +- **Range/period chips** centred under the chart (Home) or in a segmented control under the header (pages). +- **Buttons:** `.btn` quiet (light, hairline) or dark primary for the one main action of a page. The shadcn `Button` + default blue is not used on these pages; Export/Upload get restyled (§12). +- **Dialogs:** light (Sky tokens), including the upload dialog. + +Implementation is the builder's call (a `PageHeader` + a few card primitives in `components/page/` is the expected +shape); the rule is that the five pages use the same pieces. + +## 4. Pages + +### 4.1 Home v2 (`home.html`) +Greeting row; brief; **hero card "Spent in ›"** (big number, delta vs previous period with sign colour, "heading +for ₹X" from the forecast, legend, `+` = upload, chart, range chips 1W/1M/3M/YTD); then a two-column grid: +- left: **", day by day"** card = month total + calendar with the amount in each day cell (heat shade, today + outlined, future days dashed with known bills named) beside **Recent transactions** (5, logo chip, date · category, + amount, "See all" → Spending); **Lumen found** as a quiet list (icon, headline, detail, two action links that *do* + things: e.g. Review duplicate → the review item, See in Spending → filtered Spending, Ask why → Ask with the question + filled). +- right: **Budget in ** (spent of total, green bar + %, top 4 category bars; no budget → the draft promo linking to + the Budget page); **Coming up** (next 4 bills with date chips); Activity stays only if it fits, else moves to + Receipts. +Everything on Home already has data except per-category budget bars (exists via `/api/budget`) — no new endpoint. + +### 4.2 Spending (`spending.html`) +Answers "where did my money go, and is anything off?". Header + period control (This month / Last month / 3M / YTD / +Custom) + "Compare with " toggle + date range. Hero: total, delta, cumulative line vs previous; inset "Month +by month" bars (last 6 months, average). For 3M/YTD the bars become the main chart (owner may revisit). +Row 2: **By category** (horizontal bars: amount, share, change vs previous; click a row = filter the table, "Clear"; +"N transactions still need a category → Sort them now") and **Lumen noticed** (findings from `/api/home/summary` +kinds: duplicate, spike, price_rise, review; each with real actions + "Ask Lumen why"). The mockup's "First time at a +merchant" card is **dropped** (no such finding kind). +Row 3: **Transactions** table (search, category chip filter, source filter, date/merchant/category pill/source/amount, +hover pencil → category menu; "Always file here" only if a rule store exists — otherwise omit) and **Top +merchants**. Export = CSV of the current filtered view. +Removes the duplicated payment calendar + reminders and the two `

`s (§12). + +### 4.3 Bills & subscriptions (`bills.html`) +Answers "what's due, what recurs, what can I cut?". Header + month switcher. Three tiles: due in next 30 days, recurring +per month, "subscriptions that look stopped" (renamed from "you may not use": we only know charges stopped, not usage). +**Month calendar** with bill names/amounts on due days (states shown: upcoming, today; paid/late/overdue states are +deferred) beside an **Upcoming** agenda grouped This week / Next week / Later with group totals. **Subscriptions table** +(tabs Subscriptions / Bills / All): logo chip, cadence, last charged, next charge, amount, pills for price rise and +"looks stopped". Actions: **Mark as cancelled**, **Not a bill** (both saved, §5), **How to cancel** → opens Ask with +"How do I cancel ?" typed but not sent (owner choice). "Set reminder" and the overdue strip are deferred. +Bills owns the calendar and reminders; Spending no longer shows them. + +### 4.4 Budget (`budget.html`) — new page, sidebar Plan · Budget +Answers "am I on track, and what should my plan be?". Header + month switcher, "Edit budget" (quiet) and "Let Lumen +draft" (dark). Hero: spent of total, green bar with an even-pace tick, left / days left / safe per day (= left ÷ days +left; subtracting known upcoming bills is the builder's call if the bills data is already loaded), pace chart (actual +vs straight line to the budget). **By category** table: budget, spent, left, bar (green / amber ≥90% / red over), hover +→ inline edit of the amount (Enter saves, Esc cancels, no row height change), footer "categories add up to X, Y +unassigned" + Add category. **Draft panel on the page** (owner choice; replaces the dialog): each line shows proposed +amount, old limit, basis sentence, a small month-bars glyph, Approve / Skip with the category in the accessible name, +decided state in place, "Approve remaining N". Home's promo links here. **Last 6 months**: budget outline vs actual +bars (green under / red over), current month as "so far" + forecast. + +### 4.5 Receipts & statements (`receipts.html`) +Answers "what have I given Lumen, and what needs my OK?". Header actions Upload / Import statement / Connect email. +**Needs your review** row of cards (only when the queue is non-empty; count = sidebar badge): extracted fields with the +doubtful ones highlighted from existing validation flags (`total_mismatch`, `duplicate`, `bad_date`, `unknown_vendor`, +`no_currency`), a plain-words reason, Approve / Edit / Reject (duplicate: "Keep both" / Compare / Reject). This replaces +the old Review tab. **Library** list (filter chips All / Receipts / Invoices / Statements / Contracts, search, list view +default; grid toggle optional): file icon, title, came from (Upload / Gmail / Import), date, amount, status (Needs +review, Done; Reading/Failed deferred). Right column: **Statement imports** history (bank/file, period, lines, new / +matched / duplicates / credits skipped bar) and **From your inbox** (Gmail status, Check now, Pause). **Light upload +dialog**: choose Receipts & invoices vs Documents, drop zone, accepted types, per-file progress rows, pointer to Import +statement for CSVs. + +## 5. Backend in scope ("build what's cheap", owner choice) +- `GET /api/bills/recurring`: wraps `ai/recurring.find_recurring` (vendor, cadence, typical amount, last/next date, + bill vs subscription, price change, looks-stopped) minus vendors the user dismissed; plus + `POST /api/bills/recurring//state` with `cancelled | not_a_bill | active` saved per user (new small table). + Home's bills card and the Bills page both use it. +- `GET /api/budget?month=YYYY-MM` and `GET /api/budget/history?months=6` (budget vs actual per month). Daily cumulative + series for the pace chart comes from the transactions the page already fetches (no new endpoint unless that is + heavy). +- Statement import history: persist one row per import (file name, account/bank label if given, period from the + rows, counts already returned by the import); `GET /api/statements/imports`. +- §12 backend items: `POST /chat` creates the thread and its messages in one commit; the lost approval race returns a + generic "This proposal was already decided" (re-read status after a failed claim); `basis.months` lists only months + that had spend. +- Everything new: per-user scoping, rate limits like the neighbouring routes, tests first. + +**Decisions made after the builder check (agent's calls, owner can overrule):** +- **Budgets are versioned by month.** Today `Budget` is one `monthly_limit` per category. Add an `effective_from` + month (migration; existing rows get the month they were created). A month's budget = for each category, the latest + row with `effective_from <= month`. Editing (inline or Edit budget) writes a row effective from the **current** + month, so past months keep what they were; approving a draft "for November" writes rows effective from November. + Past months are read-only in the UI. History therefore compares each month with the limits that applied then; months + before any budget show actual spend only (no outline). `User.monthly_budget` stays the overall total fallback. +- **Saved recurring states are keyed by the normalised vendor** (`ai/recurring._norm`, the same key `find_recurring` + groups by); the route takes the display name and normalises it. `not_a_bill` hides the vendor from every recurring + list (a "Hidden" tab or footer link lets you undo). `cancelled` keeps the row in a dimmed "Cancelled" group at the + bottom; if a charge from that vendor appears **after** the cancel date, the state is ignored and the row shows a + "charged again after you cancelled" pill. +- **Library "Statements" filter** lists the new import-history rows as library items (type Statement, came from + Import, no file to open since CSVs aren't kept). The chip shows only when there is at least one. +- **Import history starts with the new table** (older imports don't appear). Counts are stored as the import returned + them; a re-import of the same file is a new history row and its "matched" count may repeat, labelled "at import". + +## 6. Deferred (hidden until built; recorded in §12) +Paid / paid-late / overdue matching per bill cycle and the overdue strip; "Set reminder"; usage-based "unused"; +quarterly cadence detection; credit-card bill due dates from statements; per-field extraction confidence; persisted +Reading/Failed + Retry; PDF statement import; "Approve all high-confidence"; category rules ("Always file X here"); +median/outlier budget drafts (eval set first); Spending's "first time at a merchant" finding. + +## 7. §12 items folded in +Duplicate calendar/reminders and the double `

` on Spending; Export/Upload restyle + light upload dialog; draft +buttons' accessible names + `basis.months`; `POST /chat` single commit; generic 409 text; thread rail (disable +Keep/Delete while busy, focus "Keep" when confirm opens, trash visible on touch); AskPalette rendered outside +`` so Ctrl+K works on tablets with the sheet closed. + +## 8. Delivery +Stacked PRs into `refactor`, each green and checkable on its own, in this order (later ones may depend on earlier): +1. **5a** shared skeleton + Home v2 + Export/Upload restyle + light upload dialog + Ask/§12 frontend fixes. +2. **5b** Spending. +3. **5c** Bills (+ recurring API and saved states). +4. **5d** Budget page + Plan group (+ month/history API, draft panel, §12 draft items). +5. **5e** Receipts & statements (+ import history API, review cards replacing the Review tab). +Each PR: TDD per task, layout-shift tests for its controls, a manual-check list for the owner, a STORY entry, and the +codebase guide updated. One whole-branch review at the end of the phase; Critical/Important fixed test-first, Minors to +§12. + +## 9. Left to the builder +Component boundaries and file layout; chart implementation (existing chart code vs a small SVG helper); exact +breakpoints (pages must work at phone width with no sideways scroll); empty and loading states in the established +skeleton style; whether Activity stays on Home; how the brief sentence is phrased per page as long as it uses real +numbers only. diff --git a/frontend/.gitignore b/frontend/.gitignore index 4b01206..ee88db3 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -8,6 +8,7 @@ # next.js /.next/ +/.next-e2e/ /out/ # production diff --git a/frontend/e2e/ask.spec.ts b/frontend/e2e/ask.spec.ts index a81ecd9..9d434eb 100644 --- a/frontend/e2e/ask.spec.ts +++ b/frontend/e2e/ask.spec.ts @@ -73,6 +73,28 @@ test("deleting a thread removes it", async ({ page }) => { await expect(rail(page).getByRole("button", { name: /^Bills due/ })).toHaveCount(0); }); +test("rail actions wait for the answer", async ({ page }) => { + await openAsk(page, { ...old, delayMs: 1500 }); + await rail(page).getByRole("button", { name: "Delete chat: Bills due" }).click(); + await expect(rail(page).getByRole("button", { name: "Keep" })).toBeFocused(); + await composer(page).fill("Slow one"); + await composer(page).press("Enter"); + await expect(rail(page).getByRole("button", { name: "Keep" })).toBeDisabled(); + await expect(rail(page).getByRole("button", { name: "Confirm delete" })).toBeDisabled(); + await expect(rail(page).getByRole("button", { name: "Delete chat: Food last month" })).toBeDisabled(); + await expect(page.getByText("Answer to: Slow one")).toBeVisible(); + await expect(rail(page).getByRole("button", { name: "Keep" })).toBeEnabled(); +}); + +test("the trash button shows without hover at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openAsk(page, old); + await page.getByRole("button", { name: "History" }).click(); + const trash = rail(page).getByRole("button", { name: "Delete chat: Bills due" }); + await expect(trash).toBeVisible(); + await expect(trash).toHaveCSS("opacity", "1"); +}); + test("handoff sends once", async ({ page }) => { const posts: string[] = []; page.on("request", (r) => r.method() === "POST" && r.url().endsWith("/chat") && posts.push(r.postDataJSON().query)); @@ -182,3 +204,54 @@ test("a question suggestion is still sent to Lumen", async ({ page }) => { await page.getByRole("button", { name: "Show last month by category" }).click(); await expect(page.getByText("Answer to: Show last month by category")).toBeVisible(); }); + +test("draft prefills the composer without sending", async ({ page }) => { + const posts: string[] = []; + page.on("request", (r) => r.method() === "POST" && r.url().endsWith("/chat") && posts.push("x")); + await openAsk(page, {}, "/ask?draft=Why%20is%20Food%20up%3F"); + await expect(composer(page)).toHaveValue("Why is Food up?"); + await expect(composer(page)).toBeFocused(); + await expect(page).toHaveURL(/\/ask$/); + await page.waitForTimeout(500); + expect(posts).toHaveLength(0); + await page.reload(); + await expect(composer(page)).toHaveValue(""); +}); + +test("a sent draft doesn't come back in the follow-up box", async ({ page }) => { + await openAsk(page, {}, "/ask?draft=Why%20is%20Food%20up%3F"); + await composer(page).press("Enter"); + await expect(page.getByText("Answer to: Why is Food up?")).toBeVisible(); + await expect(composer(page)).toHaveValue(""); +}); + +test("a late answer can't take over a chat started after it", async ({ page }) => { + const posts: { query: string; thread_id?: string | null }[] = []; + const threadOf: Record = {}; + page.on("request", (r) => r.method() === "POST" && r.url().endsWith("/chat") && posts.push(r.postDataJSON())); + page.on("response", async (r) => { + if (r.request().method() === "POST" && r.url().endsWith("/chat")) + threadOf[r.request().postDataJSON().query] = (await r.json()).data.thread_id; + }); + // the first question answers after the second one + await openAsk(page, { delayMs: (q) => (q === "Slow one" ? 2500 : 100) }); + await composer(page).fill("Slow one"); + await composer(page).press("Enter"); + // the Ask anything palette hands off to /ask?q= in the same page: a new chat while the first answer is in flight + await page.getByRole("button", { name: /^Ask anything/ }).click(); + const palette = page.getByRole("dialog", { name: "Ask anything" }); + await palette.getByRole("textbox").fill("Fast one"); + await palette.getByRole("textbox").press("Enter"); + await expect(page.getByText("Answer to: Fast one")).toBeVisible(); + await page.waitForTimeout(3000); // the slow answer lands now + await expect(page.getByText("Answer to: Slow one")).toHaveCount(0); + await composer(page).fill("And a follow-up"); + await composer(page).press("Enter"); + await expect(page.getByText("Answer to: And a follow-up")).toBeVisible(); + const fast = posts.find((p) => p.query === "Fast one")!; + const follow = posts.find((p) => p.query === "And a follow-up")!; + expect(fast.thread_id ?? null).toBeNull(); + // the follow-up goes into the Fast chat's thread, never the slow one's + expect(follow.thread_id).toBe(threadOf["Fast one"]); + expect(threadOf["Fast one"]).not.toBe(threadOf["Slow one"]); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 64868a8..226b215 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -30,6 +30,8 @@ export type ChatFixture = { /** the "Next:" suggestion POST /chat returns (default none) */ followUp?: string; suggestions?: string[]; + /** hold each POST /chat this long (or per question), to test the in-flight state */ + delayMs?: number | ((query: string) => number); }; export async function signIn( @@ -80,8 +82,19 @@ export async function signIn( 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 } }); + // the real shape (ai/pattern_detection.generate_reminders), built from the fixture's simpler rows; a row with + // no vendor is a category pattern ("You usually buy Groceries around the 5th") + if (path === "/api/analytics/reminders") { + const reminders = (home.reminders ?? []).map((r) => ({ + title: `Upcoming: ${r.vendor || r.category}`, + description: "", + reminder_type: "pattern_based", + predicted_date: r.date, + pattern_id: r.id, + metadata: { pattern_type: r.vendor ? "recurring" : "day_of_month", vendor_name: r.vendor || null, category: r.category ?? null, average_amount: r.amount }, + })); + return route.fulfill({ json: { success: true, reminders, count: reminders.length } }); + } 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" } }); @@ -133,6 +146,8 @@ export async function signIn( } if (path === "/chat" && route.request().method() === "POST") { const body = route.request().postDataJSON() as { query: string; thread_id?: string }; + const delay = typeof opts.chat?.delayMs === "function" ? opts.chat.delayMs(body.query) : opts.chat?.delayMs; + if (delay) await new Promise((r) => setTimeout(r, delay)); let t = threads.find((x) => x.id === body.thread_id); if (!t) { t = { id: `t${nextThread++}`, title: body.query.slice(0, 60), updated_at: new Date().toISOString(), message_count: 0, messages: [] }; diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts index 00fec18..61789e5 100644 --- a/frontend/e2e/home-math.spec.ts +++ b/frontend/e2e/home-math.spec.ts @@ -1,5 +1,6 @@ // Node-only unit tests (no browser) for the Home calculations. import { test, expect } from "@playwright/test"; +import { pickTicks, smoothPath } from "../src/lib/chart"; import { briefSentence, fill, @@ -10,6 +11,7 @@ import { relativeTime, sameDayDelta, type Txn, + vendorChip, } from "../src/lib/home-math"; const d = (s: string) => { @@ -153,3 +155,60 @@ test("suggestedQuestions maps found items, dedupes, caps at 4", () => { ]); expect(suggestedQuestions([], ["A", "B"])).toEqual(["A", "B"]); }); + +// ---- lib/chart.ts (Phase 5) ---- + +// y of each cubic segment of a path "M x y C x1 y1 x2 y2 x y C …" sampled at t +function samples(d: string, ts = [0.25, 0.5, 0.75]): number[] { + const n = d.match(/-?\d+(\.\d+)?/g)!.map(Number); + let [x0, y0] = [n[0], n[1]]; + const out: number[] = []; + for (let i = 2; i + 5 < n.length + 1; i += 6) { + const [, c1, , c2, x, y] = n.slice(i, i + 6); + for (const t of ts) out.push((1 - t) ** 3 * y0 + 3 * (1 - t) ** 2 * t * c1 + 3 * (1 - t) * t ** 2 * c2 + t ** 3 * y); + [x0, y0] = [x, y]; + } + void x0; + return out; +} + +test("smoothPath passes through every point", () => { + const d = smoothPath([[0, 10], [10, 20], [20, 20], [30, 40]]); + expect(d.startsWith("M0 10")).toBe(true); + expect(d).toContain("20 20"); + expect(d.endsWith("30 40")).toBe(true); + expect(smoothPath([])).toBe(""); + expect(smoothPath([[5, 6]])).toBe("M5 6"); +}); + +test("smoothPath never overshoots a running total", () => { + const ys = samples(smoothPath([[0, 0], [10, 100], [20, 100], [30, 101]])); + for (let i = 0; i < ys.length; i++) { + expect(ys[i]).toBeLessThanOrEqual(101 + 1e-9); + if (i) expect(ys[i]).toBeGreaterThanOrEqual(ys[i - 1] - 1e-9); + } +}); + +test("pickTicks drops ticks that crowd a higher-ranked one", () => { + const t = (x: number, rank: number) => ({ x, w: 10, anchor: "middle" as const, rank, label: String(x) }); + expect(pickTicks([t(0, 1), t(20, 2), t(40, 2), t(300, 0), t(310, 1)], 30).map((k) => k.label)).toEqual(["0", "40", "300"]); + expect(pickTicks([t(0, 1), t(60, 2), t(120, 2), t(200, 1)], 30).map((k) => k.label)).toEqual(["0", "60", "120", "200"]); +}); + +test("vendorChip gives two initials and a stable hue", () => { + expect(vendorChip("Swiggy Instamart").initials).toBe("SI"); + expect(vendorChip("swiggy").initials).toBe("SW"); + expect(vendorChip("Swiggy Instamart").hue).toBe(vendorChip("Swiggy Instamart").hue); + expect(vendorChip("").initials).toBe("?"); +}); + +test("pickTicks compares real label edges, not centres", () => { + // "1 Oct" is left-anchored (covers 0–32), "Today" centred at 50 (covers 32–68): they touch, so the edge label goes + const t = (x: number, w: number, anchor: "start" | "middle" | "end", label: string, rank: number) => ({ x, w, anchor, label, rank }); + const shown = pickTicks([t(0, 32, "start", "1 Oct", 1), t(50, 36, "middle", "Today", 0), t(300, 38, "end", "31 Oct", 1)], 6); + expect(shown.map((s) => s.label)).toEqual(["Today", "31 Oct"]); + // right edge: "31 Oct" covers 262–300, "Today" at 250 covers 232–268 + expect(pickTicks([t(0, 32, "start", "1 Oct", 1), t(250, 36, "middle", "Today", 0), t(300, 38, "end", "31 Oct", 1)], 6).map((s) => s.label)).toEqual(["1 Oct", "Today"]); + // middle ticks give way to the higher-ranked ones and to each other + expect(pickTicks([t(0, 32, "start", "1 Oct", 1), t(36, 20, "middle", "8", 2), t(120, 20, "middle", "15", 2), t(200, 36, "middle", "Today", 0)], 6).map((s) => s.label)).toEqual(["1 Oct", "15", "Today"]); +}); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 9caaceb..93beeca 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -68,20 +68,23 @@ test("old dashboard is gone", async ({ page }) => { }); const spend = (page: Page) => page.getByTestId("spend-card"); -const prevShort = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "short" }); +const prevLong = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "long" }); +const monthLong = new Date(Y, M, 1).toLocaleString("en-US", { month: "long" }); +const ord = (n: number) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th")}`; const lastDay = new Date(Y, M + 1, 0).getDate(); test("spend card shows total and delta", async ({ page }) => { await openHome(page); await expect(spend(page).getByText("₹18,412", { exact: true })).toBeVisible(); - await expect(spend(page).getByTestId("spend-delta")).toHaveText(`↓ ₹1,868 vs ${prevShort} at this point`); + await expect(spend(page).getByTestId("spend-delta")).toHaveText(`−₹1,868 (9%) vs ${prevLong} by the ${ord(now.getDate())}`); }); test("range chips switch period", async ({ page }) => { await openHome(page); - await expect(spend(page)).toContainText("SPENT THIS MONTH", { ignoreCase: true }); + await expect(spend(page)).toContainText(`Spent in ${monthLong}`); + await expect(spend(page).getByRole("link", { name: new RegExp(`Spent in ${monthLong}`) })).toHaveAttribute("href", "/spending"); await spend(page).getByRole("button", { name: "1W" }).click(); - await expect(spend(page)).toContainText("SPENT THIS WEEK", { ignoreCase: true }); + await expect(spend(page)).toContainText("Spent this week"); await expect(spend(page).getByRole("button", { name: "1W" })).toHaveAttribute("aria-pressed", "true"); }); @@ -96,6 +99,9 @@ test("forecast draws to month end", async ({ page }) => { const dates = Array.from({ length: 31 }, (_, i) => ymd(Y, M, now.getDate() + 1 + i)); await openHome(page, { ...typical, forecast: { dates, values: dates.map(() => 100) } }); await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(1); + // 18,412 so far + 100 for each day left in the month + const left = lastDay - now.getDate(); + await expect(spend(page)).toContainText(`heading for ₹${(18412 + 100 * left).toLocaleString("en-IN")}`); }); test("bill pins show upcoming bills", async ({ page }) => { @@ -104,7 +110,8 @@ test("bill pins show upcoming bills", async ({ page }) => { ...typical, reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: thisMonth(now.getDate() + 1), type: "upcoming" }], }); - await expect(spend(page).getByTestId("bill-pin")).toHaveText("Netflix"); + await expect(spend(page).getByTestId("bill-pin")).toHaveAttribute("title", "Netflix"); + await expect(spend(page).getByTestId("bill-pin")).toHaveText("N"); }); test("new user sees empty states", async ({ page }) => { @@ -161,12 +168,18 @@ test("long names truncate", async ({ page }) => { expect(Math.abs(a!.height - b!.height)).toBeLessThanOrEqual(1); }); -test("bills card lists next three", async ({ page }) => { +test("coming up lists the next four bills", 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)] }); + await openHome(page, { + ...typical, + reminders: [r("4", "Rent", 9), r("1", "Netflix", 1), r("3", "Gym", 6), r("2", "Spotify", 3), r("5", "Jio", 12), r("6", "LIC", 20)], + }); const rows = page.getByTestId("bill-row"); - await expect(rows).toHaveCount(3); + await expect(rows).toHaveCount(4); await expect(rows.first()).toContainText("Netflix"); + const d = new Date(Y, M, now.getDate() + 1); + await expect(rows.first().getByTestId("date-chip")).toHaveText(`${d.toLocaleString("en-GB", { weekday: "short" })}${d.getDate()}`); + await expect(page.getByRole("link", { name: /^Coming up/ })).toHaveAttribute("href", "/bills"); }); test("budget card is honest with no budget", async ({ page }) => { @@ -191,7 +204,7 @@ test("side column order", async ({ page }) => { 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"]); + expect(titles).toEqual([`budget in ${monthLong.toLowerCase()}`, "coming up", "let lumen draft your budget", "activity"]); }); test("sky band is drawn behind the greeting", async ({ page }) => { @@ -228,6 +241,7 @@ test("a forecast with no matching days draws no line", async ({ page }) => { await openHome(page, { ...typical, forecast: { dates: [lastMonth(2), lastMonth(3)], values: [500, 500] } }); await expect(spend(page).locator('[data-series="current"]')).toHaveCount(1); await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(0); + await expect(spend(page)).not.toContainText("heading for"); }); const dupe = { kind: "duplicate", tag: "DUPLICATES", headline: "Possible double charge: Croma {0}", detail: "Charged on 14 Oct and 15 Oct.", @@ -238,8 +252,8 @@ const spike = { kind: "spike", tag: "SPENDING", headline: "Food is up 35%", deta test("lumen found shows server signals and worth", async ({ page }) => { await openHome(page, { ...typical, summary: { found: [dupe, spike], worth_total: 2499 } }); const found = page.getByTestId("lumen-found"); - await expect(found).toContainText("2 things worth a look · ₹2,499 you may get back"); - await expect(found.getByTestId("found-tile")).toHaveCount(2); + await expect(found).toContainText("2 to look at · ₹2,499 you may get back"); + await expect(found.getByTestId("found-row")).toHaveCount(2); await expect(found).toContainText("Possible double charge: Croma ₹2,499"); await expect(found).toContainText("₹6,750 so far vs ₹5,000 by this point last month."); }); @@ -253,6 +267,7 @@ test("budget card shows progress against the monthly budget", async ({ page }) = await openHome(page, { ...typical, budget: { monthly_budget: 28000, spent: 18412, categories: [{ category: "Food", limit: 8000, spent: 7480 }] } }); const card = page.getByTestId("budget-card"); await expect(card).toContainText("₹18,412 of ₹28,000"); + await expect(card).toContainText("66%"); await expect(card).toContainText("₹9,588 left"); await expect(card).toContainText("Food"); await expect(page.getByText("Let Lumen draft your budget")).toHaveCount(0); // has category budgets already @@ -333,3 +348,178 @@ test("a replaced draft says so", async ({ page }) => { await page.waitForTimeout(300); expect(approvals).toBe(1); }); + +const overlaps = (a: { x: number; y: number; width: number; height: number }, b: typeof a) => + a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; + +test("home header keeps clear of the account menu", async ({ page }) => { + for (const [width, height] of [[1440, 900], [1024, 800]]) { + await page.setViewportSize({ width, height }); + await openHome(page); + await expect(page.getByRole("heading", { level: 1 })).toHaveCSS("font-size", "26px"); + const menu = (await page.getByRole("button", { name: "Account menu" }).boundingBox())!; + for (const name of [/upload/i, /^export$/i]) { + const box = (await page.getByRole("button", { name }).first().boundingBox())!; + expect(overlaps(menu, box), `${String(name)} at ${width}px`).toBe(false); + } + } +}); + +test("hero shows an increase with its percent", async ({ page }) => { + await openHome(page, { txns: [tx("a", "Croma", thisMonth(1), 30000), tx("b", "Rent", lastMonth(1), 20000)] }); + await expect(spend(page).getByTestId("spend-delta")).toHaveText(`+₹10,000 (50%) vs ${prevLong} by the ${ord(now.getDate())}`); +}); + +test("chart ticks never overlap at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openHome(page); + await expect(spend(page).getByTestId("tick").first()).toBeVisible(); + const boxes = await spend(page).getByTestId("tick").evaluateAll((els) => els.map((e) => e.getBoundingClientRect().toJSON())); + expect(boxes.length).toBeGreaterThanOrEqual(2); + for (let i = 0; i < boxes.length; i++) + for (let j = i + 1; j < boxes.length; j++) expect(overlaps(boxes[i], boxes[j]), `ticks ${i} and ${j}`).toBe(false); +}); + +test("the hero + opens upload", async ({ page }) => { + await openHome(page); + await spend(page).getByRole("button", { name: "Upload a receipt" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); +}); + +test("calendar marks today and names future bills", async ({ page }) => { + test.skip(now.getDate() === lastDay, "no future days left this month"); + const tomorrow = now.getDate() + 1; + await openHome(page, { + ...typical, + reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: thisMonth(tomorrow), type: "upcoming" }], + }); + await expect(page.getByTestId(`cal-day-${now.getDate()}`)).toHaveAttribute("data-today", "true"); + await expect(page.getByTestId(`cal-day-${tomorrow}`)).toHaveAttribute("data-future", "true"); + await expect(page.getByTestId(`cal-day-${tomorrow}`)).toContainText("Netflix"); + const card = page.getByTestId("calendar-card"); + await expect(card.getByRole("link", { name: new RegExp(`${monthLong}, day by day`) })).toHaveAttribute("href", "/spending"); + await expect(card.getByRole("link", { name: "See all" })).toHaveAttribute("href", "/spending"); +}); + +test("lumen found rows do things", async ({ page }) => { + await openHome(page, { ...typical, summary: { found: [dupe, spike], worth_total: 2499 } }); + const rows = page.getByTestId("lumen-found").getByTestId("found-row"); + // the spike links to where you can act on it; a row with nowhere to go gets no dead link + await expect(rows.nth(1).getByRole("link", { name: "See in Spending" })).toHaveAttribute("href", "/spending"); + await expect(rows.nth(0).getByRole("link", { name: /^Review|^See/ })).toHaveCount(0); + // "Ask Lumen" opens Ask with the question typed, never sent + const ask = rows.nth(1).getByRole("link", { name: "Ask Lumen" }); + await expect(ask).toHaveAttribute("href", /^\/ask\?draft=/); + expect(decodeURIComponent((await ask.getAttribute("href"))!.split("draft=")[1])).toBe("Tell me more about this: Food is up 35%"); +}); + +test("budget card shows the top four categories by spend", async ({ page }) => { + const c = (category: string, limit: number, spent: number) => ({ category, limit, spent }); + await openHome(page, { + ...typical, + budget: { monthly_budget: 100000, spent: 62182, categories: [c("Travel", 5000, 2140), c("Rent", 25000, 25000), c("Shopping", 8000, 16840), c("Groceries", 12000, 8120), c("Food", 6000, 5930), c("Health", 3000, 640)] }, + }); + const rows = page.getByTestId("budget-cat"); + await expect(rows).toHaveCount(4); + expect(await rows.evaluateAll((els) => els.map((e) => e.getAttribute("data-category")))).toEqual(["Rent", "Shopping", "Groceries", "Food"]); + await expect(rows.nth(1)).toHaveAttribute("data-over", "true"); + await expect(rows.nth(0)).not.toHaveAttribute("data-over", "true"); +}); + +test("an empty account keeps every card at its loading height", async ({ page }) => { + await openHome(page, { txns: [], delayMs: 1500 }); + const cards = page.locator('[data-testid="home-main"] > section, [data-testid="home-side"] > section, [data-testid="spend-card"]'); + await expect(cards.first()).toBeVisible(); + const loading = await cards.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().height))); + await expect(page.getByTestId("brief")).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(300); + const ready = await cards.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().height))); + expect(ready.length).toBe(loading.length); + ready.forEach((h, i) => expect(Math.abs(h - loading[i]), `card ${i}`).toBeLessThanOrEqual(1)); + await expect(page.locator("main")).not.toContainText(/NaN|of ₹0/); +}); + +test("calendar amounts stay readable at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openHome(page, { ...typical, txns: [tx("big", "Rent", thisMonth(1), 25640), tx("b", "Rent", lastMonth(1), 20280)] }); + const amount = page.getByTestId("cal-day-1").locator("b"); + await expect(amount).toBeVisible(); + expect(await amount.evaluate((e) => e.scrollWidth <= e.clientWidth)).toBe(true); +}); + +test("a restock reminder (no vendor) neither crashes Home nor shows as a bill", async ({ page }) => { + await openHome(page, { + ...typical, + reminders: [ + { id: "g", vendor: "", category: "Groceries", amount: 900, date: inDays(1), type: "upcoming" }, + { id: "n", vendor: "Netflix", amount: 649, date: inDays(2), type: "upcoming" }, + ], + }); + await expect(page.getByTestId("bill-row")).toHaveCount(1); + await expect(page.getByTestId("bill-row")).toContainText("Netflix"); + await expect(page.getByTestId("calendar-card")).toBeVisible(); +}); + +// relative luminance of an element's background; the theme uses oklch(), so let a canvas turn it into sRGB first +const bgLuminance = (el: import("@playwright/test").Locator) => + el.evaluate((e) => { + const ctx = document.createElement("canvas").getContext("2d")!; + ctx.fillStyle = getComputedStyle(e).backgroundColor; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b] = [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3).map((v) => { + const x = v / 255; + return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }); + +test("Home's actions are quiet, not loud blue", async ({ page }) => { + await openHome(page); + const upload = page.getByRole("button", { name: "Upload", exact: true }); + await expect(upload).toBeVisible(); + expect(await upload.evaluate((e) => getComputedStyle(e).backgroundColor)).not.toBe("rgb(37, 99, 235)"); + const exp = page.getByRole("button", { name: "Export", exact: true }); + await expect(exp).toBeVisible(); + await expect(exp).toHaveText(""); +}); + +test("upload and export dialogs are light", async ({ page }) => { + await openHome(page); + for (const name of ["Upload", "Export"]) { + await page.getByRole("button", { name, exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + expect(await bgLuminance(dialog), `${name} dialog`).toBeGreaterThan(0.85); + expect(await dialog.evaluate((e) => e.outerHTML.match(/neutral-[89]00|#0b0612|text-white/g))).toBeNull(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + } +}); + +test("the upload dialog has one close button", async ({ page }) => { + await openHome(page); + await page.getByRole("button", { name: "Upload", exact: true }).click(); + await expect(page.getByRole("dialog").locator("button:has(svg.lucide-x)")).toHaveCount(1); +}); + +// Review fix: labels are anchored at the edges, so "Today" near the start or end of the axis used to collide. +for (const [day, range] of [["2026-10-26", "1M"], ["2026-10-04", "1M"], ["2026-10-29", "1M"], ["2026-10-27", "1W"], ["2026-10-26", "3M"]] as const) { + test(`ticks don't overlap on ${day} (${range}) at phone width`, async ({ page }) => { + await page.clock.setFixedTime(new Date(`${day}T12:00:00`)); + await page.setViewportSize({ width: 375, height: 812 }); + const [y, m] = day.split("-").map(Number); + const at = (mm: number, dd: number) => `${y}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`; + await openHome(page, { txns: [tx("a", "Swiggy", at(m, 1), 18412), tx("t", "Uber", day, 300), tx("b", "Rent", at(m - 1, 1), 20280), tx("c", "Rent", at(m - 3, 1), 20280)] }); + if (range !== "1M") await spend(page).getByRole("button", { name: range }).click(); + await expect(spend(page).getByTestId("tick").first()).toBeVisible(); + await page.waitForTimeout(300); + const boxes = await spend(page).getByTestId("tick").evaluateAll((els) => els.map((e) => e.getBoundingClientRect().toJSON())); + for (let i = 0; i < boxes.length; i++) + for (let j = i + 1; j < boxes.length; j++) expect(overlaps(boxes[i], boxes[j]), `ticks ${i} and ${j}`).toBe(false); + if (range === "3M") { + // every label names its month, so "14" can't mean any month + const labels = await spend(page).getByTestId("tick").allTextContents(); + for (const l of labels) expect(l, l).toMatch(/Today|[A-Z][a-z]{2}/); + } + }); +} diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 8bc6b4d..f0c2c16 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -140,6 +140,15 @@ test("ctrl+k opens the ask palette and enter hands off", async ({ page }) => { expect(posts).toEqual(["Where did my money go"]); }); +test("ctrl+k works below md with the sheet closed", async ({ page }) => { + await page.setViewportSize({ width: 767, height: 1024 }); + await signIn(page); + await page.goto("/home"); + await expect(page.getByRole("button", { name: "Account menu" })).toBeVisible(); + await page.keyboard.press("Control+k"); + await expect(palette(page)).toBeVisible(); +}); + test("palette works with caps lock", async ({ page }) => { await signIn(page); await page.goto("/home"); @@ -253,3 +262,16 @@ test("sidebar never scrolls sideways", async ({ page }) => { await expect(content).toBeVisible(); expect(await content.evaluate((e) => e.scrollWidth - e.clientWidth)).toBeLessThanOrEqual(0); }); + +test("spending and bills show no cards that can never load", async ({ page }) => { + await signIn(page, { + home: { reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10), type: "upcoming" }] }, + }); + await page.goto("/spending"); + await expect(page.getByText("Spending Comparison")).toBeVisible(); + for (const dead of ["Could not load payment calendar.", "Could not load reminders.", "Spending Trends"]) + await expect(page.getByText(dead, { exact: true })).toHaveCount(0); + await page.goto("/bills"); + await expect(page.getByTestId("bill-row")).toContainText("Netflix"); + await expect(page.getByText(/Could not load/)).toHaveCount(0); +}); diff --git a/frontend/next.config.js b/frontend/next.config.js index fdcda45..200d02c 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -19,6 +19,9 @@ if (missing.length > 0) { const nextConfig = { reactStrictMode: true, + // e2e runs its own dev server (playwright.config.ts) with dummy env; a separate build folder keeps it from + // clobbering the `npm run dev` server's cache in the same checkout ("missing required error components"). + distDir: process.env.NEXT_DIST_DIR || ".next", // The login page used to live at /signin; old bookmarks and already-sent email links still work // (query strings carry over, and browsers keep the #fragment Supabase puts on email links). async redirects() { diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 547b4f9..3c6d3d5 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321", NEXT_PUBLIC_SUPABASE_ANON_KEY: "e2e-dummy", NEXT_PUBLIC_BACKEND_URL: "http://127.0.0.1:9", + NEXT_DIST_DIR: ".next-e2e", }, }, }); diff --git a/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx b/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx index 5a3b52a..91acece 100644 --- a/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx +++ b/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx @@ -3,11 +3,8 @@ import { useState, useEffect } from "react"; import { DashboardShell } from "@/components/dashboard-shell"; import QuickStatsCard from "@/components/ai-analytics/quickStatsCard"; -import PaymentCalendarCard from "@/components/ai-analytics/paymentCalendarCard"; -import ReminderDetailsCard from "@/components/ai-analytics/reminderDetailsCard"; import AnomalyDetectionCard from "@/components/ai-analytics/anomalyDetectionCard"; import AISuggestionsCard from "@/components/ai-analytics/aiSuggestionsCard"; -import SpendingTrendGraph from "@/components/ai-analytics/spendingTrendGraph"; import CategoryPieChart from "@/components/ai-analytics/categoryPieChart"; import { aiAnalyticsApi } from "@/lib/api/client"; @@ -53,24 +50,16 @@ export default function AIAnalyticsContent() { } > + {/* The payment calendar, reminders and spending-trend cards read fields the backend never sent; they were + removed (owner, 2026-10-22). Bills gets a real calendar in UI revamp Phase 5c, Spending a rebuild in 5b. */}
- - -
+ +
- -
-
- -
-
- -
-
); } diff --git a/frontend/src/app/(app)/ask/chatbotContent.tsx b/frontend/src/app/(app)/ask/chatbotContent.tsx index e81baf7..6a57538 100644 --- a/frontend/src/app/(app)/ask/chatbotContent.tsx +++ b/frontend/src/app/(app)/ask/chatbotContent.tsx @@ -70,7 +70,14 @@ export default function ChatbotContent() { const bottomRef = useRef(null); const lastQ = useRef(null); // the ?q= already handed off (StrictMode runs effects twice) const router = useRouter(); - const q = useSearchParams().get("q"); + const params = useSearchParams(); + const q = params.get("q"); + const draftParam = params.get("draft"); + const lastDraft = useRef(null); + // Bumped whenever the chat on screen changes (new chat, opening a thread). An answer that arrives for an older chat + // must not touch this one: it is already saved to its own thread, so it only refreshes the list. + const chatGen = useRef(0); + const [draft, setDraft] = useState<{ text: string; key: number } | null>(null); const busy = messages.some((m) => m.pending); const refreshThreads = useCallback(() => { @@ -110,6 +117,21 @@ export default function ChatbotContent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [q]); + // /ask?draft=… (Lumen found, Bills "How to cancel"): a new chat with the question typed in but NOT sent, so you can + // edit it first; the URL is cleaned so a reload starts empty + useEffect(() => { + if (draftParam === null) { + lastDraft.current = null; + return; + } + if (lastDraft.current === draftParam) return; + lastDraft.current = draftParam; + router.replace("/ask"); + newChat(); + setDraft({ text: draftParam, key: Date.now() }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draftParam]); + async function ask(question: string, file?: File, replaceId?: string, inThread: string | null = threadId) { const id = crypto.randomUUID(); const started = Date.now(); @@ -118,8 +140,10 @@ export default function ChatbotContent() { ...(replaceId ? [] : [{ id: `${id}-q`, role: "user" as const, content: question, file }]), { id, role: "assistant", content: "", question, file, pending: true }, ]); + const gen = chatGen.current; try { const answer = await chatApi.sendMessage(question, file, inThread); + if (gen !== chatGen.current) return refreshThreads(); if (!inThread) { setThreadId(answer.thread_id); refreshThreads(); @@ -133,6 +157,7 @@ export default function ChatbotContent() { ) ); } catch (error) { + if (gen !== chatGen.current) return; setMessages((list) => list.map((m) => (m.id === id ? { ...m, content: errorMessage(error), pending: false, error: true, keyIssue: KEY_CODES.includes(errorCode(error) ?? "") } : m)) ); @@ -140,12 +165,14 @@ export default function ChatbotContent() { } function newChat() { + chatGen.current++; setThreadId(null); setMessages([]); setHistoryOpen(false); } async function openThread(id: string) { + chatGen.current++; setHistoryOpen(false); try { const { messages: rows } = await chatApi.thread(id); @@ -218,7 +245,7 @@ export default function ChatbotContent() { Ask about spending, invoices or contracts. Answers come with their sources.

- void ask(text, file)} busy={busy} /> + { setDraft(null); void ask(text, file); }} busy={busy} />
{suggestions.map((text, i) => ( @@ -295,7 +322,7 @@ export default function ChatbotContent() {
- void ask(text, file)} busy={busy} /> + { setDraft(null); void ask(text, file); }} busy={busy} />

Lumen cites its sources and only proposes changes; nothing is applied until you approve it.

diff --git a/frontend/src/app/(app)/ask/thread-rail.tsx b/frontend/src/app/(app)/ask/thread-rail.tsx index b2f14bb..112395a 100644 --- a/frontend/src/app/(app)/ask/thread-rail.tsx +++ b/frontend/src/app/(app)/ask/thread-rail.tsx @@ -7,7 +7,8 @@ import { relativeTime } from "@/lib/home-math"; import { cn } from "@/lib/utils"; // Past conversations (UI revamp Phase 4): one click resumes, delete asks once inline. Rendered as a column on -// large screens and inside a Sheet below that (chatbotContent.tsx). +// large screens and inside a Sheet below that (chatbotContent.tsx). `disabled` while an answer is in flight, so nothing +// here can delete or swap the thread that answer is landing in. export function ThreadRail({ threads, activeId, @@ -43,13 +44,20 @@ export function ThreadRail({ {confirming === t.id ? (
Delete this chat? - diff --git a/frontend/src/app/(app)/bills/page.tsx b/frontend/src/app/(app)/bills/page.tsx index b8af91a..7172d40 100644 --- a/frontend/src/app/(app)/bills/page.tsx +++ b/frontend/src/app/(app)/bills/page.tsx @@ -1,17 +1,15 @@ import type { Metadata } from "next"; import { DashboardShell } from "@/components/dashboard-shell"; -import PaymentCalendarCard from "@/components/ai-analytics/paymentCalendarCard"; -import ReminderDetailsCard from "@/components/ai-analytics/reminderDetailsCard"; +import { UpcomingBills } from "./upcoming-bills"; export const metadata: Metadata = { title: "Bills & subscriptions" }; -// Interim (UI revamp Phase 1): the payment calendar and reminders until Phase 5 redesigns Bills. +// Interim until UI revamp Phase 5c redesigns Bills: the upcoming bills Lumen has spotted. export default function Page() { return ( -
- - +
+
); diff --git a/frontend/src/app/(app)/bills/upcoming-bills.tsx b/frontend/src/app/(app)/bills/upcoming-bills.tsx new file mode 100644 index 0000000..5e75088 --- /dev/null +++ b/frontend/src/app/(app)/bills/upcoming-bills.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { aiAnalyticsApi } from "@/lib/api/client"; +import { BillsCard } from "../home/_components/bills-card"; +import { toReminders, type Reminder } from "../home/use-home-data"; + +// Interim Bills (until UI revamp Phase 5c): the same upcoming-bills list as Home, over the next two months. +export function UpcomingBills() { + const [reminders, setReminders] = useState(null); + useEffect(() => { + aiAnalyticsApi.getReminders(62).then( + (r: { reminders?: Parameters[0] }) => setReminders(toReminders(r.reminders ?? [])), + () => setReminders([]), + ); + }, []); + const today = new Date(); + return ; +} diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx index 5287f27..7475e7a 100644 --- a/frontend/src/app/(app)/home/_components/activity-card.tsx +++ b/frontend/src/app/(app)/home/_components/activity-card.tsx @@ -2,13 +2,13 @@ import Link from "next/link"; import type { ActivityItem } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { fill, relativeTime } from "@/lib/home-math"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; // What happened lately: statement imports, receipts read, your decisions (GET /api/home/summary). export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; now: Date; loading: boolean }) { const money = useMoney(); return ( - + {items.length === 0 ? (

Nothing imported yet.{" "} @@ -25,6 +25,6 @@ export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; n

)) )} - + ); } diff --git a/frontend/src/app/(app)/home/_components/bills-card.tsx b/frontend/src/app/(app)/home/_components/bills-card.tsx index 214bbd1..4efad3a 100644 --- a/frontend/src/app/(app)/home/_components/bills-card.tsx +++ b/frontend/src/app/(app)/home/_components/bills-card.tsx @@ -1,34 +1,34 @@ import { useMoney } from "@/lib/format"; import { parseDay } from "@/lib/home-math"; import type { Reminder } from "../use-home-data"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; -// The next three recurring charges Lumen expects. +// "Coming up": the next four recurring charges Lumen expects, each with a little date chip. 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); + .slice(0, 4); 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")} +
+ + {d.toLocaleString("en-GB", { weekday: "short" })} + {d.getDate()} {r.vendor} - {r.amount == null ? "—" : money(r.amount)} + {r.amount == null ? "—" : money(r.amount)}
); }) )} - + ); } diff --git a/frontend/src/app/(app)/home/_components/brief.tsx b/frontend/src/app/(app)/home/_components/brief.tsx index 190c266..a7cf827 100644 --- a/frontend/src/app/(app)/home/_components/brief.tsx +++ b/frontend/src/app/(app)/home/_components/brief.tsx @@ -3,11 +3,11 @@ 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/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx index 9e0737f..b611e2a 100644 --- a/frontend/src/app/(app)/home/_components/budget-card.tsx +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -6,28 +6,33 @@ import { useMoney } from "@/lib/format"; import { toast } from "@/lib/toast"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; // Overall budget = the profile's monthly budget (onboarding); per-category limits come from approved set_budget -// proposals or PUT /api/budget. Nothing here is invented: with neither, the card says so. +// proposals or PUT /api/budget. Nothing here is invented: with neither, the card says so. The top four categories +// by spend show as bars: green, amber from 90% of the limit, red when over. export function BudgetCard({ budget, monthName, today, loading }: { budget: BudgetState | null; monthName: string; today: Date; loading: boolean }) { const money = useMoney(); const total = budget?.monthly_budget ?? null; - const cats = (budget?.categories ?? []).slice(0, 3); + const cats = [...(budget?.categories ?? [])].sort((a, b) => b.spent - a.spent).slice(0, 4); const spent = budget?.spent ?? 0; const daysLeft = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() - today.getDate() + 1; + const pct = total ? Math.round((spent / total) * 100) : 0; return ( - + {total ? ( <> -
- {money(spent)} of {money(total)} +
+ {money(spent)} of {money(total)}
-
- total ? "bg-bad" : "bg-good")} style={{ width: `${Math.min(100, (spent / total) * 100)}%` }} /> +
+
+ total ? "bg-bad" : "bg-good")} style={{ width: `${Math.min(100, pct)}%` }} /> +
+ {pct}%
-

+

{spent > total ? `${money(spent - total)} over` : `${money(total - spent)} left · ${daysLeft} ${daysLeft === 1 ? "day" : "days"} · ~${money((total - spent) / daysLeft)}/day`} @@ -40,18 +45,30 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg ) : null} {cats.length > 0 && ( -

- {cats.map((c) => ( -
- {c.category} - c.limit && "text-bad")}> - {money(c.spent)} / {money(c.limit)} - -
- ))} +
+ {cats.map((c) => { + const used = c.limit > 0 ? c.spent / c.limit : 0; + const over = c.spent > c.limit; + return ( +
+
+ {c.category} + + {money(c.spent)} / {money(c.limit)} + +
+
+ = 0.9 ? "bg-amber-500" : "bg-primary")} + style={{ width: `${Math.min(100, used * 100)}%` }} + /> +
+
+ ); + })}
)} - + ); } diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index 8d66590..b808960 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -1,61 +1,122 @@ +import Link from "next/link"; +import { PageCard } from "@/components/page/page-card"; +import { VendorChip } from "@/components/page/vendor-chip"; import { useMoney } from "@/lib/format"; -import { calendarBuckets, heat, parseDay, type Period, type Txn } from "@/lib/home-math"; -import { HomeCard } from "./card"; +import { calendarBuckets, dayIndex, heat, parseDay, type Period, type Txn } from "@/lib/home-math"; +import { cn } from "@/lib/utils"; +import type { Reminder } from "../use-home-data"; -// "₹2.2k" in the user's own currency: take the symbol from how useMoney prints zero -const kShort = (n: number, symbol: string) => (n >= 1000 ? `${symbol}${(n / 1000).toFixed(1)}k` : `${symbol}${Math.round(n)}`); +// "2.2k"; the user's currency symbol (from how useMoney prints zero) goes in front, except on phones, where a cell +// is too narrow for "₹25.6k" and the card's total already says the currency +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" }); +const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" }); -// 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 }) { +// This month day by day (spec 2026-10-22 §4.1): what each day cost, darker = more, today outlined, and the bills +// still to come named on their days; the latest transactions beside it. The card is always as tall as a six-week +// month, so it never changes height between loading and loaded, or from one month to the next. +export function CalendarCard({ + txns, + reminders, + period, + today, + loading, +}: { + txns: Txn[]; + reminders: Reminder[]; + period: Period; + today: Date; + loading: boolean; +}) { const money = useMoney(); const symbol = money(0).replace(/[\d.,\s]/g, ""); const days = calendarBuckets(txns, period); const max = Math.max(...days.map((d) => d.amount), 0); + const total = days.reduce((s, d) => s + d.amount, 0); + const spentDays = days.filter((d) => d.amount > 0).length; + const t = dayIndex(period, today); const lead = (period.start.getDay() + 6) % 7; // Monday first const recent = [...txns].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 5); + const billOn = new Map(); + for (const r of reminders) { + const i = dayIndex(period, parseDay(r.date)); + if (r.type === "upcoming" && i > t && i < period.days && !billOn.has(i)) billOn.set(i, r.vendor); + } 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, symbol)}} + +
+
+
+ {money(Math.round(total))}{" "} + + across {spentDays} {spentDays === 1 ? "day" : "days"} + +
+
+ {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => ( +
+ {d}
- ); - })} + ))} + {Array.from({ length: lead }, (_, i) => ( +
+ ))} + {days.map(({ day, amount }, i) => { + const h = heat(amount, max); + const future = i > t; + const bill = billOn.get(i); + return ( +
0.5 ? "#fff" : undefined } : undefined} + > + {day} + {amount > 0 && ( + + {symbol} + {kShort(amount)} + + )} + {future && bill && {bill}} +
+ ); + })} +
-
Recent transactions
+
+ Recent transactions + + See all + +
{recent.length === 0 ? (

No transactions this month yet.

) : ( recent.map((t) => ( -
+
+ {t.vendor_name ?? "Unknown"} - {t.category ?? "Uncategorised"} · {dayMonth(t.date)} + {dayMonth(t.date)} · {t.category ?? "Uncategorised"} - {money(t.total_amount ?? 0)} + {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 index a986f88..5feae32 100644 --- a/frontend/src/app/(app)/home/_components/lumen-found.tsx +++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx @@ -1,8 +1,21 @@ import Link from "next/link"; -import type { HomeSummary } from "@/lib/api/client"; +import { Copy, ReceiptText, RefreshCw, TrendingUp, type LucideIcon } from "lucide-react"; +import type { FoundItem, HomeSummary } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { fill } from "@/lib/home-math"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; + +// How each kind looks and what its link does. A row only links somewhere it can be acted on (spec 2026-10-22 §4.1). +const KIND: Record = { + duplicate: { icon: Copy, tint: "bg-bad/10 text-bad", action: "Review duplicate" }, + review: { icon: ReceiptText, tint: "bg-primary/10 text-primary", action: "Review receipts" }, + spike: { icon: TrendingUp, tint: "bg-amber-500/10 text-amber-700", action: "See in Spending" }, + price_rise: { icon: RefreshCw, tint: "bg-primary/10 text-primary", action: "See the charges" }, +}; + +// What "Ask Lumen" types into Ask for you (not sent: you can edit it first). +export const askAbout = (item: FoundItem, money: (n: number) => string) => + `Tell me more about this: ${fill(item.headline, item.amounts, money).replace(/\.$/, "")}`; // "Lumen found": duplicates, receipts to review, spending spikes and price rises (GET /api/home/summary, rule-based). // A money "worth" is only claimed for things you may get back (duplicates). @@ -11,34 +24,48 @@ export function LumenFound({ summary, loading }: { summary: HomeSummary | null; const found = summary?.found ?? []; const total = found.length + (summary?.more ?? 0); return ( - + 0 && ( + + {total} to look at + {summary!.worth_total > 0 && ` · ${money(summary!.worth_total)} you may get back`} + + ) + } + loading={loading} + skeletonHeight={132} + data-testid="lumen-found" + > {found.length === 0 ? (

Nothing needs you right now.

) : ( - <> -

- {total} {total === 1 ? "thing" : "things"} worth a look - {summary!.worth_total > 0 && ( - · {money(summary!.worth_total)} you may get back - )} -

-
- {found.map((f, i) => ( -
- {/* the API sends tags in caps ("RECEIPTS"); labels are sentence case */} -
{f.tag.charAt(0) + f.tag.slice(1).toLowerCase()}
-

{fill(f.headline, f.amounts, money)}

-

{fill(f.detail, f.amounts, money)}

- {f.href && ( - - {f.kind === "review" ? "Review →" : "See →"} + found.map((f, i) => { + const { icon: Icon, tint, action } = KIND[f.kind] ?? KIND.review; + return ( +
+ + + +
+

{fill(f.headline, f.amounts, money)}

+

{fill(f.detail, f.amounts, money)}

+
+ {f.href && ( + + {action} + + )} + + Ask Lumen - )} +
- ))} -
- +
+ ); + }) )} - + ); } diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index a10fbfa..fda1882 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -2,18 +2,31 @@ import { useEffect, useState } from "react"; import { animate } from "motion/react"; +import { Plus } from "lucide-react"; +import { PageCard } from "@/components/page/page-card"; +import { pickTicks, smoothPath } from "@/lib/chart"; import { useMoney } from "@/lib/format"; -import { dayIndex, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math"; +import { dayIndex, ordinal, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math"; import { cn } from "@/lib/utils"; import type { Forecast, Reminder } from "../use-home-data"; -import { 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-US", { month: "short" }); // "Sep", not en-GB's "Sept" -const tick = (d: Date) => d.toLocaleString("en-GB", { day: "2-digit", month: "short" }); +const W = 1000; +const H = 200; +const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" }); +const dayMonth = (d: Date) => d.toLocaleString("en-GB", { day: "numeric", month: "short" }); + +function title(range: Range, period: Period) { + if (range === "1M") return `Spent in ${monthLong(period.start)}`; + if (range === "YTD") return `Spent in ${period.start.getFullYear()}`; + return range === "1W" ? "Spent this week" : "Spent this quarter"; +} + +function compared(range: Range, previous: Period, today: Date) { + if (range === "1M") return `vs ${monthLong(previous.start)} by the ${ordinal(today.getDate())}`; + if (range === "YTD") return `vs ${previous.start.getFullYear()} at this point`; + return range === "1W" ? "vs last week at this point" : "vs last quarter at this point"; +} // Counts up once per browser session, then shows the number straight away (motion rule: no replay on revisits). function CountUp({ value, format }: { value: number; format: (n: number) => string }) { @@ -32,9 +45,26 @@ function CountUp({ value, format }: { value: number; format: (n: number) => stri return <>{format(shown)}; } +// The width the tick labels are laid out in, so they can be spaced in real pixels at any screen size. A callback +// ref: the axis only exists once the card has data, after the first render. +function useWidth() { + const [el, setEl] = useState(null); + const [width, setWidth] = useState(800); + useEffect(() => { + if (!el) return; + const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width)); + ro.observe(el); + return () => ro.disconnect(); + }, [el]); + return [setEl, width] as const; +} + +// Home's hero (spec 2026-10-22 §4.1, Origin style): the period's total, how it compares, where it's heading, and the +// running total as a smooth line against the previous period, with upcoming bills pinned on the axis. export function SpendCard({ range, onRange, + onUpload, period, previous, cur, @@ -46,6 +76,7 @@ export function SpendCard({ }: { range: Range; onRange: (r: Range) => void; + onUpload: () => void; period: Period; previous: Period; cur: number[]; @@ -56,6 +87,7 @@ export function SpendCard({ loading: boolean; }) { const money = useMoney(); + const [axis, width] = useWidth(); const t = Math.min(Math.max(dayIndex(period, today), 0), period.days - 1); const total = cur[t] ?? 0; const delta = sameDayDelta(cur, prev, t); @@ -78,9 +110,9 @@ export function SpendCard({ const empty = total === 0 && !prev.some((v) => v > 0); const max = Math.max(total, prev[prev.length - 1] ?? 0, fc[fc.length - 1] ?? 0, 1); const x = (i: number) => (period.days > 1 ? (i / (period.days - 1)) * W : 0); - const y = (v: number) => H - (v / max) * (H - 20); - const line = (vals: number[], from = 0) => vals.map((v, i) => `${i ? "L" : "M"}${x(i + from).toFixed(1)} ${y(v).toFixed(1)}`).join(" "); - const actual = cur.slice(0, t + 1); + const y = (v: number) => H - (v / max) * (H - 16); + const pts = (vals: number[], from = 0) => vals.map((v, i) => [x(i + from), y(v)] as [number, number]); + const actual = pts(cur.slice(0, t + 1)); const pins = reminders.filter((r) => { if (r.type !== "upcoming") return false; @@ -88,83 +120,159 @@ export function SpendCard({ return i > t && i < period.days; }); - const ticks = [0, Math.round((period.days - 1) * 0.25), Math.round((period.days - 1) * 0.5), t, period.days - 1] - .filter((v, i, a) => a.indexOf(v) === i) - .sort((a, b) => a - b); - const vsLabel = - range === "1M" ? `vs ${short(previous.start)} at this point` : range === "YTD" ? `vs ${previous.start.getFullYear()} at this point` : "vs last period at this point"; + // Today always shows; the two ends unless they'd touch it; the dates in between only where they fit. 1W/1M label + // those by day number, 3M by day and month, YTD by the 1st of each month. Widths are estimated (12.5px UI font). + const px = (i: number) => (x(i) / W) * width; + const at = (i: number) => new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i); + const last = period.days - 1; + const middle = (i: number) => + range === "YTD" ? at(i).getDate() === 1 : i % 7 === 0; + const ticks = pickTicks( + Array.from({ length: period.days }, (_, i) => i) + .filter((i) => i === 0 || i === t || i === last || middle(i)) + .map((i) => { + const label = + i === t ? "Today" + : i === 0 || i === last || range === "3M" ? dayMonth(at(i)) + : range === "YTD" ? at(i).toLocaleString("en-GB", { month: "short" }) + : String(at(i).getDate()); + return { + i, + label, + x: px(i), + w: label.length * 7 + 2, + anchor: (i === 0 ? "start" : i === last ? "end" : "middle") as "start" | "end" | "middle", + rank: i === t ? 0 : i === 0 || i === last ? 1 : 2, + }; + }), + 8, + ); + + const legend = ( + + + + Now + + + + Before + + {fc.length > 0 && ( + + + Forecast + + )} + + ); 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} -
+ + + + } + loading={loading} + skeletonHeight={348} + className="relative" + data-testid="spend-card" + > +
+ money(n)} /> +
+
+ {delta && Math.round(delta.abs) !== 0 && ( + + + {delta.abs < 0 ? "−" : "+"} + {money(Math.abs(Math.round(delta.abs)))} ({Math.abs(Math.round(delta.pct))}%) + {" "} + {compared(range, previous, today)} + + )} + {fc.length > 0 && ( + + {delta && Math.round(delta.abs) !== 0 && " · "}heading for{" "} + {money(Math.round(fc[fc.length - 1]))} + + )}
{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.

) : ( - <> -
- - - - - - - - {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} + <> +
+ + + + + + + + {[0.25, 0.5, 0.75].map((f) => ( + + ))} + {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.charAt(0).toUpperCase()} + + ); + })} +
+
+ {ticks.map(({ i, label }) => ( + + {label} - - - ); - })} -
-
- {ticks.map((i) => ( - - {i === t ? "today" : tick(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i))} - - ))} -
- + ))} +
+ )}
{RANGES.map((r) => ( @@ -174,14 +282,14 @@ export function SpendCard({ aria-pressed={r === range} onClick={() => onRange(r)} className={cn( - "label-ui rounded-md px-2.5 py-1 transition-colors", - r === range ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground", + "label-ui rounded-lg px-3.5 py-1 transition-colors", + r === range ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground", )} > {r} ))}
- + ); } diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index ca06636..122b663 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useProfile } from "@/components/profile/profile-provider"; import { DialogDemo } from "@/components/uploadDialog"; @@ -8,6 +8,7 @@ import { ExportDialog } from "@/components/exportDialog"; import type { FoundItem } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { briefSentence, cumulative, dayIndex, fill, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math"; +import { PageHeader } from "@/components/page/page-header"; import { Brief } from "./_components/brief"; import { ActivityCard } from "./_components/activity-card"; import { BillsCard } from "./_components/bills-card"; @@ -37,6 +38,10 @@ export default function HomeContent() { const { profile } = useProfile(); const money = useMoney(); const [range, setRange] = useState("1M"); + const [uploadOpen, setUploadOpen] = useState(openUpload); + useEffect(() => { + if (openUpload) setUploadOpen(true); + }, [openUpload]); const today = useMemo(() => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate()); @@ -68,35 +73,33 @@ export default function HomeContent() {
- {/* md:pr-12 leaves room for the floating account menu (app-shell.tsx) */} -
-

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

-
- - -
-
-
- -
-
+ } + actions={ + <> + + + + } + /> + setUploadOpen(true)} + period={chart.current} + previous={chart.previous} + cur={cumulative(data.txns, chart.current)} + prev={cumulative(data.prevTxns, chart.previous)} + forecast={data.forecast} + reminders={data.reminders} + today={today} + loading={data.status === "loading"} + /> +
- + -
diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts index 054cd53..ef52d75 100644 --- a/frontend/src/app/(app)/home/use-home-data.ts +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -5,6 +5,29 @@ import { aiAnalyticsApi, budgetApi, homeApi, transactionApi, type BudgetState, t 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 }; +// GET /api/analytics/reminders as the backend sends it (ai/pattern_detection.generate_reminders). +type ApiReminder = { + title?: string; + predicted_date?: string | null; + pattern_id?: string | number; + metadata?: { vendor_name?: string | null; average_amount?: number | null; category?: string | null } | null; +}; +// Bills only: a recurring payment to a vendor with a date. Restock nudges ("You usually buy Groceries around the +// 5th") have no vendor and aren't bills. Rows with no date are skipped rather than crashing the page. +export function toReminders(rows: ApiReminder[]): Reminder[] { + return rows.flatMap((r, i) => + r.predicted_date && r.metadata?.vendor_name + ? [{ + id: r.pattern_id ?? i, + vendor: r.metadata.vendor_name, + amount: r.metadata.average_amount ?? null, + date: r.predicted_date, + type: "upcoming", + category: r.metadata.category ?? undefined, + }] + : [], + ); +} export type Forecast = { dates: string[]; values: number[] } | null; export type HomeData = { status: "loading" | "ready"; @@ -83,7 +106,7 @@ export function useHomeData(range: Range, today: Date): HomeData { useEffect(() => { let live = true; Promise.allSettled([ - aiAnalyticsApi.getReminders(31).then((r: { reminders?: Reminder[] }) => r.reminders ?? []), + aiAnalyticsApi.getReminders(31).then((r: { reminders?: ApiReminder[] }) => toReminders(r.reminders ?? [])), homeApi.summary(), budgetApi.get(), ]).then(([reminders, summary, budget]) => { diff --git a/frontend/src/components/ai-analytics/paymentCalendarCard.tsx b/frontend/src/components/ai-analytics/paymentCalendarCard.tsx deleted file mode 100644 index f562382..0000000 --- a/frontend/src/components/ai-analytics/paymentCalendarCard.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Calendar as CalendarIcon, - ChevronLeft, - ChevronRight, -} from "lucide-react"; -import { motion } from "motion/react"; -import { useState, useEffect } from "react"; -import { analyticsApi } from "@/lib/api/client"; - -const cardVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - ease: [0.4, 0, 0.2, 1] as any, - delay: 0.1, - }, - }, -}; - -export default function PaymentCalendarCard() { - const [currentDate, setCurrentDate] = useState(new Date()); - const [paymentDates, setPaymentDates] = useState<{ - upcoming: number[]; - forecast: number[]; - }>({ - upcoming: [], - forecast: [], - }); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchPaymentDates = async () => { - try { - setError(null); - const response = await analyticsApi.getAllTimeSummary(); - if (response.success && response.payment_calendar) { - setPaymentDates(response.payment_calendar); - } else { - setPaymentDates({ upcoming: [], forecast: [] }); - setError("Could not load payment calendar."); - } - } catch (err) { - console.error("Failed to load payment calendar", err); - setPaymentDates({ upcoming: [], forecast: [] }); - setError("Could not load payment calendar."); - } finally { - setLoading(false); - } - }; - fetchPaymentDates(); - }, [currentDate]); - - const getDaysInMonth = (date: Date) => { - const year = date.getFullYear(); - const month = date.getMonth(); - const firstDay = new Date(year, month, 1).getDay(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - return { firstDay, daysInMonth }; - }; - - const { firstDay, daysInMonth } = getDaysInMonth(currentDate); - const monthName = currentDate.toLocaleDateString("en-US", { - month: "long", - year: "numeric", - }); - - const previousMonth = () => { - setCurrentDate( - new Date(currentDate.getFullYear(), currentDate.getMonth() - 1) - ); - }; - - const nextMonth = () => { - setCurrentDate( - new Date(currentDate.getFullYear(), currentDate.getMonth() + 1) - ); - }; - - const isUpcoming = (day: number) => paymentDates.upcoming.includes(day); - const isForecast = (day: number) => paymentDates.forecast.includes(day); - - if (loading) { - return ( - - -

Loading calendar...

-
-
- ); - } - - if (error) { - return ( - - -

{error}

-
-
- ); - } - - return ( - - - - -
- - Payment Calendar -
-
- - -
-
-
- -
- {/* Month/Year */} -

- {monthName} -

- - {/* Calendar Grid */} -
- {/* Day Headers */} - {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map( - (day) => ( -
- {day} -
- ) - )} - - {/* Empty cells for days before month starts */} - {Array.from({ length: firstDay }).map( - (_, index) => ( -
- ) - )} - - {/* Days of the month */} - {Array.from({ length: daysInMonth }).map( - (_, index) => { - const day = index + 1; - const isUpcomingDay = isUpcoming(day); - const isForecastDay = isForecast(day); - - return ( -
- {day} -
- ); - } - )} -
- - {/* Legend */} -
-
-
- Upcoming -
-
-
- Forecast -
-
-
-
-
-
- ); -} diff --git a/frontend/src/components/ai-analytics/reminderDetailsCard.tsx b/frontend/src/components/ai-analytics/reminderDetailsCard.tsx deleted file mode 100644 index 4d14355..0000000 --- a/frontend/src/components/ai-analytics/reminderDetailsCard.tsx +++ /dev/null @@ -1,171 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Bell, TrendingUp, Calendar } from "lucide-react"; -import { motion } from "motion/react"; -import { Badge } from "@/components/ui/badge"; -import { useState, useEffect } from "react"; -import { useMoney } from "@/lib/format"; -import { analyticsApi } from "@/lib/api/client"; - -const cardVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - ease: "easeOut" as const, - delay: 0.2, - }, - }, -}; - -export default function ReminderDetailsCard() { - const money = useMoney(); - const [reminders, setReminders] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchReminders = async () => { - try { - setError(null); - const response = await analyticsApi.getAllTimeSummary(); - if (response.success && response.upcoming_payments) { - setReminders(response.upcoming_payments); - } else { - setReminders([]); - setError("Could not load reminders."); - } - } catch (err) { - console.error("Failed to load reminders", err); - setReminders([]); - setError("Could not load reminders."); - } finally { - setLoading(false); - } - }; - fetchReminders(); - }, []); - - if (loading) { - return ( - - -

Loading reminders...

-
-
- ); - } - - return ( - - - - - - Reminders & Forecasts - - - - {error ? ( -
- {error} -
- ) : reminders.length === 0 ? ( -
- No reminders or forecasts available -
- ) : ( -
- {reminders.map((reminder, index) => ( - -
-
-

- {reminder.vendor} -

-
- - {reminder.type === - "upcoming" ? ( - - ) : ( - - )} - {reminder.type === - "upcoming" - ? "Upcoming" - : "Forecast"} - - - {reminder.category} - -
-
-
-

- {money(reminder.amount ?? 0, null, { decimals: 2 })} -

-

- {reminder.date} -

-
-
-

- {reminder.description || - reminder.reason} -

-
- ))} -
- )} -
-
- - -
- ); -} diff --git a/frontend/src/components/ai-analytics/spendingTrendGraph.tsx b/frontend/src/components/ai-analytics/spendingTrendGraph.tsx deleted file mode 100644 index fa5ed04..0000000 --- a/frontend/src/components/ai-analytics/spendingTrendGraph.tsx +++ /dev/null @@ -1,254 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { TrendingUp, Activity } from "lucide-react"; -import { motion } from "motion/react"; -import { useState, useEffect } from "react"; -import { useMoney } from "@/lib/format"; -import { useProfile } from "@/components/profile/profile-provider"; -import { analyticsApi } from "@/lib/api/client"; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, -} from "recharts"; - -const cardVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - ease: [0.4, 0, 0.2, 1] as any, - delay: 0.5, - }, - }, -}; - -const CustomTooltip = ({ active, payload, label }: any) => { - const money = useMoney(); - if (active && payload && payload.length) { - return ( -
-

{label}

- {payload.map((entry: any, index: number) => ( -

- {entry.name}: {money(entry.value ?? 0)} -

- ))} -
- ); - } - return null; -}; - -export default function SpendingTrendGraph() { - const money = useMoney(); - const { profile } = useProfile(); - // Axis ticks: short amounts in the profile currency, e.g. "₹8K" / "CHF 8K". - const compact = (v: number) => - new Intl.NumberFormat(undefined, { style: "currency", currency: profile?.currency || "INR", notation: "compact", maximumFractionDigits: 1 }).format(v); - const [spendingData, setSpendingData] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchSpendingTrends = async () => { - try { - const response = await analyticsApi.getAllTimeSummary(); - if (response.success && response.monthly_trends) { - setSpendingData(response.monthly_trends); - } else { - setSpendingData([]); - } - } catch (err) { - setError("Failed to load spending trends"); - setSpendingData([]); - } finally { - setLoading(false); - } - }; - fetchSpendingTrends(); - }, []); - - if (loading) { - return ( - - -

Loading spending trends...

-
-
- ); - } - - if (error) { - return ( - - -

{error}

-
-
- ); - } - - return ( - - - - -
- - Spending Trends -
-
-
-
- - Actual - -
-
-
- - Forecast - -
-
-
- - Budget - -
-
-
-
- -
- - - - - compact(value)} - /> - } /> - - - - - -
-
-

- AI Insights -

-
-
- -
-

- November Spending Anomaly Detected -

-

- Your November spending of {money(8316)} is 454% - above your monthly average of {money(1500)}. - This represents an unusual spike with 37 - transactions (48% more than usual). -

-
-
-
-
-
- -
-

- Seasonal Pattern Recognition -

-

- Historical data shows spending increases - in Q3 (Aug-Oct). Your 2024 Q3 averaged - {money(1973)}/month. Plan for similar patterns - in 2026. -

-
-
-
-
-
- -
-

- Spending Discipline Observed -

-

- April-July 2025 maintained excellent - spending control, averaging just - {money(645)}/month. This shows strong budget - adherence during low-activity periods. -

-
-
-
-
-
-
-
- ); -} diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx index 9810ec2..6c6a085 100644 --- a/frontend/src/components/app-shell.tsx +++ b/frontend/src/components/app-shell.tsx @@ -1,16 +1,32 @@ "use client"; -import { Suspense, type ReactNode } from "react"; +import { Suspense, useEffect, useState, type ReactNode } from "react"; import { MotionConfig } from "motion/react"; import { AppSidebar } from "@/components/app-sidebar"; +import { AskPalette } from "@/components/ask-palette"; 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"; +import { isTypingTarget } from "@/lib/utils"; // 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 }) { + // the Ask palette lives here, not in the sidebar: below md the sidebar is a Sheet that isn't mounted while closed + const [paletteOpen, setPaletteOpen] = useState(false); + useEffect(() => { + // e.code, not e.key: Caps Lock or Shift turns the key into "K" + const onKey = (e: KeyboardEvent) => { + if (e.code === "KeyK" && (e.metaKey || e.ctrlKey) && !isTypingTarget(e.target)) { + e.preventDefault(); + setPaletteOpen(true); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + return ( // Sky (globals.css) wraps everything, auth loader included, so the first server paint is already light.
@@ -21,7 +37,8 @@ export function AppShell({ children }: { children: ReactNode }) { {/* 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. */} - + setPaletteOpen(true)} /> + {/* no top bar on desktop: just the account menu, floating top-right over the page (owner, 2026-10-22). Phones keep a slim row, since the sidebar there is a sheet that needs a button to open. */} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 2329ff9..4e54e0d 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -20,10 +20,8 @@ import { SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; -import { AskPalette } from "@/components/ask-palette"; import { reviewApi } from "@/lib/api/client"; import { eventBus, EVENTS } from "@/lib/events"; -import { isTypingTarget } from "@/lib/utils"; type Item = { label: string; url: string; icon: LucideIcon }; @@ -55,11 +53,12 @@ function NavItem({ item, children }: { item: Item; children?: React.ReactNode }) ); } -export function AppSidebar(props: React.ComponentProps) { +// `onAsk` opens the Ask palette, which app-shell.tsx owns: below md this sidebar is a Sheet that isn't mounted while +// closed, so a palette (and its Ctrl+K) living in here would do nothing there. +export function AppSidebar({ onAsk, ...props }: React.ComponentProps & { onAsk: () => void }) { const pathname = usePathname(); const [toReview, setToReview] = React.useState(0); const [mac, setMac] = React.useState(false); - const [paletteOpen, setPaletteOpen] = React.useState(false); // refreshed on navigation and after each review decision (which happens without leaving /receipts) React.useEffect(() => { @@ -68,18 +67,7 @@ export function AppSidebar(props: React.ComponentProps) { return eventBus.on(EVENTS.REVIEW_DECIDED, refresh); }, [pathname]); - React.useEffect(() => { - setMac(/Mac|iPhone|iPad/.test(navigator.platform)); - // e.code, not e.key: Caps Lock or Shift turns the key into "K" - const onKey = (e: KeyboardEvent) => { - if (e.code === "KeyK" && (e.metaKey || e.ctrlKey) && !isTypingTarget(e.target)) { - e.preventDefault(); - setPaletteOpen(true); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, []); + React.useEffect(() => setMac(/Mac|iPhone|iPad/.test(navigator.platform)), []); return ( @@ -137,7 +125,7 @@ export function AppSidebar(props: React.ComponentProps) { setPaletteOpen(true)} + onClick={onAsk} className="border border-sidebar-border bg-card text-muted-foreground hover:text-foreground" > @@ -151,7 +139,6 @@ export function AppSidebar(props: React.ComponentProps) { - ); diff --git a/frontend/src/components/ask/prompt-bar.tsx b/frontend/src/components/ask/prompt-bar.tsx index 1725343..65851ba 100644 --- a/frontend/src/components/ask/prompt-bar.tsx +++ b/frontend/src/components/ask/prompt-bar.tsx @@ -10,13 +10,16 @@ export function PromptBar({ onSend, busy, hero = false, + initial = "", }: { onSend: (text: string, file?: File) => void; busy: boolean; /** the large, centred version shown before the first question */ hero?: boolean; + /** text to start with (Ask's ?draft=); remount with a new key to change it */ + initial?: string; }) { - const [draft, setDraft] = useState(""); + const [draft, setDraft] = useState(initial); const [file, setFile] = useState(null); const inputRef = useRef(null); const fileRef = useRef(null); diff --git a/frontend/src/components/exportDialog.tsx b/frontend/src/components/exportDialog.tsx index 40af6cc..f6349bc 100644 --- a/frontend/src/components/exportDialog.tsx +++ b/frontend/src/components/exportDialog.tsx @@ -15,17 +15,8 @@ import { FileDown, Filter } from "lucide-react"; import { invoiceApi } from "@/lib/api/client"; import { toast } from "@/lib/toast"; -interface ExportDialogProps { - variant?: "default" | "secondary"; - size?: "default" | "sm" | "lg" | "icon"; - className?: string; -} - -export function ExportDialog({ - variant = "default", - size = "sm", - className = "", -}: ExportDialogProps) { +// Opened from a quiet round icon button (Phase 5 pages: no loud blue actions). +export function ExportDialog() { const [open, setOpen] = useState(false); const [format, setFormat] = useState<"csv" | "json">("csv"); const [exporting, setExporting] = useState(false); @@ -91,14 +82,13 @@ export function ExportDialog({ return ( - - + - + Export Invoices @@ -107,13 +97,13 @@ export function ExportDialog({
{/* Format Selection */}
-