From 7bc006f03cdbe2db28ad9c7e5f6ea89cc0ffa863 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:19:24 +0530 Subject: [PATCH 01/47] fix: owner feedback on the revamp shell, labels and Ask chips - Sidebar: every group is labelled (Overview, Track, Records, Assistant); the collapse button moves into the sidebar header (on the rail the logo becomes the expand button); no top bar on desktop, the account menu floats top-right; phones keep a slim row. - Labels: Funnel Sans, sentence case (`.label-ui`) instead of all-caps IBM Plex Mono; Plex Mono is no longer loaded. Ask's labels match. - Ask: an "Upload ..." suggestion opens the upload dialog instead of being sent to Lumen as a question; other do-it-yourself steps ("Connect your Gmail...") stay in the answer text instead of becoming a chip; the prompt says the same. - QUICK_START and start.ps1 start the API with uvicorn (python app.py only starts the old Flask half, so /demo failed). --- QUICK_START.md | 2 +- backend/agent/graph.py | 6 ++- backend/agent/prompts.py | 2 +- backend/start.ps1 | 5 ++- backend/tests/test_agent_tools.py | 7 +++ docs/CODEBASE-GUIDE.md | 8 ++-- frontend/e2e/ask.spec.ts | 19 ++++++++ frontend/e2e/fixtures.ts | 4 +- frontend/e2e/shell.spec.ts | 19 +++++++- frontend/src/app/(app)/ask/chatbotContent.tsx | 4 +- frontend/src/app/(app)/ask/thread-rail.tsx | 2 +- .../(app)/home/_components/activity-card.tsx | 2 +- .../app/(app)/home/_components/bills-card.tsx | 2 +- .../(app)/home/_components/budget-card.tsx | 2 +- .../(app)/home/_components/calendar-card.tsx | 2 +- .../src/app/(app)/home/_components/card.tsx | 2 +- .../(app)/home/_components/lumen-found.tsx | 5 ++- .../app/(app)/home/_components/spend-card.tsx | 8 ++-- frontend/src/app/(app)/home/homeContent.tsx | 3 +- .../src/app/(app)/receipts/receipts-tabs.tsx | 2 +- frontend/src/app/beautiful-ui.css | 2 +- frontend/src/app/globals.css | 12 ++--- frontend/src/app/layout.tsx | 11 +---- frontend/src/components/app-shell.tsx | 17 ++++--- frontend/src/components/app-sidebar.tsx | 44 +++++++++++++------ frontend/src/components/ask-palette.tsx | 2 +- frontend/src/components/ask/answer.tsx | 25 +++++++---- 27 files changed, 148 insertions(+), 71 deletions(-) diff --git a/QUICK_START.md b/QUICK_START.md index 5ae047f..15247cb 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -70,7 +70,7 @@ alembic upgrade head ``` ```bash -python app.py +python -m uvicorn asgi:app --port 5000 ``` API at http://localhost:5000 diff --git a/backend/agent/graph.py b/backend/agent/graph.py index 518c2b5..a4ea8f2 100644 --- a/backend/agent/graph.py +++ b/backend/agent/graph.py @@ -53,6 +53,10 @@ class AgentState(TypedDict): _ASKS_USER = re.compile(r"(would you|do you (want|need|wish)|shall i|want me|would it help|is there anything|" r"anything else|let me know|how about i|you could|you can|tell me (which|if|whether)|if you('d| would) like|" r"feel free)\b", re.IGNORECASE) +# A step the user does themselves ("Connect your Gmail…") isn't a request to Lumen either: as a chip it was sent to the +# chat (owner report, 2026-10-22). Uploads are the exception: the UI turns an "Upload…" chip into the upload button. +_USER_STEP = re.compile(r"(connect|go to|log ?in|sign ?in|visit|click|tap|open (the|your)|check your|forward)\b", + re.IGNORECASE) def split_follow_up(text: str) -> tuple[str, str | None]: @@ -65,7 +69,7 @@ def split_follow_up(text: str) -> tuple[str, str | None]: answer, follow_up = (text[:m.start()] + text[m.end():]).strip(), m.group(1).strip(" \t*_\"'“”‘’") if not follow_up: # a bare "Next:" return answer, None - if _ASKS_USER.match(follow_up): + if _ASKS_USER.match(follow_up) or _USER_STEP.match(follow_up): return f"{answer}\n\n{follow_up}", None return answer, follow_up diff --git a/backend/agent/prompts.py b/backend/agent/prompts.py index 975fe71..9bef0bb 100644 --- a/backend/agent/prompts.py +++ b/backend/agent/prompts.py @@ -51,7 +51,7 @@ - Write naturally, like a knowledgeable friend: lead with the answer, then a sentence or two of useful context. Use a short list or table only when it helps. - If you need to ask the user something, ask it in the answer itself. Then end with one short request the user - might send next, in their own words (e.g. "Show last month by category"), never a question to them, on its own + might send next, in their own words (e.g. "Show last month by category"), never a question to them or a step they must do in the app, on its own last line, starting "Next: ".""" FORCE_ANSWER = ("You have used the maximum number of tool calls. Answer now from the information above, " diff --git a/backend/start.ps1 b/backend/start.ps1 index e6ad7b8..1d37e6b 100644 --- a/backend/start.ps1 +++ b/backend/start.ps1 @@ -14,5 +14,6 @@ if (-not (Test-Path .env)) { } # Start Flask application -Write-Host "Starting Flask server on http://localhost:5000" -ForegroundColor Cyan -python app.py +Write-Host "Starting the API (uvicorn) on http://localhost:5000" -ForegroundColor Cyan +# uvicorn runs the whole API; `python app.py` would start only the old Flask half (no /api/demo, agent or documents) +python -m uvicorn asgi:app --port 5000 diff --git a/backend/tests/test_agent_tools.py b/backend/tests/test_agent_tools.py index 69a2cd3..f294256 100644 --- a/backend/tests/test_agent_tools.py +++ b/backend/tests/test_agent_tools.py @@ -247,6 +247,13 @@ def test_split_follow_up(): "If you'd like a breakdown, ask", "You can ask for last month"): assert split_follow_up(f"Done.\nNext: {lumen}")[1] is None assert split_follow_up("Done.\nNext: Tell me my top vendors")[1] == "Tell me my top vendors" + # owner report 2026-10-22: a step for the user is not a question for Lumen; clicking it sent it to the chat. + # Uploads stay a chip (the UI turns it into an "upload" button); other do-it-yourself steps stay in the answer. + assert split_follow_up("Empty.\nNext: Upload an invoice or receipt to get started.")[1] == \ + "Upload an invoice or receipt to get started." + for step in ("Connect your Gmail in Settings", "Go to Receipts and import a statement", "Log in to your bank app", + "Visit your bank's website", "Click Draft budget on Home"): + assert split_follow_up(f"Done.\nNext: {step}") == (f"Done.\n\n{step}", None) assert split_follow_up("Here it is.\nNext: Would you like last month or the last three?") == ( "Here it is.\n\nWould you like last month or the last three?", None) assert split_follow_up("Done.\nNext: Can I afford a trip in December?") == ( diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index cdc170c..acc3d61 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -262,8 +262,10 @@ embeds the demo documents once per server and uploads the files in the backgroun in the `sidebar_state` cookie. The layout reads that cookie on the sidebar's first render, which is always client-side because the auth guard shows a loader on the server, so there's no jump and no server read. Shortcuts skip key presses in text fields (`isTypingTarget` in `lib/utils.ts`). **Account** (Settings, API keys, Log out) is the - top-right `components/avatar-menu.tsx`. - `app/(app)/layout.tsx` holds the auth guard, sidebar and top bar; they mount once and stay while you move between + top-right `components/avatar-menu.tsx`, floating over the page (there is no top bar on desktop; phones keep a slim + row with the sheet button). The collapse button lives in the sidebar header; on the 56px rail the logo turns into it + on hover. Every sidebar group is labelled (Overview, Track, Records, Assistant). + `app/(app)/layout.tsx` holds the auth guard and sidebar; they mount once and stay while you move between pages, so only the content area swaps. `app/(app)/template.tsx` fades each page's content in (`.app-page-in` in `globals.css`). `components/dashboard-shell.tsx` is just a page's content wrapper and optional header card. - There is no root `loading.tsx`: the pages are static and prefetched, and a full-screen spinner made every click @@ -273,7 +275,7 @@ embeds the demo documents once per server and uploads the files in the backgroun swaps every shadcn colour token to the light Sky palette, so portaled menus and dialogs match too. The `dark:` variant is also switched off while the shell is mounted. Public pages keep the forced dark theme. Extra tokens: `good`, `bad`, `accent-soft`, `--band`. Type roles: `font-serif` (Newsreader), `font-label` / - `.label-mono` (IBM Plex Mono labels). + `.label-ui` (card titles, tags, ticks: Funnel Sans, sentence case; the owner ruled out all-caps mono labels). - **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 diff --git a/frontend/e2e/ask.spec.ts b/frontend/e2e/ask.spec.ts index 3aa7b42..a81ecd9 100644 --- a/frontend/e2e/ask.spec.ts +++ b/frontend/e2e/ask.spec.ts @@ -163,3 +163,22 @@ test("the send button looks different when it can send", async ({ page }) => { const lum = (c: number[]) => (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * (c[3] / 255) + 244 * (1 - c[3] / 255); expect(Math.abs(lum(on) - lum(off))).toBeGreaterThan(80); // a dark filled button vs a faint one on the light page }); + +test("an upload suggestion opens the upload dialog instead of asking Lumen", async ({ page }) => { + await openAsk(page, { followUp: "Upload an invoice or receipt to get started." }); + await composer(page).fill("Do I have invoices?"); + await composer(page).press("Enter"); + const chip = page.getByRole("link", { name: "Upload an invoice or receipt to get started" }); + await expect(chip).toHaveAttribute("href", "/home?upload=1"); + await chip.click(); + await expect(page).toHaveURL(/\/home\?upload=1$/); + await expect(page.getByRole("dialog")).toBeVisible(); +}); + +test("a question suggestion is still sent to Lumen", async ({ page }) => { + await openAsk(page, { followUp: "Show last month by category" }); + await composer(page).fill("How much did I spend?"); + await composer(page).press("Enter"); + await page.getByRole("button", { name: "Show last month by category" }).click(); + await expect(page.getByText("Answer to: Show last month by category")).toBeVisible(); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 6f5d5c1..64868a8 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -27,6 +27,8 @@ export type ChatFixture = { threads?: { id: string; title: string; updated_at: string; message_count: number; messages?: { role: string; content: string }[] }[]; /** the answer text POST /chat returns (default "Answer to: ") */ answer?: string; + /** the "Next:" suggestion POST /chat returns (default none) */ + followUp?: string; suggestions?: string[]; }; @@ -139,7 +141,7 @@ export async function signIn( const response = opts.chat?.answer ?? `Answer to: ${body.query}`; t.messages.push({ role: "user", content: body.query }, { role: "assistant", content: response }); return route.fulfill({ - json: { success: true, data: { response, follow_up: null, sources: [], steps: [], proposals: [], stopped: "answer", thread_id: t.id } }, + json: { success: true, data: { response, follow_up: opts.chat?.followUp ?? null, sources: [], steps: [], proposals: [], stopped: "answer", thread_id: t.id } }, }); } if (path === "/api/profile") diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 53a6963..8bc6b4d 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -62,7 +62,10 @@ test("sidebar shows the new IA", async ({ page }) => { await page.goto("/home"); const links = sidebar(page).locator('[data-sidebar="menu-button"]'); await expect(links).toHaveText(["Home", "Spending", "Bills & subscriptions", "Receipts & statements", "Ask Lumen", /^Ask anything/]); - await expect(sidebar(page).getByText("Track", { exact: true })).toBeVisible(); + // every group is labelled (owner, 2026-10-22) + for (const group of ["Overview", "Track", "Records", "Assistant"]) { + await expect(sidebar(page).getByText(group, { exact: true })).toBeVisible(); + } for (const old of ["Workspace", "Compare", "Financial command center"]) { await expect(page.getByText(old, { exact: true })).toHaveCount(0); } @@ -85,6 +88,20 @@ test("ctrl+b collapses to a 56px rail with tooltips", async ({ page }) => { await expect(page.getByRole("tooltip")).toHaveText("Spending"); }); +test("the collapse control sits in the sidebar header, with no top bar on desktop", async ({ page }) => { + await signIn(page); + await page.goto("/home"); + const toggle = sidebar(page).getByRole("button", { name: "Toggle sidebar (Ctrl+B)" }); + await toggle.click(); + await expect.poll(() => railWidth(page)).toBeCloseTo(56, 0); + // on the rail the logo turns into the expand button on hover + await sidebar(page).locator('[data-sidebar="header"]').hover(); + await toggle.click(); + await expect.poll(() => railWidth(page)).toBeGreaterThan(200); + await expect(page.locator("#main > header")).toBeHidden(); + await expect(page.getByRole("button", { name: "Account menu" })).toBeVisible(); +}); + test("collapsed state survives reload without a jump", async ({ page }) => { // record the sidebar's data-state the moment it first enters the DOM (the auth guard renders it client-side) await page.addInitScript(() => { diff --git a/frontend/src/app/(app)/ask/chatbotContent.tsx b/frontend/src/app/(app)/ask/chatbotContent.tsx index 55bc560..e81baf7 100644 --- a/frontend/src/app/(app)/ask/chatbotContent.tsx +++ b/frontend/src/app/(app)/ask/chatbotContent.tsx @@ -183,7 +183,7 @@ export default function ChatbotContent() { return ( -
+
@@ -194,7 +194,7 @@ export default function ChatbotContent() {
-
+
diff --git a/frontend/src/app/(app)/ask/thread-rail.tsx b/frontend/src/app/(app)/ask/thread-rail.tsx index 4352439..b2f14bb 100644 --- a/frontend/src/app/(app)/ask/thread-rail.tsx +++ b/frontend/src/app/(app)/ask/thread-rail.tsx @@ -35,7 +35,7 @@ export function ThreadRail({ > New chat -
Recent
+
Recent
    {threads.length === 0 &&
  • No past chats yet.
  • } {threads.map((t) => ( diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx index c6fb675..5287f27 100644 --- a/frontend/src/app/(app)/home/_components/activity-card.tsx +++ b/frontend/src/app/(app)/home/_components/activity-card.tsx @@ -21,7 +21,7 @@ export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; n items.map((a, i) => (
    {fill(a.text, a.amounts, money)} - {relativeTime(a.at, now)} + {relativeTime(a.at, now)}
    )) )} diff --git a/frontend/src/app/(app)/home/_components/bills-card.tsx b/frontend/src/app/(app)/home/_components/bills-card.tsx index 6e0192c..214bbd1 100644 --- a/frontend/src/app/(app)/home/_components/bills-card.tsx +++ b/frontend/src/app/(app)/home/_components/bills-card.tsx @@ -19,7 +19,7 @@ export function BillsCard({ reminders, today, loading }: { reminders: Reminder[] const d = parseDay(r.date); return (
    - + {d.toLocaleString("en-GB", { weekday: "short" })} {String(d.getDate()).padStart(2, "0")} diff --git a/frontend/src/app/(app)/home/_components/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx index 777b64b..9e0737f 100644 --- a/frontend/src/app/(app)/home/_components/budget-card.tsx +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -111,7 +111,7 @@ export function DraftBudgetPromo({ onChanged }: { onChanged: () => void }) { type="button" onClick={() => void draft()} disabled={busy} - className="label-mono mt-2.5 rounded-full bg-foreground px-3.5 py-1.5 text-background transition-opacity disabled:opacity-60" + className="label-ui mt-2.5 rounded-full bg-foreground px-3.5 py-1.5 text-background transition-opacity disabled:opacity-60" > Draft budget diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index f4da6f4..8d66590 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -38,7 +38,7 @@ export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: P })}
    -
    Recent transactions
    +
    Recent transactions
    {recent.length === 0 ? (

    No transactions this month yet.

    ) : ( diff --git a/frontend/src/app/(app)/home/_components/card.tsx b/frontend/src/app/(app)/home/_components/card.tsx index c8bd384..2e8692d 100644 --- a/frontend/src/app/(app)/home/_components/card.tsx +++ b/frontend/src/app/(app)/home/_components/card.tsx @@ -25,7 +25,7 @@ export function HomeCard({ }) { return (
    -
    +
    {href ? ( {title} › diff --git a/frontend/src/app/(app)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx index 8c1e987..a986f88 100644 --- a/frontend/src/app/(app)/home/_components/lumen-found.tsx +++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx @@ -25,11 +25,12 @@ export function LumenFound({ summary, loading }: { summary: HomeSummary | null;
    {found.map((f, i) => (
    -
    {f.tag}
    + {/* 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 →"} )} diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index e56885a..a10fbfa 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -109,7 +109,7 @@ export function SpendCard({ )}
    -
    +
    — now ┄ before {fc.length > 0 && · forecast} @@ -149,7 +149,7 @@ export function SpendCard({ const i = dayIndex(period, parseDay(p.date)); return ( - + {p.vendor} @@ -157,7 +157,7 @@ export function SpendCard({ ); })}
    -
    +
    {ticks.map((i) => ( {i === t ? "today" : tick(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i))} @@ -174,7 +174,7 @@ export function SpendCard({ aria-pressed={r === range} onClick={() => onRange(r)} className={cn( - "label-mono rounded-md px-2.5 py-1 transition-colors", + "label-ui rounded-md px-2.5 py-1 transition-colors", r === range ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground", )} > diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index 0d98a48..ca06636 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -68,7 +68,8 @@ 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"}

    diff --git a/frontend/src/app/(app)/receipts/receipts-tabs.tsx b/frontend/src/app/(app)/receipts/receipts-tabs.tsx index 783edcb..694ea98 100644 --- a/frontend/src/app/(app)/receipts/receipts-tabs.tsx +++ b/frontend/src/app/(app)/receipts/receipts-tabs.tsx @@ -26,7 +26,7 @@ export function ReceiptsTabs() { href={`?tab=${t.id}`} aria-current={t.id === active.id ? "page" : undefined} className={cn( - "label-mono border-b-2 pb-2 transition-colors", + "label-ui border-b-2 pb-2 transition-colors", t.id === active.id ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground", diff --git a/frontend/src/app/beautiful-ui.css b/frontend/src/app/beautiful-ui.css index a4b0385..9a4c71f 100644 --- a/frontend/src/app/beautiful-ui.css +++ b/frontend/src/app/beautiful-ui.css @@ -149,4 +149,4 @@ } /* uppercase mono micro-label (eyebrows, badges, data annotations) */ -.bui-label { font-family: var(--font-label); font-size: 11px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; } +.bui-label { font-family: var(--font-label); font-size: 12.5px; font-weight: 500; letter-spacing: 0.01em; } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a95d3f5..b0cbd07 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -122,7 +122,7 @@ --color-bad: var(--bad); --color-accent-soft: var(--accent-soft); --font-serif: var(--font-newsreader), Georgia, serif; - --font-label: var(--font-plex-mono), ui-monospace, monospace; + --font-label: var(--font-ui), system-ui, sans-serif; --font-sans: "Inter", "Inter Fallback", ui-sans-serif, system-ui, sans-serif; --font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, monospace; --font-script: "Dancing Script", cursive; @@ -232,11 +232,11 @@ animation: slide-out-to-right-full 0.2s ease-in; } - .label-mono { - font-family: var(--font-plex-mono), ui-monospace, monospace; - font-size: 10.5px; - letter-spacing: 0.12em; - text-transform: uppercase; + /* card titles, tags and axis ticks: the UI font, sentence case, quiet (owner: no all-caps mono labels) */ + .label-ui { + font-family: var(--font-ui), system-ui, sans-serif; + font-size: 12.5px; + letter-spacing: 0.01em; font-weight: 500; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 7e92c84..8630b62 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata, Viewport } from "next"; -import { IBM_Plex_Mono, Inter, JetBrains_Mono, Newsreader } from "next/font/google"; +import { Inter, JetBrains_Mono, Newsreader } from "next/font/google"; import localFont from "next/font/local"; import "./globals.css"; import { AuthProvider } from "@/components/auth/auth-provider"; @@ -26,13 +26,6 @@ const newsreader = Newsreader({ variable: "--font-newsreader", }); -// In-app card titles, tags and axis ticks (the `.label-mono` / `font-label` style). -const plexMono = IBM_Plex_Mono({ - subsets: ["latin"], - weight: ["400", "500"], - variable: "--font-plex-mono", -}); - // Funnel Sans (OFL, self-hosted from @fontsource-variable/funnel-sans): text, buttons and labels on the landing, // sign-in and onboarding pages, and the label style everywhere (it replaced Hanken Grotesk and Roboto Mono). const funnelSans = localFont({ @@ -141,7 +134,7 @@ export default function RootLayout({ {/* every signed-in page talks to these two right away */} diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx index f6d215d..9810ec2 100644 --- a/frontend/src/components/app-shell.tsx +++ b/frontend/src/components/app-shell.tsx @@ -22,15 +22,18 @@ export function AppShell({ children }: { children: ReactNode }) { state straight from the cookie gives the right width on the sidebar's first paint: no jump, no SSR read. */} - -
    -
    - -
    - -
    + + {/* 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. */} +
    + +
    +
    +
    + +
    {children}
    diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index c73ff27..2329ff9 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -17,7 +17,7 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarRail, - SidebarSeparator, + SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; import { AskPalette } from "@/components/ask-palette"; @@ -27,8 +27,8 @@ import { isTypingTarget } from "@/lib/utils"; type Item = { label: string; url: string; icon: LucideIcon }; -// Spec section 3. Group labels say what you do, not what the backend is. Budget and Forecast (PLAN) join once -// their pages exist. +// Spec section 3. Every group is labelled (owner, 2026-10-22); labels say what you do, not what the backend is. +// Budget and Forecast (Plan) join once their pages exist. const HOME: Item = { label: "Home", url: "/home", icon: House }; const TRACK: Item[] = [ { label: "Spending", url: "/spending", icon: ChartLine }, @@ -83,34 +83,52 @@ export function AppSidebar(props: React.ComponentProps) { return ( - - - - - Lumen - - + {/* the collapse control lives here, not in a top bar (owner, 2026-10-22). On the icon rail the logo turns into + the expand button on hover, so the rail stays 56px wide. */} + +
    + + + + Lumen + + + +
    + Overview - Track + Track {TRACK.map((item) => ( ))} - + Records {toReview > 0 && {toReview}} + + + + Assistant + @@ -126,7 +144,7 @@ export function AppSidebar(props: React.ComponentProps) { Ask anything {/* fixed width: the label swaps to ⌘K on a Mac after mount without moving anything */} - + {mac ? "⌘K" : "Ctrl K"} diff --git a/frontend/src/components/ask-palette.tsx b/frontend/src/components/ask-palette.tsx index 42f529a..358e2c0 100644 --- a/frontend/src/components/ask-palette.tsx +++ b/frontend/src/components/ask-palette.tsx @@ -42,7 +42,7 @@ export function AskPalette({ open, onOpenChange }: { open: boolean; onOpenChange className="h-11 w-full bg-transparent text-[16px] outline-none placeholder:text-muted-foreground" /> - + Enter to ask · Esc to close diff --git a/frontend/src/components/ask/answer.tsx b/frontend/src/components/ask/answer.tsx index 6229d52..07513d8 100644 --- a/frontend/src/components/ask/answer.tsx +++ b/frontend/src/components/ask/answer.tsx @@ -3,17 +3,25 @@ // An Ask Lumen answer: markdown with inline citation chips, an action row and the cited passages. // Visuals adapted from Beautiful UI's Streaming Text and Context Cards (beautifului.dev, MIT). import { useState, type ReactNode } from "react"; +import Link from "next/link"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { ArrowUpRight, Check, Copy, FileText, Globe, Info, RotateCcw } from "lucide-react"; +import { ArrowUpRight, Check, Copy, FileText, Globe, Info, RotateCcw, Upload } from "lucide-react"; import { filesApi, type ChatAttachment, type ChatSource } from "@/lib/api/client"; import { InvoiceCard } from "./invoice-card"; import { toast } from "@/lib/toast"; import { cn } from "@/lib/utils"; +const CHIP = + "self-start rounded-full border border-line px-3.5 py-1.5 text-left text-[13px] text-ink-2 transition-colors duration-200 hover:border-line-strong hover:bg-hover hover:text-ink"; + // Same shape the agent cites with (backend agent/graph.py _CITATION). const CITATION = /[[【]\s*([^[\]【】\s]+#s\d{2}(?:-\d+)?|web:\d+)\s*[\]】]/g; const CITE_PREFIX = "#cite-"; +// A suggested next step that is an upload is something you do, not something to ask: the chip opens the upload dialog +// instead of sending the sentence to Lumen (owner report, 2026-10-22). Other do-it-yourself steps never become chips +// (backend agent/graph.py _USER_STEP). +const UPLOAD_STEP = /^(upload|import|add|attach|scan)\b.*\b(receipt|invoice|statement|document|bill|file)s?\b/i; function openOriginal(key: string) { filesApi.open(key).catch(() => toast.error("Couldn't open the original document.")); @@ -166,15 +174,16 @@ export function Answer({ {children} - {followUp && onFollowUp && ( - - )} + ))}
    `) and `open`/`onOpenChange` passthrough so the hero `+` (Task 3) and `?upload=1` open the same dialog. `ExportDialog` default trigger = `Button variant="quiet" size="pill-icon" aria-label="Export"` with a download icon. + +- [ ] **Step 1: Write failing tests**: `upload dialog is light` — open it; computed `background-color` of `[role="dialog"]` has luminance > 0.9 (parse rgb); no element inside has class matching `neutral-9|#0b0612`. `Home buttons are quiet` — the Upload button's computed background is not `rgb(37, 99, 235)` (blue-600). +- [ ] **Step 2: Run** — Expected: FAIL. +- [ ] **Step 3: Implement**: dialog on Sky tokens (`bg-card`, `border-border`, `text-foreground/muted-foreground`), drop zone `border-dashed border-border bg-muted/40`, file rows `bg-muted/50`; keep all upload logic untouched. +- [ ] **Step 4: Run** — Expected: PASS. +- [ ] **Step 5: Commit** `feat: quiet Upload/Export and a light upload dialog`. + +### Task 8: Ask and chat §12 fixes + +**Files:** +- Modify: `frontend/src/app/(app)/ask/thread-rail.tsx`, `frontend/src/components/app-sidebar.tsx` (render `AskPalette` outside ``; lift `paletteOpen` into `app-shell.tsx` or a tiny context), `backend/routes/chat.py` (`_thread_for` + `_save_exchange` in one commit), `backend/api/agent.py` (409 text) +- Test: `frontend/e2e/ask.spec.ts`, `frontend/e2e/shell.spec.ts`, `backend/tests/test_chat_threads.py` (or the existing chat test module), `backend/tests/test_agent_api.py` (existing proposals tests) + +**Interfaces:** +- Backend: `_thread_for(user_id, thread_id, question)` no longer commits; the `/chat` handler commits once after `_save_exchange`. On a lost claim, both 409s say `"This proposal was already decided."`. + +- [ ] **Step 1: Write failing tests**: + - e2e ask: `rail actions wait for the answer` — with a delayed `/chat` route, while pending the thread's Delete button is disabled; opening the delete confirm moves focus to "Keep"; at 375px with the history sheet open the trash button is visible without hover. + - e2e shell: `ctrl+k works on a tablet with the sheet closed` — viewport 768×1024 (below the md sheet breakpoint if 768 is still sheet; else 767), press Control+k → palette dialog visible. + - backend: `test_chat_save_failure_leaves_no_empty_thread` — monkeypatch the message save to raise; after the failed POST, the user has 0 threads. + - backend: `test_lost_claim_says_already_decided` — approve twice concurrently (or claim then approve) → 409 with detail `"This proposal was already decided."`. +- [ ] **Step 2: Run** them — Expected: FAIL. +- [ ] **Step 3: Implement** the four fixes. +- [ ] **Step 4: Run** `e2e/ask.spec.ts e2e/shell.spec.ts` and backend `pytest -q` — Expected: PASS. +- [ ] **Step 5: Commit** `fix: Ask rail busy/focus/touch, palette outside the sheet, one-commit chat save, clear 409`. + +### Task 9: 5a docs, full checks, PR + +- [ ] **Step 1:** STORY.md entry `## 32.` (owner review round + Home v2: what was flagged, what changed, test counts), CODEBASE-GUIDE: `components/page/*`, `lib/chart.ts`, `/ask?draft=`; spec §12: tick the Phase 5 items done in 5a. +- [ ] **Step 2:** Run backend `pytest -q`, `pytest -q -m eval`; frontend `npm run lint`, `npm run build` (dummy env), `npm run test:e2e`. Expected: all green. +- [ ] **Step 3:** Screenshot `/home` at 1440 and 375 with seeded fixture data (throwaway `e2e/tmp/shot.spec.ts`) and compare with `home.html` side by side; fix visible drift. +- [ ] **Step 4:** Push `ui-revamp/phase5-pages`, open PR into `refactor` (or stacked on `ui-revamp/polish` until #96 merges) with manual checks: Home matches the mockup on your seeded account; hero `+` and Upload open the same light dialog; "Ask why" fills Ask without sending; phone width has no overlapping ticks. + +--- + +## Phase 5b — Spending (outline; detail when reached) +Backend: none expected (transactions, categories, `/api/home/summary` findings exist); confirm category-update endpoint for the hover edit. Tasks: period control + compare toggle (layout-shift test); hero (cumulative + month bars inset; 3M/YTD bars as main chart); By category bars with click-to-filter (`?category=`); Lumen noticed (summary findings, no "first time" card); Transactions table (search, filters, hover category edit, `vendorChip`), Top merchants; Export CSV of the filtered view; remove the stacked old pages (single `

    `, no calendar/reminders). STORY 33. + +## Phase 5c — Bills & subscriptions (outline) +Backend (TDD): `RecurringState` table (`user_id`, `vendor_key`, `state`, `changed_at`); `GET /api/bills/recurring` (wraps `find_recurring`, applies states: `not_a_bill` hidden, `cancelled` dimmed unless charged after `changed_at`); `POST /api/bills/recurring/state {vendor, state}`; Home's Coming up switches to it. Frontend: tiles, month calendar + Upcoming agenda (This week / Next week / Later), subscriptions table with tabs and actions (Mark as cancelled, Not a bill + Hidden undo, How to cancel → `/ask?draft=How do I cancel ?`). STORY 34. + +## Phase 5d — Budget page (outline) +Backend (TDD): `Budget.effective_from` (migration + create_all column list; existing rows → created month); `get_budget(month)` = latest row per category with `effective_from <= month`; `GET /api/budget?month=YYYY-MM`, `GET /api/budget/history?months=6`; edits write rows effective from the current month; draft approval writes rows effective from the draft's month; `basis.months` only months with spend; draft buttons' accessible names include the category. Frontend: `/budget` page + sidebar Plan group; hero with even-pace tick and pace chart; category table with inline edit (no row height change); draft panel on the page (Home promo links here); last 6 months bars. STORY 35. + +## Phase 5e — Receipts & statements (outline) +Backend (TDD): `StatementImport` table + write on import; `GET /api/statements/imports`. Frontend: header actions; Needs-your-review cards from the review queue with highlights from validation flags (replaces the Review tab); library list with filter chips (Statements = import rows) and search; Statement imports card; From your inbox card; Activity moves here from Home if it no longer fits. Old `/documents`, `/import`, `/review` routes redirect into `/receipts`. STORY 36, then the whole-branch review for Phase 5. From ff30d4ffa3c43bec1336bc7b827fba33dba9a461 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:46:34 +0530 Subject: [PATCH 04/47] feat: shared page header and card for the Phase 5 pages --- frontend/e2e/home.spec.ts | 16 ++++++++++++ .../(app)/home/_components/activity-card.tsx | 6 ++--- .../app/(app)/home/_components/bills-card.tsx | 6 ++--- .../src/app/(app)/home/_components/brief.tsx | 6 ++--- .../(app)/home/_components/budget-card.tsx | 6 ++--- .../(app)/home/_components/calendar-card.tsx | 6 ++--- .../(app)/home/_components/lumen-found.tsx | 6 ++--- .../app/(app)/home/_components/spend-card.tsx | 6 ++--- frontend/src/app/(app)/home/homeContent.tsx | 24 ++++++++--------- .../page/page-card.tsx} | 25 +++++++++++++----- frontend/src/components/page/page-header.tsx | 26 +++++++++++++++++++ frontend/src/components/ui/button.tsx | 4 +++ 12 files changed, 96 insertions(+), 41 deletions(-) rename frontend/src/{app/(app)/home/_components/card.tsx => components/page/page-card.tsx} (52%) create mode 100644 frontend/src/components/page/page-header.tsx diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 9caaceb..308af4f 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -333,3 +333,19 @@ test("a replaced draft says so", async ({ page }) => { await page.waitForTimeout(300); expect(approvals).toBe(1); }); + +const overlaps = (a: { x: number; y: number; width: number; height: number }, b: typeof a) => + a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; + +test("home header keeps clear of the account menu", async ({ page }) => { + for (const [width, height] of [[1440, 900], [1024, 800]]) { + await page.setViewportSize({ width, height }); + await openHome(page); + await expect(page.getByRole("heading", { level: 1 })).toHaveCSS("font-size", "26px"); + const menu = (await page.getByRole("button", { name: "Account menu" }).boundingBox())!; + for (const name of [/upload/i, /^export$/i]) { + const box = (await page.getByRole("button", { name }).first().boundingBox())!; + expect(overlaps(menu, box), `${String(name)} at ${width}px`).toBe(false); + } + } +}); diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx index 5287f27..7475e7a 100644 --- a/frontend/src/app/(app)/home/_components/activity-card.tsx +++ b/frontend/src/app/(app)/home/_components/activity-card.tsx @@ -2,13 +2,13 @@ import Link from "next/link"; import type { ActivityItem } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { fill, relativeTime } from "@/lib/home-math"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; // What happened lately: statement imports, receipts read, your decisions (GET /api/home/summary). export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; now: Date; loading: boolean }) { const money = useMoney(); return ( - + {items.length === 0 ? (

    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..1989478 100644 --- a/frontend/src/app/(app)/home/_components/bills-card.tsx +++ b/frontend/src/app/(app)/home/_components/bills-card.tsx @@ -1,7 +1,7 @@ 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. export function BillsCard({ reminders, today, loading }: { reminders: Reminder[]; today: Date; loading: boolean }) { @@ -11,7 +11,7 @@ export function BillsCard({ reminders, today, loading }: { reminders: Reminder[] .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 3); return ( - + {next.length === 0 ? (

    No bills coming up in the next month.

    ) : ( @@ -29,6 +29,6 @@ export function BillsCard({ reminders, today, loading }: { reminders: Reminder[] ); }) )} -
    + ); } 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..4d3b9d0 100644 --- a/frontend/src/app/(app)/home/_components/budget-card.tsx +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -6,7 +6,7 @@ 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. @@ -18,7 +18,7 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg const daysLeft = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() - today.getDate() + 1; return ( - + {total ? ( <>
    @@ -51,7 +51,7 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg ))}
    )} -
    + ); } diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index 8d66590..2324b7e 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -1,6 +1,6 @@ import { useMoney } from "@/lib/format"; import { calendarBuckets, heat, parseDay, type Period, type Txn } from "@/lib/home-math"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; // "₹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)}`); @@ -16,7 +16,7 @@ export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: P const recent = [...txns].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 5); return ( - +
    {Array.from({ length: lead }, (_, i) => ( @@ -56,6 +56,6 @@ export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: P )}
    -
    + ); } diff --git a/frontend/src/app/(app)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx index a986f88..e528ed2 100644 --- a/frontend/src/app/(app)/home/_components/lumen-found.tsx +++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import type { 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"; // "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,7 +11,7 @@ export function LumenFound({ summary, loading }: { summary: HomeSummary | null; const found = summary?.found ?? []; const total = found.length + (summary?.more ?? 0); return ( - + {found.length === 0 ? (

    Nothing needs you right now.

    ) : ( @@ -39,6 +39,6 @@ export function LumenFound({ summary, loading }: { summary: HomeSummary | null;
    )} - + ); } diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index a10fbfa..b8bbe03 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -6,7 +6,7 @@ import { useMoney } from "@/lib/format"; import { dayIndex, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math"; import { cn } from "@/lib/utils"; import type { Forecast, Reminder } from "../use-home-data"; -import { HomeCard } from "./card"; +import { PageCard } from "@/components/page/page-card"; const RANGES: Range[] = ["1W", "1M", "3M", "YTD"]; const TITLE: Record = { "1W": "Spent this week", "1M": "Spent this month", "3M": "Spent this quarter", YTD: "Spent this year" }; @@ -95,7 +95,7 @@ export function SpendCard({ range === "1M" ? `vs ${short(previous.start)} at this point` : range === "YTD" ? `vs ${previous.start.getFullYear()} at this point` : "vs last period at this point"; return ( - +
    @@ -182,6 +182,6 @@ export function SpendCard({ ))}
    - + ); } diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index ca06636..52ddc4e 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -8,6 +8,7 @@ import { ExportDialog } from "@/components/exportDialog"; import type { FoundItem } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { briefSentence, cumulative, dayIndex, fill, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math"; +import { PageHeader } from "@/components/page/page-header"; import { Brief } from "./_components/brief"; import { ActivityCard } from "./_components/activity-card"; import { BillsCard } from "./_components/bills-card"; @@ -68,19 +69,16 @@ export default function HomeContent() {
    - {/* md:pr-12 leaves room for the floating account menu (app-shell.tsx) */} -
    -

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

    -
    - - -
    -
    -
    - -
    + } + actions={ + <> + + + + } + />
    -
    +
    +
    {href ? ( - {title} › + {title} › ) : (

    {title}

    )} + {extra} {action &&
    {action}
    }
    {loading ? :
    {children}
    } diff --git a/frontend/src/components/page/page-header.tsx b/frontend/src/components/page/page-header.tsx new file mode 100644 index 0000000..38a4480 --- /dev/null +++ b/frontend/src/components/page/page-header.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +// Every Phase 5 page opens the same way (spec 2026-10-22 §3): title and quiet actions on one row, the serif brief +// under it, then an optional control row. md:pr-12 leaves room for the floating account menu (app-shell.tsx). +export function PageHeader({ + title, + brief, + actions, + children, +}: { + title: ReactNode; + brief?: ReactNode; + actions?: ReactNode; + children?: ReactNode; +}) { + return ( +
    +
    +

    {title}

    + {actions &&
    {actions}
    } +
    + {brief &&
    {brief}
    } + {children &&
    {children}
    } +
    + ); +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 24e29f1..0968e6d 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -18,6 +18,9 @@ const buttonVariants = cva( "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", + // Phase 5 pages: light hairline actions, and one dark primary per page (no loud blue) + quiet: "border border-border bg-card/70 text-foreground/80 backdrop-blur hover:bg-card hover:text-foreground", + ink: "bg-foreground text-background hover:bg-foreground/90", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", @@ -26,6 +29,7 @@ const buttonVariants = cva( icon: "size-9", "icon-sm": "size-8", "icon-lg": "size-10", + "pill-icon": "size-[34px] rounded-full", }, }, defaultVariants: { From ce1416e53516d62de33cf025bcf257c58233360a Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:50:37 +0530 Subject: [PATCH 05/47] feat: smooth monotone chart path and tick picking --- frontend/e2e/home-math.spec.ts | 40 ++++++++++++++++++++++++++++ frontend/src/lib/chart.ts | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 frontend/src/lib/chart.ts diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts index 00fec18..c0a3979 100644 --- a/frontend/e2e/home-math.spec.ts +++ b/frontend/e2e/home-math.spec.ts @@ -1,5 +1,6 @@ // Node-only unit tests (no browser) for the Home calculations. import { test, expect } from "@playwright/test"; +import { pickTicks, smoothPath } from "../src/lib/chart"; import { briefSentence, fill, @@ -153,3 +154,42 @@ test("suggestedQuestions maps found items, dedupes, caps at 4", () => { ]); expect(suggestedQuestions([], ["A", "B"])).toEqual(["A", "B"]); }); + +// ---- lib/chart.ts (Phase 5) ---- + +// y of each cubic segment of a path "M x y C x1 y1 x2 y2 x y C …" sampled at t +function samples(d: string, ts = [0.25, 0.5, 0.75]): number[] { + const n = d.match(/-?\d+(\.\d+)?/g)!.map(Number); + let [x0, y0] = [n[0], n[1]]; + const out: number[] = []; + for (let i = 2; i + 5 < n.length + 1; i += 6) { + const [, c1, , c2, x, y] = n.slice(i, i + 6); + for (const t of ts) out.push((1 - t) ** 3 * y0 + 3 * (1 - t) ** 2 * t * c1 + 3 * (1 - t) * t ** 2 * c2 + t ** 3 * y); + [x0, y0] = [x, y]; + } + void x0; + return out; +} + +test("smoothPath passes through every point", () => { + const d = smoothPath([[0, 10], [10, 20], [20, 20], [30, 40]]); + expect(d.startsWith("M0 10")).toBe(true); + expect(d).toContain("20 20"); + expect(d.endsWith("30 40")).toBe(true); + expect(smoothPath([])).toBe(""); + expect(smoothPath([[5, 6]])).toBe("M5 6"); +}); + +test("smoothPath never overshoots a running total", () => { + const ys = samples(smoothPath([[0, 0], [10, 100], [20, 100], [30, 101]])); + for (let i = 0; i < ys.length; i++) { + expect(ys[i]).toBeLessThanOrEqual(101 + 1e-9); + if (i) expect(ys[i]).toBeGreaterThanOrEqual(ys[i - 1] - 1e-9); + } +}); + +test("pickTicks drops ticks that crowd a kept one", () => { + const t = (x: number, keep = false) => ({ i: x, x, label: String(x), keep }); + expect(pickTicks([t(0, true), t(20), t(40), t(300, true), t(310, true)], 48).map((k) => k.label)).toEqual(["0", "300", "310"]); + expect(pickTicks([t(0, true), t(60), t(120), t(200, true)], 48).map((k) => k.label)).toEqual(["0", "60", "120", "200"]); +}); diff --git a/frontend/src/lib/chart.ts b/frontend/src/lib/chart.ts new file mode 100644 index 0000000..77e59e9 --- /dev/null +++ b/frontend/src/lib/chart.ts @@ -0,0 +1,48 @@ +// Small chart helpers shared by the Phase 5 pages (spec 2026-10-22 §3). + +const n = (v: number) => +v.toFixed(2); + +// A smooth SVG path through every point: monotone cubic (Fritsch–Carlson), so a running total never dips or +// overshoots between two days the way a plain Catmull-Rom curve does. +export function smoothPath(points: [number, number][]): string { + if (!points.length) return ""; + const [x0, y0] = points[0]; + if (points.length === 1) return `M${n(x0)} ${n(y0)}`; + const last = points.length - 1; + const m = points.slice(0, last).map(([x, y], k) => (points[k + 1][1] - y) / (points[k + 1][0] - x || 1)); + const t = points.map((_, k) => (k === 0 ? m[0] : k === last ? m[last - 1] : m[k - 1] * m[k] <= 0 ? 0 : (m[k - 1] + m[k]) / 2)); + for (let k = 0; k < last; k++) { + if (m[k] === 0) { + t[k] = t[k + 1] = 0; + continue; + } + const a = t[k] / m[k]; + const b = t[k + 1] / m[k]; + const s = a * a + b * b; + if (s > 9) { + const tau = 3 / Math.sqrt(s); + t[k] = tau * a * m[k]; + t[k + 1] = tau * b * m[k]; + } + } + let d = `M${n(x0)} ${n(y0)}`; + for (let k = 0; k < last; k++) { + const [xa, ya] = points[k]; + const [xb, yb] = points[k + 1]; + const h = (xb - xa) / 3; + d += ` C${n(xa + h)} ${n(ya + t[k] * h)} ${n(xb - h)} ${n(yb - t[k + 1] * h)} ${n(xb)} ${n(yb)}`; + } + return d; +} + +// Axis labels that never overlap: `keep` ticks (first, today, last) always stay; any other tick is dropped when it +// sits closer than `minGap` px to a tick already shown. +export function pickTicks(candidates: T[], minGap: number): T[] { + const kept = candidates.filter((c) => c.keep); + const shown = [...kept]; + for (const c of candidates) { + if (c.keep) continue; + if (shown.every((s) => Math.abs(s.x - c.x) >= minGap)) shown.push(c); + } + return candidates.filter((c) => shown.includes(c)); +} From 877f4447394ad446d53cfbbfffe4794114d3e3f8 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:02:20 +0530 Subject: [PATCH 06/47] feat: Home hero card in the Origin style --- frontend/e2e/home.spec.ts | 39 ++- .../app/(app)/home/_components/spend-card.tsx | 249 ++++++++++++------ frontend/src/app/(app)/home/homeContent.tsx | 9 +- frontend/src/components/uploadDialog.tsx | 20 +- frontend/src/lib/home-math.ts | 3 + 5 files changed, 233 insertions(+), 87 deletions(-) diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 308af4f..e8d32e7 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -68,20 +68,23 @@ test("old dashboard is gone", async ({ page }) => { }); const spend = (page: Page) => page.getByTestId("spend-card"); -const prevShort = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "short" }); +const prevLong = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "long" }); +const monthLong = new Date(Y, M, 1).toLocaleString("en-US", { month: "long" }); +const ord = (n: number) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th")}`; const lastDay = new Date(Y, M + 1, 0).getDate(); test("spend card shows total and delta", async ({ page }) => { await openHome(page); await expect(spend(page).getByText("₹18,412", { exact: true })).toBeVisible(); - await expect(spend(page).getByTestId("spend-delta")).toHaveText(`↓ ₹1,868 vs ${prevShort} at this point`); + await expect(spend(page).getByTestId("spend-delta")).toHaveText(`−₹1,868 (9%) vs ${prevLong} by the ${ord(now.getDate())}`); }); test("range chips switch period", async ({ page }) => { await openHome(page); - await expect(spend(page)).toContainText("SPENT THIS MONTH", { ignoreCase: true }); + await expect(spend(page)).toContainText(`Spent in ${monthLong}`); + await expect(spend(page).getByRole("link", { name: new RegExp(`Spent in ${monthLong}`) })).toHaveAttribute("href", "/spending"); await spend(page).getByRole("button", { name: "1W" }).click(); - await expect(spend(page)).toContainText("SPENT THIS WEEK", { ignoreCase: true }); + await expect(spend(page)).toContainText("Spent this week"); await expect(spend(page).getByRole("button", { name: "1W" })).toHaveAttribute("aria-pressed", "true"); }); @@ -96,6 +99,9 @@ test("forecast draws to month end", async ({ page }) => { const dates = Array.from({ length: 31 }, (_, i) => ymd(Y, M, now.getDate() + 1 + i)); await openHome(page, { ...typical, forecast: { dates, values: dates.map(() => 100) } }); await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(1); + // 18,412 so far + 100 for each day left in the month + const left = lastDay - now.getDate(); + await expect(spend(page)).toContainText(`heading for ₹${(18412 + 100 * left).toLocaleString("en-IN")}`); }); test("bill pins show upcoming bills", async ({ page }) => { @@ -104,7 +110,8 @@ test("bill pins show upcoming bills", async ({ page }) => { ...typical, reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: thisMonth(now.getDate() + 1), type: "upcoming" }], }); - await expect(spend(page).getByTestId("bill-pin")).toHaveText("Netflix"); + await expect(spend(page).getByTestId("bill-pin")).toHaveAttribute("title", "Netflix"); + await expect(spend(page).getByTestId("bill-pin")).toHaveText("N"); }); test("new user sees empty states", async ({ page }) => { @@ -228,6 +235,7 @@ test("a forecast with no matching days draws no line", async ({ page }) => { await openHome(page, { ...typical, forecast: { dates: [lastMonth(2), lastMonth(3)], values: [500, 500] } }); await expect(spend(page).locator('[data-series="current"]')).toHaveCount(1); await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(0); + await expect(spend(page)).not.toContainText("heading for"); }); const dupe = { kind: "duplicate", tag: "DUPLICATES", headline: "Possible double charge: Croma {0}", detail: "Charged on 14 Oct and 15 Oct.", @@ -349,3 +357,24 @@ test("home header keeps clear of the account menu", async ({ page }) => { } } }); + +test("hero shows an increase with its percent", async ({ page }) => { + await openHome(page, { txns: [tx("a", "Croma", thisMonth(1), 30000), tx("b", "Rent", lastMonth(1), 20000)] }); + await expect(spend(page).getByTestId("spend-delta")).toHaveText(`+₹10,000 (50%) vs ${prevLong} by the ${ord(now.getDate())}`); +}); + +test("chart ticks never overlap at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openHome(page); + await expect(spend(page).getByTestId("tick").first()).toBeVisible(); + const boxes = await spend(page).getByTestId("tick").evaluateAll((els) => els.map((e) => e.getBoundingClientRect().toJSON())); + expect(boxes.length).toBeGreaterThanOrEqual(2); + for (let i = 0; i < boxes.length; i++) + for (let j = i + 1; j < boxes.length; j++) expect(overlaps(boxes[i], boxes[j]), `ticks ${i} and ${j}`).toBe(false); +}); + +test("the hero + opens upload", async ({ page }) => { + await openHome(page); + await spend(page).getByRole("button", { name: "Upload a receipt" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); +}); diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index b8bbe03..2419abd 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -2,18 +2,31 @@ import { useEffect, useState } from "react"; import { animate } from "motion/react"; +import { Plus } from "lucide-react"; +import { PageCard } from "@/components/page/page-card"; +import { pickTicks, smoothPath } from "@/lib/chart"; import { useMoney } from "@/lib/format"; -import { dayIndex, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math"; +import { dayIndex, ordinal, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math"; import { cn } from "@/lib/utils"; import type { Forecast, Reminder } from "../use-home-data"; -import { PageCard } from "@/components/page/page-card"; const RANGES: Range[] = ["1W", "1M", "3M", "YTD"]; -const TITLE: Record = { "1W": "Spent this week", "1M": "Spent this month", "3M": "Spent this quarter", YTD: "Spent this year" }; -const W = 860; -const H = 160; -const short = (d: Date) => d.toLocaleString("en-US", { month: "short" }); // "Sep", not en-GB's "Sept" -const tick = (d: Date) => d.toLocaleString("en-GB", { day: "2-digit", month: "short" }); +const W = 1000; +const H = 200; +const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" }); +const dayMonth = (d: Date) => d.toLocaleString("en-GB", { day: "numeric", month: "short" }); + +function title(range: Range, period: Period) { + if (range === "1M") return `Spent in ${monthLong(period.start)}`; + if (range === "YTD") return `Spent in ${period.start.getFullYear()}`; + return range === "1W" ? "Spent this week" : "Spent this quarter"; +} + +function compared(range: Range, previous: Period, today: Date) { + if (range === "1M") return `vs ${monthLong(previous.start)} by the ${ordinal(today.getDate())}`; + if (range === "YTD") return `vs ${previous.start.getFullYear()} at this point`; + return range === "1W" ? "vs last week at this point" : "vs last quarter at this point"; +} // Counts up once per browser session, then shows the number straight away (motion rule: no replay on revisits). function CountUp({ value, format }: { value: number; format: (n: number) => string }) { @@ -32,9 +45,26 @@ function CountUp({ value, format }: { value: number; format: (n: number) => stri return <>{format(shown)}; } +// The width the tick labels are laid out in, so they can be spaced in real pixels at any screen size. A callback +// ref: the axis only exists once the card has data, after the first render. +function useWidth() { + const [el, setEl] = useState(null); + const [width, setWidth] = useState(800); + useEffect(() => { + if (!el) return; + const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width)); + ro.observe(el); + return () => ro.disconnect(); + }, [el]); + return [setEl, width] as const; +} + +// Home's hero (spec 2026-10-22 §4.1, Origin style): the period's total, how it compares, where it's heading, and the +// running total as a smooth line against the previous period, with upcoming bills pinned on the axis. export function SpendCard({ range, onRange, + onUpload, period, previous, cur, @@ -46,6 +76,7 @@ export function SpendCard({ }: { range: Range; onRange: (r: Range) => void; + onUpload: () => void; period: Period; previous: Period; cur: number[]; @@ -56,6 +87,7 @@ export function SpendCard({ loading: boolean; }) { const money = useMoney(); + const [axis, width] = useWidth(); const t = Math.min(Math.max(dayIndex(period, today), 0), period.days - 1); const total = cur[t] ?? 0; const delta = sameDayDelta(cur, prev, t); @@ -78,9 +110,9 @@ export function SpendCard({ const empty = total === 0 && !prev.some((v) => v > 0); const max = Math.max(total, prev[prev.length - 1] ?? 0, fc[fc.length - 1] ?? 0, 1); const x = (i: number) => (period.days > 1 ? (i / (period.days - 1)) * W : 0); - const y = (v: number) => H - (v / max) * (H - 20); - const line = (vals: number[], from = 0) => vals.map((v, i) => `${i ? "L" : "M"}${x(i + from).toFixed(1)} ${y(v).toFixed(1)}`).join(" "); - const actual = cur.slice(0, t + 1); + const y = (v: number) => H - (v / max) * (H - 16); + const pts = (vals: number[], from = 0) => vals.map((v, i) => [x(i + from), y(v)] as [number, number]); + const actual = pts(cur.slice(0, t + 1)); const pins = reminders.filter((r) => { if (r.type !== "upcoming") return false; @@ -88,83 +120,146 @@ export function SpendCard({ return i > t && i < period.days; }); - const ticks = [0, Math.round((period.days - 1) * 0.25), Math.round((period.days - 1) * 0.5), t, period.days - 1] - .filter((v, i, a) => a.indexOf(v) === i) - .sort((a, b) => a - b); - const vsLabel = - range === "1M" ? `vs ${short(previous.start)} at this point` : range === "YTD" ? `vs ${previous.start.getFullYear()} at this point` : "vs last period at this point"; + // first, today and last always show unless they'd crowd today; the weekly ones in between only where they fit + const px = (i: number) => (x(i) / W) * width; + const minGap = 48; + const ticks = pickTicks( + Array.from({ length: period.days }, (_, i) => i) + .filter((i) => i === 0 || i === t || i === period.days - 1 || i % 7 === 0) + .map((i) => ({ + i, + x: px(i), + keep: i === t || ((i === 0 || i === period.days - 1) && Math.abs(px(i) - px(t)) >= minGap), + label: i === t ? "Today" : i === 0 || i === period.days - 1 ? dayMonth(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i)) : String(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i).getDate()), + })), + minGap, + ); + + const legend = ( + + + + Now + + + + Before + + {fc.length > 0 && ( + + + Forecast + + )} + + ); return ( - -
    -
    -
    - 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={318} + 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,8 +269,8 @@ 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 52ddc4e..4c152cb 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"; @@ -38,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()); @@ -75,7 +79,7 @@ export default function HomeContent() { actions={ <> - + } /> @@ -84,6 +88,7 @@ export default function HomeContent() { setUploadOpen(true)} period={chart.current} previous={chart.previous} cur={cumulative(data.txns, chart.current)} diff --git a/frontend/src/components/uploadDialog.tsx b/frontend/src/components/uploadDialog.tsx index 1bed992..8c2df4e 100644 --- a/frontend/src/components/uploadDialog.tsx +++ b/frontend/src/components/uploadDialog.tsx @@ -17,14 +17,28 @@ interface FileWithStatus { extractedData?: any; } -export function DialogDemo({ defaultOpen = false }: { defaultOpen?: boolean }) { +// Pass `open`/`onOpenChange` to open it from elsewhere too (Home's hero +); otherwise it manages itself. +export function DialogDemo({ + defaultOpen = false, + open: openProp, + onOpenChange, +}: { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { const [files, setFiles] = useState([]); const [uploading, setUploading] = useState(false); - const [open, setOpen] = useState(defaultOpen); + const [ownOpen, setOwnOpen] = useState(defaultOpen); + const open = openProp ?? ownOpen; + const setOpen = (next: boolean) => { + setOwnOpen(next); + onOpenChange?.(next); + }; useEffect(() => { if (defaultOpen) { - setOpen(true); + setOwnOpen(true); } }, [defaultOpen]); diff --git a/frontend/src/lib/home-math.ts b/frontend/src/lib/home-math.ts index 5a22fe8..5b28fa7 100644 --- a/frontend/src/lib/home-math.ts +++ b/frontend/src/lib/home-math.ts @@ -137,3 +137,6 @@ export function suggestedQuestions(found: { kind: string; subject: string | null const fromFound = found.slice(0, 3).map((f) => ASK_ABOUT[f.kind]?.(f.subject)).filter((q): q is string => !!q); return [...new Set([...fromFound, ...server])].slice(0, 4); } + +// "1st", "22nd", "13th" +export const ordinal = (n: number) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th")}`; From 1b0c2b4ea74043234d2b61984d1a0b8112f9029f Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:55:46 +0530 Subject: [PATCH 07/47] fix: Ask rail busy/focus/touch, palette outside the sheet, one-commit chat save, clear 409 --- backend/api/agent.py | 7 ++++--- backend/routes/chat.py | 7 +++++-- backend/tests/test_api_agent.py | 3 ++- backend/tests/test_chat_attach.py | 2 +- backend/tests/test_chat_threads.py | 15 ++++++++++++++ frontend/e2e/ask.spec.ts | 22 +++++++++++++++++++++ frontend/e2e/fixtures.ts | 3 +++ frontend/e2e/shell.spec.ts | 9 +++++++++ frontend/src/app/(app)/ask/thread-rail.tsx | 18 +++++++++++++---- frontend/src/components/app-shell.tsx | 21 ++++++++++++++++++-- frontend/src/components/app-sidebar.tsx | 23 +++++----------------- 11 files changed, 99 insertions(+), 31 deletions(-) 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/frontend/e2e/ask.spec.ts b/frontend/e2e/ask.spec.ts index a81ecd9..a1eeaa0 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)); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 64868a8..fd5aa86 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, to test the in-flight state */ + delayMs?: number; }; export async function signIn( @@ -133,6 +135,7 @@ export async function signIn( } if (path === "/chat" && route.request().method() === "POST") { const body = route.request().postDataJSON() as { query: string; thread_id?: string }; + if (opts.chat?.delayMs) await new Promise((r) => setTimeout(r, opts.chat!.delayMs)); 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/shell.spec.ts b/frontend/e2e/shell.spec.ts index 8bc6b4d..1e47af9 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"); 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/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) { - ); From 85685505892d22abcd41e95b49ad1d11528eb3b3 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:07:26 +0530 Subject: [PATCH 08/47] feat: day-by-day calendar with amounts and bill names --- frontend/e2e/home-math.spec.ts | 8 ++ frontend/e2e/home.spec.ts | 15 +++ .../(app)/home/_components/calendar-card.tsx | 108 +++++++++++++----- frontend/src/app/(app)/home/homeContent.tsx | 2 +- frontend/src/components/page/vendor-chip.tsx | 16 +++ frontend/src/lib/home-math.ts | 10 ++ 6 files changed, 131 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/page/vendor-chip.tsx diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts index c0a3979..126d816 100644 --- a/frontend/e2e/home-math.spec.ts +++ b/frontend/e2e/home-math.spec.ts @@ -11,6 +11,7 @@ import { relativeTime, sameDayDelta, type Txn, + vendorChip, } from "../src/lib/home-math"; const d = (s: string) => { @@ -193,3 +194,10 @@ test("pickTicks drops ticks that crowd a kept one", () => { expect(pickTicks([t(0, true), t(20), t(40), t(300, true), t(310, true)], 48).map((k) => k.label)).toEqual(["0", "300", "310"]); expect(pickTicks([t(0, true), t(60), t(120), t(200, true)], 48).map((k) => k.label)).toEqual(["0", "60", "120", "200"]); }); + +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("?"); +}); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index e8d32e7..5795df5 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -378,3 +378,18 @@ test("the hero + opens upload", async ({ 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"); +}); diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index 2324b7e..3d8ec61 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -1,56 +1,110 @@ -import { useMoney } from "@/lib/format"; -import { calendarBuckets, heat, parseDay, type Period, type Txn } from "@/lib/home-math"; +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, 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)}`); 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. +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 && {kShort(amount, symbol)}} + {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/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index 4c152cb..d2ce389 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -99,7 +99,7 @@ export default function HomeContent() { loading={data.status === "loading"} /> - +
    diff --git a/frontend/src/components/page/vendor-chip.tsx b/frontend/src/components/page/vendor-chip.tsx new file mode 100644 index 0000000..b8eeedd --- /dev/null +++ b/frontend/src/components/page/vendor-chip.tsx @@ -0,0 +1,16 @@ +import { vendorChip } from "@/lib/home-math"; +import { cn } from "@/lib/utils"; + +// A vendor's initials on a soft tint of its own stable hue (lib/home-math vendorChip). +export function VendorChip({ name, className }: { name: string; className?: string }) { + const { initials, hue } = vendorChip(name); + return ( + + {initials} + + ); +} diff --git a/frontend/src/lib/home-math.ts b/frontend/src/lib/home-math.ts index 5b28fa7..313f42b 100644 --- a/frontend/src/lib/home-math.ts +++ b/frontend/src/lib/home-math.ts @@ -140,3 +140,13 @@ export function suggestedQuestions(found: { kind: string; subject: string | null // "1st", "22nd", "13th" export const ordinal = (n: number) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th")}`; + +// The little initials chip shown for a vendor: two letters ("Swiggy Instamart" → "SI", "Swiggy" → "SW") and a hue +// that's the same every time for the same name, so a vendor keeps its colour across pages. +export function vendorChip(name: string): { initials: string; hue: number } { + const words = name.trim().split(/\s+/).filter(Boolean); + const initials = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] ?? "").slice(0, 2)).toUpperCase() || "?"; + let hash = 0; + for (const ch of name.toLowerCase()) hash = (hash * 31 + ch.charCodeAt(0)) % 360; + return { initials, hue: hash }; +} From a60c4de98fd68ebc270e8712255ac31824384c1d Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:53:39 +0530 Subject: [PATCH 09/47] feat: Lumen found list with real actions; /ask?draft= prefills --- frontend/e2e/ask.spec.ts | 20 +++++ frontend/e2e/home.spec.ts | 16 +++- frontend/src/app/(app)/ask/chatbotContent.tsx | 25 ++++++- .../(app)/home/_components/lumen-found.tsx | 73 +++++++++++++------ frontend/src/components/ask/prompt-bar.tsx | 5 +- 5 files changed, 110 insertions(+), 29 deletions(-) diff --git a/frontend/e2e/ask.spec.ts b/frontend/e2e/ask.spec.ts index a1eeaa0..f62159a 100644 --- a/frontend/e2e/ask.spec.ts +++ b/frontend/e2e/ask.spec.ts @@ -204,3 +204,23 @@ 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(""); +}); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 5795df5..26fa571 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -246,8 +246,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."); }); @@ -393,3 +393,15 @@ test("calendar marks today and names future bills", async ({ page }) => { 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%"); +}); diff --git a/frontend/src/app/(app)/ask/chatbotContent.tsx b/frontend/src/app/(app)/ask/chatbotContent.tsx index e81baf7..ea8e101 100644 --- a/frontend/src/app/(app)/ask/chatbotContent.tsx +++ b/frontend/src/app/(app)/ask/chatbotContent.tsx @@ -70,7 +70,11 @@ 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); + const [draft, setDraft] = useState<{ text: string; key: number } | null>(null); const busy = messages.some((m) => m.pending); const refreshThreads = useCallback(() => { @@ -110,6 +114,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(); @@ -218,7 +237,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 +314,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)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx index e528ed2..5feae32 100644 --- a/frontend/src/app/(app)/home/_components/lumen-found.tsx +++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx @@ -1,9 +1,22 @@ 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 { 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). export function LumenFound({ summary, loading }: { summary: HomeSummary | null; loading: boolean }) { @@ -11,33 +24,47 @@ 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/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); From 9c13ab8d16281c6423e3a09882ca0f48a533c265 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:05:42 +0530 Subject: [PATCH 10/47] feat: Home v2 layout, budget and coming-up cards --- frontend/e2e/home.spec.ts | 41 +++++++++++++-- .../app/(app)/home/_components/bills-card.tsx | 16 +++--- .../(app)/home/_components/budget-card.tsx | 51 ++++++++++++------- .../(app)/home/_components/calendar-card.tsx | 5 +- .../app/(app)/home/_components/spend-card.tsx | 4 +- frontend/src/app/(app)/home/homeContent.tsx | 30 +++++------ 6 files changed, 99 insertions(+), 48 deletions(-) diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 26fa571..933a67d 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -168,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 }) => { @@ -198,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 }) => { @@ -261,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 @@ -405,3 +412,29 @@ test("lumen found rows do things", async ({ page }) => { 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/); +}); diff --git a/frontend/src/app/(app)/home/_components/bills-card.tsx b/frontend/src/app/(app)/home/_components/bills-card.tsx index 1989478..4efad3a 100644 --- a/frontend/src/app/(app)/home/_components/bills-card.tsx +++ b/frontend/src/app/(app)/home/_components/bills-card.tsx @@ -3,28 +3,28 @@ import { parseDay } from "@/lib/home-math"; import type { Reminder } from "../use-home-data"; 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/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx index 4d3b9d0..b611e2a 100644 --- a/frontend/src/app/(app)/home/_components/budget-card.tsx +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -9,25 +9,30 @@ import { cn } from "@/lib/utils"; 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,15 +45,27 @@ 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 3d8ec61..1caa140 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -12,7 +12,8 @@ const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "nume const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" }); // 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. +// 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, @@ -42,7 +43,7 @@ export function CalendarCard({ } return ( - +
    diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx index 2419abd..89e8109 100644 --- a/frontend/src/app/(app)/home/_components/spend-card.tsx +++ b/frontend/src/app/(app)/home/_components/spend-card.tsx @@ -170,7 +170,7 @@ export function SpendCard({ } loading={loading} - skeletonHeight={318} + skeletonHeight={348} className="relative" data-testid="spend-card" > @@ -196,7 +196,7 @@ export function SpendCard({
    {empty ? ( -
    +

    Nothing spent yet this {range === "1W" ? "week" : range === "1M" ? "month" : range === "3M" ? "quarter" : "year"}. Upload a receipt or import a statement and your spending will show up here. diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index d2ce389..122b663 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -83,23 +83,23 @@ export default function HomeContent() { } /> -

    + 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"} + /> +
    - 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"} - /> - +
    From 09f27ca32e4bfda90f906dfb7a18c803fbe8f36c Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:11:55 +0530 Subject: [PATCH 11/47] fix: calendar amounts readable on phones --- frontend/e2e/home.spec.ts | 8 ++++++++ .../app/(app)/home/_components/calendar-card.tsx | 14 ++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 933a67d..76b93a3 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -438,3 +438,11 @@ test("an empty account keeps every card at its loading height", async ({ page }) 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); +}); diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index 1caa140..b808960 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -6,8 +6,9 @@ import { calendarBuckets, dayIndex, heat, parseDay, type Period, type Txn } from 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" }); @@ -72,14 +73,19 @@ export function CalendarCard({ data-today={i === t || undefined} data-future={future || undefined} className={cn( - "flex h-[50px] flex-col justify-between overflow-hidden rounded-[7px] border px-1.5 py-1 text-[10.5px] leading-tight text-muted-foreground", + "flex h-[50px] flex-col justify-between overflow-hidden rounded-[7px] border px-1.5 py-1 max-sm:px-1 text-[10.5px] leading-tight text-muted-foreground", future ? "border-dashed border-border" : "border-border/60", i === t && "outline-[1.5px] outline-primary outline-solid -outline-offset-1", )} style={h ? { background: `color-mix(in srgb, var(--primary) ${Math.round(h * 100)}%, transparent)`, color: h > 0.5 ? "#fff" : undefined } : undefined} > {day} - {amount > 0 && {kShort(amount, symbol)}} + {amount > 0 && ( + + {symbol} + {kShort(amount)} + + )} {future && bill && {bill}}
    ); From 201a5702da5d22f5887d9b042e707dcee6bdcff1 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:22:04 +0530 Subject: [PATCH 12/47] fix: e2e dev server builds into .next-e2e so it can't break npm run dev --- frontend/.gitignore | 1 + frontend/next.config.js | 3 +++ frontend/playwright.config.ts | 1 + 3 files changed, 5 insertions(+) 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/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", }, }, }); From 94916a4adae6ff0ade8d0755df7b1846c90e8508 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:22:25 +0530 Subject: [PATCH 13/47] chore: include the e2e build's route types in tsconfig --- frontend/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c3fdd48..b2b45c0 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -22,6 +22,6 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"], "exclude": ["node_modules"] } From 1dbaf139f5862b81d606d9833f32a033d54af1a1 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:36:11 +0530 Subject: [PATCH 14/47] fix: Home reads reminders in the shape the API sends (crash on a dateless reminder; bills never showed) --- frontend/e2e/fixtures.ts | 15 ++++++++++-- frontend/e2e/home.spec.ts | 13 ++++++++++ frontend/src/app/(app)/home/use-home-data.ts | 25 +++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index fd5aa86..c574df0 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -82,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" } }); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 76b93a3..3ea1d8d 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -446,3 +446,16 @@ test("calendar amounts stay readable at phone width", async ({ page }) => { 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(); +}); 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]) => { From ca088659d75b01358f403ca75388c1fb6871a422 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:43:26 +0530 Subject: [PATCH 15/47] fix: drop the Spending/Bills cards that read fields the backend never sent; interim Bills lists upcoming bills --- frontend/e2e/shell.spec.ts | 13 + .../(app)/ai-analytics/aiAnalyticsContent.tsx | 19 +- frontend/src/app/(app)/bills/page.tsx | 10 +- .../src/app/(app)/bills/upcoming-bills.tsx | 19 ++ .../ai-analytics/paymentCalendarCard.tsx | 208 -------------- .../ai-analytics/reminderDetailsCard.tsx | 171 ------------ .../ai-analytics/spendingTrendGraph.tsx | 254 ------------------ 7 files changed, 40 insertions(+), 654 deletions(-) create mode 100644 frontend/src/app/(app)/bills/upcoming-bills.tsx delete mode 100644 frontend/src/components/ai-analytics/paymentCalendarCard.tsx delete mode 100644 frontend/src/components/ai-analytics/reminderDetailsCard.tsx delete mode 100644 frontend/src/components/ai-analytics/spendingTrendGraph.tsx diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 1e47af9..f0c2c16 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -262,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/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)/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/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. -

    -
    -
    -
    -
    -
    -
    -
    - ); -} From 74995e90733faec3786123397cbdcf2401fb84f2 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:58:08 +0530 Subject: [PATCH 16/47] feat: quiet Upload/Export and light upload and export dialogs --- frontend/e2e/home.spec.ts | 42 ++++++++++++++++ frontend/src/components/exportDialog.tsx | 63 ++++++++++-------------- frontend/src/components/uploadDialog.tsx | 51 +++++++++---------- 3 files changed, 93 insertions(+), 63 deletions(-) diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 3ea1d8d..fe1a715 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -459,3 +459,45 @@ test("a restock reminder (no vendor) neither crashes Home nor shows as a bill", 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); +}); 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 */}
    -
    ); From a7b2d22c8a94df255683c342525c5917857bab90 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:50:25 +0530 Subject: [PATCH 27/47] chore: remove the old Analytics/AI Insights pages and the API calls only they used --- frontend/e2e/spending.spec.ts | 15 + .../(app)/ai-analytics/aiAnalyticsContent.tsx | 65 ---- .../app/(app)/analytics/analyticsContent.tsx | 329 ------------------ .../ai-analytics/aiSuggestionsCard.tsx | 219 ------------ .../ai-analytics/anomalyDetectionCard.tsx | 188 ---------- .../ai-analytics/categoryPieChart.tsx | 244 ------------- .../ai-analytics/quickStatsCard.tsx | 181 ---------- .../components/analytics/analyticsCards.tsx | 303 ---------------- .../analytics/spendingTrendChart.tsx | 260 -------------- frontend/src/lib/api/client.ts | 90 +---- 10 files changed, 17 insertions(+), 1877 deletions(-) delete mode 100644 frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx delete mode 100644 frontend/src/app/(app)/analytics/analyticsContent.tsx delete mode 100644 frontend/src/components/ai-analytics/aiSuggestionsCard.tsx delete mode 100644 frontend/src/components/ai-analytics/anomalyDetectionCard.tsx delete mode 100644 frontend/src/components/ai-analytics/categoryPieChart.tsx delete mode 100644 frontend/src/components/ai-analytics/quickStatsCard.tsx delete mode 100644 frontend/src/components/analytics/analyticsCards.tsx delete mode 100644 frontend/src/components/analytics/spendingTrendChart.tsx diff --git a/frontend/e2e/spending.spec.ts b/frontend/e2e/spending.spec.ts index 3c0dfbf..f54db8a 100644 --- a/frontend/e2e/spending.spec.ts +++ b/frontend/e2e/spending.spec.ts @@ -166,3 +166,18 @@ test("top merchants and CSV export of what's shown", async ({ page }) => { expect(text).toContain("Croma"); expect(text).not.toContain("Swiggy"); }); + +test("spending has no layout shift while loading", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.addInitScript(() => { + (window as unknown as { cls: number }).cls = 0; + new PerformanceObserver((list) => { + for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[]) + if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value; + }).observe({ type: "layout-shift", buffered: true }); + }); + await openSpending(page, { ...sample, delayMs: 1500 }); + await expect(page.getByTestId("brief")).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01); +}); diff --git a/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx b/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx deleted file mode 100644 index 91acece..0000000 --- a/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { DashboardShell } from "@/components/dashboard-shell"; -import QuickStatsCard from "@/components/ai-analytics/quickStatsCard"; -import AnomalyDetectionCard from "@/components/ai-analytics/anomalyDetectionCard"; -import AISuggestionsCard from "@/components/ai-analytics/aiSuggestionsCard"; -import CategoryPieChart from "@/components/ai-analytics/categoryPieChart"; -import { aiAnalyticsApi } from "@/lib/api/client"; - -import { logger } from "@/lib/logger"; -import { Badge } from "@/components/ui/badge"; - -export default function AIAnalyticsContent() { - const [analysisTriggered, setAnalysisTriggered] = useState(false); - - useEffect(() => { - const triggerAnalysis = async () => { - if (analysisTriggered) return; - - try { - logger.debug("Triggering AI analysis..."); - const data = await aiAnalyticsApi.runAnalysis({ - includeFraud: true, - includeForecast: true, - includeRisk: true, - useLlm: false, - }); - logger.debug("AI Analysis complete:", data); - setAnalysisTriggered(true); - } catch (error) { - console.error("Error triggering analysis:", error); - } - }; - - void triggerAnalysis(); - }, [analysisTriggered]); - - return ( - - {analysisTriggered ? "Model refreshed" : "Analyzing live data..."} - - } - > - {/* 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)/analytics/analyticsContent.tsx b/frontend/src/app/(app)/analytics/analyticsContent.tsx deleted file mode 100644 index 232d4be..0000000 --- a/frontend/src/app/(app)/analytics/analyticsContent.tsx +++ /dev/null @@ -1,329 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { useSearchParams } from "next/navigation"; -import { DashboardShell } from "@/components/dashboard-shell"; -import AnalyticsCards from "@/components/analytics/analyticsCards"; -import SpendingTrendChart from "@/components/analytics/spendingTrendChart"; -import { Button } from "@/components/ui/button"; -import { Calendar, ChevronLeft, ChevronRight } from "lucide-react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -type TimeRange = "weekly" | "monthly" | "yearly"; - -interface SelectedPeriod { - year: number; - month?: number; // 0-11 (for monthly) - week?: number; // 1-52 (for weekly) -} - -export default function AnalyticsContent() { - const searchParams = useSearchParams(); - const tabParam = searchParams.get("tab") as TimeRange | null; - - const [timeRange, setTimeRange] = useState( - tabParam || "monthly" - ); - const now = new Date(); - const [selectedPeriod, setSelectedPeriod] = useState({ - year: now.getFullYear(), - month: now.getMonth(), - }); - - // Generate period options based on time range - const generateYears = () => { - const currentYear = new Date().getFullYear(); - const years = []; - for (let i = currentYear; i >= currentYear - 5; i--) { - years.push(i); - } - return years; - }; - - const months = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ]; - - const generateWeeks = () => { - const weeks = []; - for (let i = 1; i <= 52; i++) { - weeks.push(i); - } - return weeks; - }; - - const getCurrentWeek = useCallback(() => { - const now = new Date(); - const start = new Date(now.getFullYear(), 0, 1); - const diff = now.getTime() - start.getTime(); - const oneWeek = 1000 * 60 * 60 * 24 * 7; - return Math.ceil(diff / oneWeek); - }, []); - - const handlePeriodChange = (type: string, value: string) => { - if (type === "year") { - setSelectedPeriod({ ...selectedPeriod, year: parseInt(value) }); - } else if (type === "month") { - setSelectedPeriod({ ...selectedPeriod, month: parseInt(value) }); - } else if (type === "week") { - setSelectedPeriod({ ...selectedPeriod, week: parseInt(value) }); - } - }; - - const handleTimeRangeChange = useCallback( - (range: TimeRange) => { - setTimeRange(range); - const currentDate = new Date(); - if (range === "monthly") { - setSelectedPeriod({ - year: currentDate.getFullYear(), - month: currentDate.getMonth(), - }); - } else if (range === "weekly") { - setSelectedPeriod({ - year: currentDate.getFullYear(), - week: getCurrentWeek(), - }); - } else { - setSelectedPeriod({ year: currentDate.getFullYear() }); - } - }, - [getCurrentWeek] - ); - - // Initialize time range and period based on URL parameter - useEffect(() => { - if (tabParam && ["weekly", "monthly", "yearly"].includes(tabParam)) { - handleTimeRangeChange(tabParam); - } - }, [tabParam, handleTimeRangeChange]); - - const getPeriodLabel = () => { - if (timeRange === "yearly") { - return `Year ${selectedPeriod.year}`; - } else if ( - timeRange === "monthly" && - selectedPeriod.month !== undefined - ) { - return `${months[selectedPeriod.month]} ${selectedPeriod.year}`; - } else if (timeRange === "weekly" && selectedPeriod.week) { - return `Week ${selectedPeriod.week}, ${selectedPeriod.year}`; - } - return ""; - }; - - const navigatePeriod = (direction: "prev" | "next") => { - const newPeriod = { ...selectedPeriod }; - if (timeRange === "yearly") { - newPeriod.year += direction === "next" ? 1 : -1; - } else if (timeRange === "monthly" && newPeriod.month !== undefined) { - if (direction === "next") { - if (newPeriod.month === 11) { - newPeriod.month = 0; - newPeriod.year++; - } else { - newPeriod.month++; - } - } else { - if (newPeriod.month === 0) { - newPeriod.month = 11; - newPeriod.year--; - } else { - newPeriod.month--; - } - } - } else if (timeRange === "weekly" && newPeriod.week) { - if (direction === "next") { - if (newPeriod.week === 52) { - newPeriod.week = 1; - newPeriod.year++; - } else { - newPeriod.week++; - } - } else { - if (newPeriod.week === 1) { - newPeriod.week = 52; - newPeriod.year--; - } else { - newPeriod.week--; - } - } - } - setSelectedPeriod(newPeriod); - }; - - const toolbar = ( -
    -
    - {(["weekly", "monthly", "yearly"] as TimeRange[]).map((range) => ( - - ))} -
    - -
    - - - {timeRange === "yearly" && ( - - )} - - {timeRange === "monthly" && ( - <> - - - - )} - - {timeRange === "weekly" && ( - <> - - - - )} - - -
    -
    - ); - - return ( - - - {getPeriodLabel()} -
    - } - > - - - - ); -} diff --git a/frontend/src/components/ai-analytics/aiSuggestionsCard.tsx b/frontend/src/components/ai-analytics/aiSuggestionsCard.tsx deleted file mode 100644 index 8ffb6e3..0000000 --- a/frontend/src/components/ai-analytics/aiSuggestionsCard.tsx +++ /dev/null @@ -1,219 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Lightbulb, - TrendingDown, - Repeat, - DollarSign, - CheckCircle, - AlertTriangle, - Target, - Zap, -} from "lucide-react"; -import { motion } from "motion/react"; -import { Badge } from "@/components/ui/badge"; -import { useState, useEffect } from "react"; -import { aiAnalyticsApi } from "@/lib/api/client"; - -const iconMap: { [key: string]: any } = { - "cost-saving": DollarSign, - duplicate: Repeat, - optimization: TrendingDown, - opportunity: CheckCircle, - warning: AlertTriangle, - recommendation: Target, - insight: Lightbulb, - action: Zap, -}; - -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.4, - }, - }, -}; - -const getImpactColor = (severity: string) => { - switch (severity?.toLowerCase()) { - case "critical": - case "high": - return "border-rose-400/30 bg-rose-500/15 text-rose-300"; - case "medium": - return "border-amber-400/30 bg-amber-500/15 text-amber-300"; - case "low": - case "info": - return "border-primary/30 bg-primary/15 text-primary"; - default: - return "border-border bg-muted text-muted-foreground"; - } -}; - -export default function AISuggestionsCard() { - const [suggestions, setSuggestions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchSuggestions = async () => { - try { - setError(null); - const response = await aiAnalyticsApi.getInsights({ - limit: 10, - }); - if (response.success && response.insights) { - setSuggestions(response.insights); - } else { - setError("Could not load AI suggestions."); - setSuggestions([]); - } - } catch (err) { - console.error("Failed to load AI suggestions", err); - setError("Could not load AI suggestions."); - setSuggestions([]); - } finally { - setLoading(false); - } - }; - fetchSuggestions(); - }, []); - - if (loading) { - return ( - - -

    Loading AI suggestions...

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

    {error}

    -
    -
    - ); - } - - if (suggestions.length === 0) { - return ( - - -
    - -

    - No suggestions available yet -

    -

    - AI analysis will generate insights based on your - transaction data -

    -
    -
    -
    - ); - } - - return ( - - - - -
    - - AI Suggestions -
    - - {suggestions.length} insights - -
    -
    - -
    - {suggestions.map((suggestion, index) => { - const Icon = - iconMap[suggestion.insight_type] || Lightbulb; - return ( - -
    -
    - -
    -
    -
    -

    - {suggestion.title} -

    - {suggestion.severity && ( - - {suggestion.severity} - - )} -
    -

    - {suggestion.description} -

    - {suggestion.confidence_score && ( -
    -
    -
    -
    - - {( - suggestion.confidence_score * - 100 - ).toFixed(0)} - % - -
    - )} -
    -
    - - ); - })} -
    - - -
    - ); -} diff --git a/frontend/src/components/ai-analytics/anomalyDetectionCard.tsx b/frontend/src/components/ai-analytics/anomalyDetectionCard.tsx deleted file mode 100644 index 2cf7acb..0000000 --- a/frontend/src/components/ai-analytics/anomalyDetectionCard.tsx +++ /dev/null @@ -1,188 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { AlertTriangle, Shield, Eye } from "lucide-react"; -import { motion } from "motion/react"; -import { Badge } from "@/components/ui/badge"; -import { useEffect, useState } from "react"; -import { useMoney } from "@/lib/format"; -import { aiAnalyticsApi } 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.3, - }, - }, -}; - -const getSeverityColor = (severity: string) => { - switch (severity) { - case "high": - return "border-rose-400/30 bg-rose-500/15 text-rose-300"; - case "medium": - return "border-amber-400/30 bg-amber-500/15 text-amber-300"; - case "low": - return "border-primary/30 bg-primary/15 text-primary"; - default: - return "border-border bg-muted text-muted-foreground"; - } -}; - -const getSeverityIcon = (severity: string) => { - switch (severity) { - case "high": - return ; - case "medium": - return ; - case "low": - return ; - default: - return null; - } -}; - -export default function AnomalyDetectionCard() { - const money = useMoney(); - const [anomalies, setAnomalies] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchAnomalies = async () => { - try { - setError(null); - const response = await aiAnalyticsApi.getAnomalies(); - if (response.success) { - setAnomalies(response.anomalies || []); - } else { - setError("Could not load anomalies."); - } - } catch (err) { - console.error("Failed to fetch anomalies:", err); - setError("Could not load anomalies."); - } finally { - setIsLoading(false); - } - }; - - fetchAnomalies(); - }, []); - - const highRiskCount = anomalies.filter( - (a) => a.risk_level === "HIGH" - ).length; - - return ( - - - - -
    - - Anomaly Detection -
    - - {highRiskCount} High - -
    -
    - - {isLoading ? ( -
    -
    -
    -
    - ) : error ? ( -
    - {error} -
    - ) : anomalies.length === 0 ? ( -
    - No anomalies detected -
    - ) : ( -
    - {anomalies.map((anomaly, index) => ( - -
    -
    -
    -

    - {anomaly.vendor_name} -

    - - {getSeverityIcon( - anomaly.risk_level?.toLowerCase() || - "low" - )} - {anomaly.risk_level || - "LOW"} - -
    -

    - {anomaly.date} -

    -
    -
    -

    - {money(anomaly.amount, null, { decimals: 2 })} -

    -
    -
    -

    - {anomaly.description} -

    -
    - -

    - {anomaly.explanation || - "Anomaly detected"} -

    -
    -
    - ))} -
    - )} -
    -
    -
    - ); -} diff --git a/frontend/src/components/ai-analytics/categoryPieChart.tsx b/frontend/src/components/ai-analytics/categoryPieChart.tsx deleted file mode 100644 index f61e11e..0000000 --- a/frontend/src/components/ai-analytics/categoryPieChart.tsx +++ /dev/null @@ -1,244 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { PieChart as PieChartIcon } from "lucide-react"; -import { motion } from "motion/react"; -import { useState, useEffect } from "react"; -import { useMoney } from "@/lib/format"; -import { transactionApi, tokenManager } from "@/lib/api/client"; -import { - PieChart, - Pie, - Cell, - ResponsiveContainer, - Tooltip, - Legend, -} from "recharts"; - -const COLORS = [ - "#4f46e5", - "#f97316", - "#10b981", - "#06b6d4", - "#8b5cf6", - "#94a3b8", -]; - -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.6, - }, - }, -}; - -const CustomTooltip = ({ active, payload }: any) => { - const money = useMoney(); - if (active && payload && payload.length) { - return ( -
    -

    {payload[0].name}

    -

    - {money(payload[0].value)} -

    -

    - {payload[0].payload.percentage}% -

    -
    - ); - } - return null; -}; - -const renderCustomLabel = (entry: any) => { - return `${entry.percentage}%`; -}; - -export default function CategoryPieChart() { - const money = useMoney(); - const [categoryData, setCategoryData] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchCategoryData = async () => { - try { - setLoading(true); - const user = tokenManager.getUser(); - if (!user?.id) { - setError("User not found"); - return; - } - - const response = await transactionApi.getTransactions({ - page: 1, - page_size: 1000, - }); - - const transactions = response.data || []; - - // Aggregate by category - const categoryMap: { [key: string]: number } = {}; - transactions.forEach((transaction: any) => { - const category = transaction.category || "Uncategorized"; - const amount = parseFloat(transaction.total_amount || 0); - categoryMap[category] = - (categoryMap[category] || 0) + amount; - }); - - // Calculate total and percentages - const total = Object.values(categoryMap).reduce( - (sum, val) => sum + val, - 0 - ); - const aggregatedData = Object.entries(categoryMap).map( - ([name, value]) => ({ - name, - value, - percentage: - total > 0 - ? ((value / total) * 100).toFixed(1) - : "0", - }) - ); - - setCategoryData(aggregatedData); - setError(null); - } catch (err: any) { - console.error("Failed to fetch category data:", err); - setError(err.message || "Failed to load data"); - } finally { - setLoading(false); - } - }; - - fetchCategoryData(); - }, []); - - if (loading) { - return ( - - -

    Loading category data...

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

    {error}

    -
    -
    - ); - } - - if (categoryData.length === 0) { - return ( - - -

    No category data available

    -
    -
    - ); - } - - return ( - - - - - - Category Breakdown - - - -
    - - - - {categoryData.map((entry, index) => ( - - ))} - - } /> - - -
    - - {/* Legend */} -
    - {categoryData.map((category, index) => ( - -
    -
    - - {category.name} - -
    -
    -

    - {money(category.value)} -

    -

    - {category.percentage}% -

    -
    -
    - ))} -
    - - {/* Total */} -
    -
    - - Total Spending - - - {money(categoryData.reduce((sum, cat) => sum + cat.value, 0))} - -
    -
    -
    -
    -
    - ); -} diff --git a/frontend/src/components/ai-analytics/quickStatsCard.tsx b/frontend/src/components/ai-analytics/quickStatsCard.tsx deleted file mode 100644 index 7f942d2..0000000 --- a/frontend/src/components/ai-analytics/quickStatsCard.tsx +++ /dev/null @@ -1,181 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { TrendingUp, DollarSign, Calendar, AlertCircle } from "lucide-react"; -import { motion } from "motion/react"; -import { useEffect, useState } from "react"; -import { aiAnalyticsApi } from "@/lib/api/client"; - -const cardVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - ease: "easeOut" as const, - }, - }, -}; - -export default function QuickStatsCard() { - const [stats, setStats] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchDashboard = async () => { - try { - setError(null); - const response = await aiAnalyticsApi.getDashboard(); - if (response.success) { - setStats(response.data); - } else { - setError("Could not load dashboard stats."); - } - } catch (err) { - console.error("Failed to fetch dashboard:", err); - setError("Could not load dashboard stats."); - } finally { - setIsLoading(false); - } - }; - - fetchDashboard(); - }, []); - - if (isLoading) { - return ( - - - - - - Quick Stats - - - -
    -
    -
    -
    -
    -
    -
    -
    - ); - } - - if (error || !stats) { - return ( - - - - - - Quick Stats - - - -

    - {error || "No analytics data yet. Upload invoices to get started."} -

    -
    -
    -
    - ); - } - - return ( - - - - - - Quick Stats - - - - {/* Risk Score & Health Status */} -
    -

    - Financial Health -

    -

    - {stats.health_status || "HEALTHY"} -

    -
    - - Risk: {stats.risk_level || "LOW"} - - - Score: {stats.risk_score || 0}/100 - -
    -
    - - {/* Divider */} -
    - - {/* Quick Metrics Grid */} -
    -
    -

    - Patterns Detected -

    -

    - {stats.patterns_detected || 0} -

    -
    -
    -

    - High Risk Anomalies -

    -

    - {stats.high_risk_anomalies || 0} -

    -
    -
    - - {/* Alerts */} -
    -
    - -
    -

    - Reminders -

    -

    - {stats.active_reminders || 0} -

    -
    -
    -
    - -
    -

    - Recommendations -

    -

    - {stats.top_recommendations?.length || 0} -

    -
    -
    -
    -
    -
    -
    - ); -} diff --git a/frontend/src/components/analytics/analyticsCards.tsx b/frontend/src/components/analytics/analyticsCards.tsx deleted file mode 100644 index dd5bcc2..0000000 --- a/frontend/src/components/analytics/analyticsCards.tsx +++ /dev/null @@ -1,303 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - ArrowUpIcon, - ArrowDownIcon, - DollarSign, - TrendingUp, - TrendingDown, - Activity, -} from "lucide-react"; -import { useEffect, useState } from "react"; -import { useMoney } from "@/lib/format"; -import { analyticsApi } from "@/lib/api/client"; - -import { logger } from "@/lib/logger"; -interface SelectedPeriod { - year: number; - month?: number; - week?: number; -} - -interface AnalyticsCardsProps { - timeRange: "weekly" | "monthly" | "yearly"; - selectedPeriod: SelectedPeriod; -} - -interface AnalyticsData { - avgSpending: number; - maxSpending: number; - minSpending: number; - totalSpending: number; - previousTotal: number; - avgChange: number; - maxChange: number; - minChange: number; -} - -const getPreviousPeriodLabel = ( - timeRange: string, - selectedPeriod: SelectedPeriod -) => { - if (timeRange === "yearly") { - return `${selectedPeriod.year - 1}`; - } else if (timeRange === "monthly" && selectedPeriod.month !== undefined) { - const prevMonth = - selectedPeriod.month === 0 ? 11 : selectedPeriod.month - 1; - const prevYear = - selectedPeriod.month === 0 - ? selectedPeriod.year - 1 - : selectedPeriod.year; - const months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - return `${months[prevMonth]} ${prevYear}`; - } else if (timeRange === "weekly" && selectedPeriod.week) { - const prevWeek = - selectedPeriod.week === 1 ? 52 : selectedPeriod.week - 1; - const prevYear = - selectedPeriod.week === 1 - ? selectedPeriod.year - 1 - : selectedPeriod.year; - return `Week ${prevWeek}, ${prevYear}`; - } - return "previous period"; -}; - -export default function AnalyticsCards({ - timeRange, - selectedPeriod, -}: AnalyticsCardsProps) { - const money = useMoney(); - const [data, setData] = useState({ - avgSpending: 0, - maxSpending: 0, - minSpending: 0, - totalSpending: 0, - previousTotal: 0, - avgChange: 0, - maxChange: 0, - minChange: 0, - }); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const fetchAnalytics = async () => { - try { - setLoading(true); - const response = await analyticsApi.getTimeRangeAnalytics( - timeRange, - selectedPeriod.year, - selectedPeriod.month, - selectedPeriod.week - ); - - logger.debug("Analytics API Response:", response); - logger.debug("Response success:", response.success); - logger.debug("Has current_period:", !!response.current_period); - - if (response.success && response.current_period) { - const current = response.current_period; - const previous = response.previous_period; - - logger.debug("Current period data:", current); - logger.debug("Previous period data:", previous); - - const newData = { - avgSpending: current.average_spending || 0, - maxSpending: current.max_spending || 0, - minSpending: current.min_spending || 0, - totalSpending: current.total_spending || 0, - previousTotal: previous.total_spending || 0, - avgChange: - previous.average_spending && - previous.average_spending !== 0 - ? ((current.average_spending - - previous.average_spending) / - previous.average_spending) * - 100 - : 0, - maxChange: - previous.max_spending && previous.max_spending !== 0 - ? ((current.max_spending - - previous.max_spending) / - previous.max_spending) * - 100 - : 0, - minChange: - previous.min_spending && previous.min_spending !== 0 - ? ((current.min_spending - - previous.min_spending) / - previous.min_spending) * - 100 - : 0, - }; - logger.debug("Setting data to:", newData); - setData(newData); - } else { - console.error( - "Response missing success or current_period", - response - ); - } - } catch (error) { - console.error("Error fetching analytics:", error); - } finally { - setLoading(false); - } - }; - - fetchAnalytics(); - }, [timeRange, selectedPeriod]); - - const trendChange = - data.previousTotal !== 0 - ? ((data.totalSpending - data.previousTotal) / data.previousTotal) * - 100 - : 0; - const trendDifference = data.totalSpending - data.previousTotal; - const previousLabel = getPreviousPeriodLabel(timeRange, selectedPeriod); - - if (loading) { - return ( -
    - {[...Array(4)].map((_, i) => ( - - -
    -
    -
    - -
    -
    -
    -
    - ))} -
    - ); - } - - const cards = [ - { - title: "Average Spending", - value: data.avgSpending, - change: data.avgChange, - icon: DollarSign, - description: `vs. ${previousLabel}`, - }, - { - title: "Maximum Spending", - value: data.maxSpending, - change: data.maxChange, - icon: TrendingUp, - description: "Highest transaction", - }, - { - title: "Minimum Spending", - value: data.minSpending, - change: data.minChange, - icon: TrendingDown, - description: "Lowest transaction", - }, - { - title: "Spending Trend", - value: Math.abs(trendDifference), - change: trendChange, - icon: Activity, - description: - trendChange >= 0 - ? `more than ${previousLabel}` - : `less than ${previousLabel}`, - isTrend: true, - }, - ]; - - return ( -
    - {cards.map((card, index) => { - const isPositive = card.change > 0; - const Icon = card.icon; - - return ( - - - - {card.title} - - - - -
    - {card.isTrend && ( - - {isPositive ? "+" : "-"} - - )} - {money(card.value, null, { decimals: 2 })} -
    -
    - {isPositive ? ( - - ) : ( - - )} - - {Math.abs(card.change).toFixed(1)}% - - - {card.description} - -
    -
    -
    - ); - })} -
    - ); -} diff --git a/frontend/src/components/analytics/spendingTrendChart.tsx b/frontend/src/components/analytics/spendingTrendChart.tsx deleted file mode 100644 index c65a8bd..0000000 --- a/frontend/src/components/analytics/spendingTrendChart.tsx +++ /dev/null @@ -1,260 +0,0 @@ -"use client"; - -import { - Area, - AreaChart, - CartesianGrid, - Tooltip, - XAxis, - YAxis, - ResponsiveContainer, - Legend, -} from "recharts"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { BarChart3 } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useMoney } from "@/lib/format"; -import { analyticsApi } from "@/lib/api/client"; - -interface SelectedPeriod { - year: number; - month?: number; - week?: number; -} - -interface SpendingTrendChartProps { - timeRange: "weekly" | "monthly" | "yearly"; - selectedPeriod: SelectedPeriod; -} - -interface ChartDataPoint { - date: string; - currentSpending: number; - previousSpending: number; - transactions: number; -} - -const getPeriodLabels = (timeRange: string, selectedPeriod: SelectedPeriod) => { - if (timeRange === "yearly") { - return { - current: `${selectedPeriod.year}`, - previous: `${selectedPeriod.year - 1}`, - }; - } else if (timeRange === "monthly" && selectedPeriod.month !== undefined) { - const months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - const prevMonth = - selectedPeriod.month === 0 ? 11 : selectedPeriod.month - 1; - const prevYear = - selectedPeriod.month === 0 - ? selectedPeriod.year - 1 - : selectedPeriod.year; - return { - current: `${months[selectedPeriod.month]} ${selectedPeriod.year}`, - previous: `${months[prevMonth]} ${prevYear}`, - }; - } else if (timeRange === "weekly" && selectedPeriod.week) { - const prevWeek = - selectedPeriod.week === 1 ? 52 : selectedPeriod.week - 1; - const prevYear = - selectedPeriod.week === 1 - ? selectedPeriod.year - 1 - : selectedPeriod.year; - return { - current: `Week ${selectedPeriod.week}, ${selectedPeriod.year}`, - previous: `Week ${prevWeek}, ${prevYear}`, - }; - } - return { current: "Current Period", previous: "Previous Period" }; -}; - -const CustomTooltip = ({ active, payload, label }: any) => { - const money = useMoney(); - if (active && payload && payload.length) { - return ( -
    -

    - {label} -

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

    - {entry.name === "currentSpending" - ? "Current Period" - : "Previous Period"} - : {money(entry.value, null, { decimals: 2 })} -

    - ))} -
    - ); - } - return null; -}; - -export default function SpendingTrendChart({ - timeRange, - selectedPeriod, -}: SpendingTrendChartProps) { - const money = useMoney(); - const [chartData, setChartData] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const fetchChartData = async () => { - try { - setLoading(true); - - const response = await analyticsApi.getTimeRangeAnalytics( - timeRange, - selectedPeriod.year, - selectedPeriod.month, - selectedPeriod.week - ); - - if (response.success && response.chart_data) { - setChartData(response.chart_data); - } - } catch (error) { - console.error("Error fetching chart data:", error); - } finally { - setLoading(false); - } - }; - - fetchChartData(); - }, [timeRange, selectedPeriod]); - - const periodLabels = getPeriodLabels(timeRange, selectedPeriod); - - if (loading) { - return ( - - -
    -
    - -
    -
    -
    - ); - } - - return ( - - - - - Spending Comparison - -

    - Comparing{" "} - - {periodLabels.current} - {" "} - vs{" "} - - {periodLabels.previous} - -

    -
    - - - - - - - - - - - - - - - - money(value)} - /> - } /> - - - - - - -
    - ); -} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index ca3afe8..bfc67b8 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -17,7 +17,6 @@ export const warmApi = (): void => { // identical requests fired together share one call. Any write clears it, and so does signing out. // ponytail: in-memory per tab (a reload refetches); move to sessionStorage if reloads matter. const CACHE_MS = 5 * 60_000; -const READ_ONLY_POSTS = ["/api/analytics/analyze"]; const cache = new Map }>(); export const clearApiCache = (): void => cache.clear(); @@ -110,8 +109,7 @@ const createApiClient = (): AxiosInstance => { client.interceptors.response.use( (response) => { - const { method, url = "" } = response.config; - if (method !== "get" && !READ_ONLY_POSTS.includes(url)) cache.clear(); + if (response.config.method !== "get") cache.clear(); return response; }, async (error: AxiosError) => { @@ -472,27 +470,6 @@ export const keysApi = { }, }; -/** Analytics — backend derives user from JWT. */ -export const analyticsApi = { - getTimeRangeAnalytics: async ( - timeRange: "weekly" | "monthly" | "yearly", - year: number, - month?: number, - week?: number - ) => { - const queryParams = new URLSearchParams(); - queryParams.append("time_range", timeRange); - queryParams.append("year", year.toString()); - if (month !== undefined) queryParams.append("month", month.toString()); - if (week !== undefined) queryParams.append("week", week.toString()); - const url = `/analytics/summary?${queryParams.toString()}`; - return cached(url, async () => (await apiClient.get(url)).data); - }, - - getAllTimeSummary: () => - cached("/analytics/summary", async () => (await apiClient.get("/analytics/summary")).data), -}; - export const transactionApi = { getTransactions: async (params?: { page?: number; @@ -581,28 +558,8 @@ export const transactionApi = { }, }; +// Analytics the redesigned pages still read: upcoming bills (Home, interim Bills) and the forecast line (Home). export const aiAnalyticsApi = { - getDashboard: () => - cached("/api/analytics/dashboard", async () => (await apiClient.get("/api/analytics/dashboard")).data), - - runAnalysis: async ( - options: { - includeFraud?: boolean; - includeForecast?: boolean; - includeRisk?: boolean; - useLlm?: boolean; - } = {} - ) => { - const body = { - include_fraud: options.includeFraud ?? true, - include_forecast: options.includeForecast ?? true, - include_risk: options.includeRisk ?? true, - use_llm: options.useLlm ?? false, - }; - return cached(`/api/analytics/analyze ${JSON.stringify(body)}`, - async () => (await apiClient.post("/api/analytics/analyze", body)).data); - }, - getReminders: async (daysAhead: number = 7) => { const response = await apiClient.get("/api/analytics/reminders", { params: { days_ahead: daysAhead }, @@ -610,55 +567,12 @@ export const aiAnalyticsApi = { return response.data; }, - getAnomalies: (riskLevel?: string) => - cached(`/api/analytics/anomalies ${riskLevel ?? ""}`, async () => (await apiClient.get("/api/analytics/anomalies", { - params: riskLevel ? { risk_level: riskLevel } : {}, - })).data), - // insights=false skips the LLM-written insight text (Home only draws the numbers); cached like the other reads getForecast: (daysAhead: number = 30, insights = true) => cached(`/api/analytics/forecast ${daysAhead} ${insights}`, async () => (await apiClient.get("/api/analytics/forecast", { params: insights ? { days_ahead: daysAhead } : { days_ahead: daysAhead, insights: 0 }, })).data), - - getRiskScore: async () => { - const response = await apiClient.get("/api/analytics/risk-score"); - return response.data; - }, - - getInsights: async (options: { - type?: string; - severity?: string; - limit?: number; - } = {}) => { - const params = { - ...(options.type && { type: options.type }), - ...(options.severity && { severity: options.severity }), - limit: options.limit || 20, - }; - return cached(`/api/analytics/insights ${JSON.stringify(params)}`, - async () => (await apiClient.get("/api/analytics/insights", { params })).data); - }, - - markInsightRead: async (insightId: number) => { - const response = await apiClient.post( - `/api/analytics/insights/${insightId}/read` - ); - return response.data; - }, - - getPatterns: async (patternType?: string) => { - const response = await apiClient.get("/api/analytics/patterns", { - params: patternType ? { pattern_type: patternType } : {}, - }); - return response.data; - }, - - healthCheck: async () => { - const response = await apiClient.get("/api/analytics/health"); - return response.data; - }, }; export const isAuthenticated = (): boolean => { From 7d3fdcdc6a3b11236cccbd9f27cf6893d000f0bc Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:53:04 +0530 Subject: [PATCH 28/47] fix: Spending period chips stay on one line on phones --- frontend/e2e/spending.spec.ts | 10 ++++++++++ frontend/src/app/(app)/spending/spending-content.tsx | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/e2e/spending.spec.ts b/frontend/e2e/spending.spec.ts index f54db8a..b1d4345 100644 --- a/frontend/e2e/spending.spec.ts +++ b/frontend/e2e/spending.spec.ts @@ -181,3 +181,13 @@ test("spending has no layout shift while loading", async ({ page }) => { await page.waitForTimeout(500); expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01); }); + +test("period chips stay on one line at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openSpending(page); + for (const name of ["This month", "Last month", "Custom"]) { + const box = (await page.getByRole("button", { name, exact: true }).boundingBox())!; + expect(box.height, name).toBeLessThan(32); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); diff --git a/frontend/src/app/(app)/spending/spending-content.tsx b/frontend/src/app/(app)/spending/spending-content.tsx index a099cd7..c242c1a 100644 --- a/frontend/src/app/(app)/spending/spending-content.tsx +++ b/frontend/src/app/(app)/spending/spending-content.tsx @@ -88,7 +88,7 @@ export default function SpendingContent() { aria-pressed={preset === c.value} onClick={() => setPreset(c.value)} className={cn( - "label-ui rounded-[7px] px-3 py-1 transition-colors", + "label-ui rounded-[7px] px-3 py-1 whitespace-nowrap transition-colors max-sm:px-2", preset === c.value ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground", )} > From 10f9d24a1d4892d1e35f4093f379ee3f9f33aaf5 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:53:35 +0530 Subject: [PATCH 29/47] docs: STORY 33, codebase guide and spec backlog for Phase 5b --- docs/CODEBASE-GUIDE.md | 9 +++++++++ docs/direction/STORY.md | 17 +++++++++++++++++ .../specs/2026-10-20-ui-revamp-design.md | 3 ++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index 8d9625d..29b7975 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -289,6 +289,15 @@ embeds the demo documents once per server and uploads the files in the backgroun `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/`. +- **Spending** (`app/(app)/spending/`, Phase 5b): "where did my money go, and is anything off?" A period control + (This month / Last month / 3M / YTD / Custom with two native date inputs) and a "Compare with" switch drive + everything. Hero: total, change vs the previous period, the shared `CumulativeChart` (or month bars for 3M/YTD) + and the last six months as bars. By category (click a row = `?category=`, "Sort them now" = Uncategorised), + Lumen noticed (Home's `LumenFound` with a title), Transactions (search, source filter, the category pill edits + in place via `PUT /transactions/`, CSV export of exactly the rows shown) and Top merchants. All maths is + pure in `lib/spending-math.ts` (tests: `e2e/spending-math.spec.ts`); data from `GET /transactions` (which now + includes `source`) and `/api/home/summary` (spikes link to `/spending?category=`). The old Analytics and + AI Insights pages, their components and the API calls only they used are gone. - **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, diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index 93df1a9..bceeb27 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -407,3 +407,20 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in 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. + +## 33. Spending answers "where did my money go?" (UI revamp, Phase 5b) +- **S:** `/spending` was two old pages stacked: two headings, cards that never loaded, and AI widgets that only showed + results saved by a heavy analysis run, so most users saw empty boxes. +- **T:** One calm page built from data Lumen already has: what was spent, against what, where it went, what looks off, + and every transaction, with nothing faked and nothing moving when a filter changes. +- **A:** Pure maths in `lib/spending-math.ts` (periods incl. custom ranges, categories with share and change, top + merchants, monthly totals) under unit tests first. Pulled Home's chart out into a shared `CumulativeChart` so both + heroes use one implementation; 3M/YTD switch to month bars of the same height. The category filter lives in the URL, + spikes found on Home now deep-link to it, and the table edits a category in place and exports exactly the rows shown + as CSV. The backend's only change: the transactions list says whether a row came from a statement. Deleted the old + Analytics/AI Insights pages, six components and ten unused API calls (about 1,900 lines). +- **R:** 13 Spending e2e checks (one heading, period switches keep the hero's box, CLS under 0.01 while loading, + filter/search/source, in-place edit without the row moving, CSV contents, chips on one line at 375 px) plus 5 maths + tests; the e2e fixture now copies its data per test after a PUT leaked between tests. A screenshot against the + approved mockup caught the wrapping period chips on phones, which a test then pinned. + 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 e933694..820a5ce 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -216,7 +216,7 @@ Small items found while building, parked here so they aren't lost. Tick them off - [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)* - [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)* +- [x] **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)* *(Done in 5b: Spending is one page.)* - [ ] **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)* - [x] **Phase 4.** Ctrl/⌘+K checks `e.key === "k"`, so Caps Lock or Shift misses it. Use `e.code === "KeyK"`. *(Phase 1 review)* - [ ] **Phase 6.** The sidebar cookie expires after 7 days (shadcn default), so a collapsed rail reopens after a week away. Extend it to about a year. *(Phase 1 review)* @@ -242,4 +242,5 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **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)* +- [ ] **Later.** Spending: the category edit (pencil) is hidden below `sm`, so phones can't recategorise; and "Lumen noticed" wraps beside its pill at 375px. *(5b)* - [ ] **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)* From 6d7479946ea5c79fde8f5d9d3c514759a43cf815 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:16:00 +0530 Subject: [PATCH 30/47] docs: Phase 5c (Bills) plan in detail --- .../plans/2026-10-22-ui-revamp-phase5.md | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md index b8d91ab..24d59a1 100644 --- a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md +++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md @@ -238,8 +238,33 @@ Branch: `ui-revamp/phase5b-spending`, stacked on `ui-revamp/phase5-pages` (#97). - STORY 34, codebase guide (Spending section), spec §12 tick (two `

    `s). - Full checks (backend, eval, lint, build into `.next-build`, e2e) green, screenshot vs `spending.html` at 1440 and 375, then PR into `ui-revamp/phase5-pages` (retarget to `refactor` as the stack lands). -## 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 5c — Bills & subscriptions (detailed) + +Branch: `ui-revamp/phase5c-bills`, stacked on `ui-revamp/phase5b-spending` (#98). Mockup `bills.html`; spec §4.3 and the builder-check decision on saved states. + +### Task C1: recurring API with saved states (backend, TDD) +- Model `RecurringState` (`recurring_states`: id, user_id, vendor_key = `ai.recurring._norm(vendor)`, state `cancelled|not_a_bill`, changed_at; unique (user_id, vendor_key)); created by `create_all` like `budgets`. +- `api/bills.py`, router `/api/bills`, registered in `asgi.py`: + - `GET /recurring` → `{success, items: [...find_recurring fields + "state": "active"|"cancelled"|"charged_again"], hidden: [{vendor, vendor_key}]}`. `not_a_bill` vendors leave `items` and go to `hidden`; `cancelled` shows `state: "cancelled"` unless `last_charge_date` is after `changed_at`'s date, then `"charged_again"`. + - `POST /recurring/state {vendor, state: "cancelled"|"not_a_bill"|"active"}` → upsert (active deletes the row); returns the new GET payload. +- Tests (`tests/test_api_bills.py`, setup like `test_api_budget.py`): per-user isolation; three monthly Netflix charges listed active; cancel → cancelled; a charge after cancel → charged_again; not_a_bill → in hidden only; active restores; vendor key ignores case/punctuation ("NETFLIX.com" vs "Netflix com"). + +### Task C2: Home reads bills from the recurring API +- `billsApi.recurring()` / `billsApi.setState(vendor, state)` in `lib/api/client.ts`; `use-home-data` maps items with `next_due_date` in the next 31 days, not `possibly_stopped`, state active → `Reminder` (`toBills`), replacing `/api/analytics/reminders` (which only had rows after the old analysis ran; spec §12 5c item). +- e2e fixture serves `/api/bills/recurring` built from `home.reminders` (vendor→vendor, date→next_due_date, amount→typical_amount, monthly) and records state POSTs; Home's bill tests stay unchanged in intent. +- Tests: Coming up / bill pins / calendar names come from the recurring route; a cancelled or stopped item is not a bill on Home. + +### Task C3: the Bills page +- `app/(app)/bills/` (replaces the interim list): `PageHeader` "Bills & subscriptions" with a ‹ month › switcher; brief ("Rent of ₹X is due . N subscriptions look stopped, about ₹Y a month." from real items only). +- Three tiles: Due in the next 30 days (sum + count), Recurring every month (monthly equivalents: weekly×52/12, yearly/12; active items only), Subscriptions that look stopped (`possibly_stopped`, count + ₹/month). +- Month calendar (Monday first) with each item's projected due days in the shown month (monthly: next_due_date's day clamped; weekly: every 7 days from next_due_date; yearly: if the month matches), today outlined, past-due days muted; beside it Upcoming (next 30 days) grouped This week / Next week / Later with group totals. +- Table with tabs Subscriptions / Bills / All (kind subscription vs bill+recurring): VendorChip, cadence, last charged, next, amount; pills: price rise (from → to), looks stopped, charged again after you cancelled. Actions per row: Mark as cancelled, Not a bill, How to cancel (→ `/ask?draft=How do I cancel ?`). Cancelled rows dimmed in a group at the bottom with Undo; a "Hidden (N)" footer lists not-a-bill vendors with Undo. +- Tests: tiles' numbers; calendar shows a bill on its projected day for this and next month; tabs filter; Mark as cancelled POSTs and moves the row to Cancelled without the table jumping (bounding box of the table's first row); Not a bill hides it and Undo brings it back; How to cancel href; month switching keeps the calendar box; CLS < 0.01 while loading. + +### Task C4: clean-up, docs, checks, PR +- Remove the interim `bills/upcoming-bills.tsx` and `aiAnalyticsApi.getReminders` if unused. +- STORY 34, guide (Bills), spec §12 (tick the 5c item). +- Full checks green, screenshot vs `bills.html` at 1440 and 375, PR stacked on #98. ## 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. From 58f58f4b60e286c29bfcfdef692006197d00c2c9 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:19:21 +0530 Subject: [PATCH 31/47] feat: recurring charges API with saved cancelled / not-a-bill choices --- backend/api/bills.py | 72 +++++++++++++++++++++ backend/asgi.py | 4 +- backend/models/__init__.py | 15 +++++ backend/tests/test_api_bills.py | 107 ++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 backend/api/bills.py create mode 100644 backend/tests/test_api_bills.py diff --git a/backend/api/bills.py b/backend/api/bills.py new file mode 100644 index 0000000..1add729 --- /dev/null +++ b/backend/api/bills.py @@ -0,0 +1,72 @@ +"""UI revamp Phase 5c: recurring charges for the Bills page and Home's "Coming up". + +Detection is ai/recurring.find_recurring over the user's transactions (pure, no LLM). The user's choices live in +recurring_states: "not_a_bill" moves a vendor to `hidden`, "cancelled" keeps it listed as cancelled unless it was +charged again after the cancel ("charged_again"). Posting "active" forgets the choice. +""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy import select, text +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session + +from ai.recurring import _norm, find_recurring +from api.deps import current_user, rate_limit +from api.profile import get_engine + +router = APIRouter(prefix="/api/bills", tags=["bills"]) + + +class StateChange(BaseModel): + vendor: str = Field(min_length=1, max_length=200) + state: Literal["cancelled", "not_a_bill", "active"] + + +def _recurring(engine: Engine, user_id: str, today: date) -> dict: + from models import RecurringState + + with engine.connect() as conn: + rows = [dict(r) for r in conn.execute(text( + "SELECT vendor_name, date, total_amount, category FROM transactions WHERE user_id = :u"), {"u": user_id}).mappings()] + with Session(engine) as session: + states = {s.vendor_key: s for s in session.scalars(select(RecurringState).where(RecurringState.user_id == user_id))} + items, hidden = [], [] + for item in find_recurring(rows, today): + key = _norm(item["vendor"]) + saved = states.get(key) + if saved and saved.state == "not_a_bill": + hidden.append({"vendor": item["vendor"], "vendor_key": key}) + continue + state = "active" + if saved and saved.state == "cancelled": + state = "charged_again" if item["last_charge_date"] > saved.changed_at.date().isoformat() else "cancelled" + items.append({**item, "state": state}) + return {"success": True, "items": items, "hidden": hidden} + + +@router.get("/recurring", dependencies=[Depends(rate_limit())]) +def recurring(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): + return _recurring(engine, claims["sub"], date.today()) + + +@router.post("/recurring/state", dependencies=[Depends(rate_limit())]) +def set_state(body: StateChange, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): + from models import RecurringState + + user_id, key = claims["sub"], _norm(body.vendor) + with Session(engine) as session, session.begin(): + row = session.scalars(select(RecurringState).where(RecurringState.user_id == user_id, + RecurringState.vendor_key == key)).first() + if body.state == "active": + if row: + session.delete(row) + elif row: + row.state, row.changed_at = body.state, datetime.utcnow() + else: + session.add(RecurringState(user_id=user_id, vendor_key=key, state=body.state)) + return _recurring(engine, user_id, date.today()) diff --git a/backend/asgi.py b/backend/asgi.py index 5baaec3..b552a38 100644 --- a/backend/asgi.py +++ b/backend/asgi.py @@ -20,7 +20,7 @@ from fastapi.middleware.cors import CORSMiddleware import app as flask_module -from api import agent, budget, demo, documents, errors, files, home, keys, profile, review, statements +from api import agent, bills, budget, demo, documents, errors, files, home, keys, profile, review, statements from api.deps import llm_deadline from config import Config @@ -90,5 +90,5 @@ def create_app(*routers: APIRouter) -> FastAPI: app = create_app(documents.router, agent.router, demo.router, review.router, files.router, profile.router, statements.router, - budget.router, home.router, + budget.router, bills.router, home.router, keys.router) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index f220616..2b193f7 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -314,6 +314,21 @@ class Budget(db.Model): __table_args__ = (db.UniqueConstraint("user_id", "category", name="u_budget_user_category"),) +class RecurringState(db.Model): + """What the user said about a recurring charge on the Bills page (UI revamp Phase 5c): "cancelled" keeps it listed + (and flags a charge after the cancel), "not_a_bill" hides it. Keyed by ai.recurring._norm(vendor), the same key + the detection groups charges by. No row = active.""" + + __tablename__ = "recurring_states" + + id = db.Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = db.Column(String(36), nullable=False, index=True) + vendor_key = db.Column(db.String, nullable=False) + state = db.Column(db.String(20), nullable=False) # cancelled | not_a_bill + changed_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + __table_args__ = (db.UniqueConstraint("user_id", "vendor_key", name="u_recurring_state_user_vendor"),) + class AuditEvent(db.Model): """Append-only log of proposals and decisions (who, what, when).""" diff --git a/backend/tests/test_api_bills.py b/backend/tests/test_api_bills.py new file mode 100644 index 0000000..acf4a08 --- /dev/null +++ b/backend/tests/test_api_bills.py @@ -0,0 +1,107 @@ +"""UI revamp Phase 5c: recurring charges for the Bills page, with the user's saved choices (cancelled / not a bill).""" +from datetime import date, timedelta + +import jwt +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +A = "7a1c0000-0000-4000-8000-0000000000aa" +B = "7a1c0000-0000-4000-8000-0000000000bb" + + +def bearer(sub): + return {"Authorization": "Bearer " + jwt.encode({"sub": sub, "email": f"{sub}@x.com"}, "k" * 32, algorithm="HS256")} + + +def ago(days): + return (date.today() - timedelta(days=days)).isoformat() + + +@pytest.fixture +def setup(tmp_path, monkeypatch): + import models # noqa: F401 + import utils.auth + from api import bills + from asgi import create_app + from models.database import db as flask_db + from utils.limiter import limiter + + monkeypatch.setattr(utils.auth, "verify_token", lambda t: jwt.decode(t, options={"verify_signature": False})) + monkeypatch.setattr(limiter, "enabled", False) + engine = create_engine(f"sqlite:///{tmp_path / 'bills.db'}") + flask_db.metadata.create_all(engine) + app = create_app(bills.router) + app.dependency_overrides[bills.get_engine] = lambda: engine + return TestClient(app), engine + + +def charge(engine, user, vendor, when, amount=649.0, category="Entertainment"): + from models import Transaction + + with Session(engine) as s, s.begin(): + s.add(Transaction(user_id=user, vendor_name=vendor, invoice_number=f"{user}-{vendor}-{when}", date=when, + total_amount=amount, category=category)) + + +def monthly(engine, user, vendor, amount=649.0, category="Entertainment", latest=10): + for n in (latest + 60, latest + 30, latest): + charge(engine, user, vendor, ago(n), amount, category) + + +def items(c, user=A): + r = c.get("/api/bills/recurring", headers=bearer(user)) + assert r.status_code == 200, r.text + return r.json() + + +def test_recurring_lists_active_charges_per_user(setup): + c, engine = setup + monthly(engine, A, "Netflix") + monthly(engine, B, "Spotify", 119) + [item] = items(c)["items"] + assert item["vendor"] == "Netflix" and item["cadence"] == "monthly" and item["state"] == "active" + assert items(c)["hidden"] == [] + assert [i["vendor"] for i in items(c, B)["items"]] == ["Spotify"] + + +def test_cancel_then_charged_again(setup): + c, engine = setup + monthly(engine, A, "Netflix") + r = c.post("/api/bills/recurring/state", json={"vendor": "Netflix", "state": "cancelled"}, headers=bearer(A)) + assert r.status_code == 200 and r.json()["items"][0]["state"] == "cancelled" + # cancelled yesterday, charged again today: the user should know the cancel didn't stick + from models import RecurringState + + charge(engine, A, "Netflix", date.today().isoformat()) + with Session(engine) as s, s.begin(): + row = s.query(RecurringState).one() + row.changed_at = row.changed_at - timedelta(days=1) + assert items(c)["items"][0]["state"] == "charged_again" + + +def test_not_a_bill_hides_and_active_restores(setup): + c, engine = setup + monthly(engine, A, "Netflix") + r = c.post("/api/bills/recurring/state", json={"vendor": "NETFLIX", "state": "not_a_bill"}, headers=bearer(A)) + body = r.json() + assert body["items"] == [] and [h["vendor"] for h in body["hidden"]] == ["Netflix"] + r = c.post("/api/bills/recurring/state", json={"vendor": "Netflix", "state": "active"}, headers=bearer(A)) + assert [i["state"] for i in r.json()["items"]] == ["active"] and r.json()["hidden"] == [] + + +def test_states_are_per_user_and_match_vendor_spelling(setup): + c, engine = setup + monthly(engine, A, "Netflix.com") + monthly(engine, B, "Netflix.com") + c.post("/api/bills/recurring/state", json={"vendor": "netflix com", "state": "cancelled"}, headers=bearer(A)) + assert items(c)["items"][0]["state"] == "cancelled" + assert items(c, B)["items"][0]["state"] == "active" + + +def test_state_input_is_validated(setup): + c, _ = setup + assert c.post("/api/bills/recurring/state", json={"vendor": "X", "state": "deleted"}, headers=bearer(A)).status_code == 422 + assert c.post("/api/bills/recurring/state", json={"vendor": "", "state": "cancelled"}, headers=bearer(A)).status_code == 422 + assert c.get("/api/bills/recurring").status_code == 401 From d8a5fd442130078616f3d6acf9e3d46ceb95ca28 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:24:42 +0530 Subject: [PATCH 32/47] feat: Home's bills come from the recurring charges API --- frontend/e2e/fixtures.ts | 21 ++++++++++ frontend/e2e/home.spec.ts | 13 ++++++ .../src/app/(app)/bills/upcoming-bills.tsx | 19 ++++----- frontend/src/app/(app)/home/use-home-data.ts | 41 ++++++++----------- frontend/src/lib/api/client.ts | 26 ++++++++++++ 5 files changed, 85 insertions(+), 35 deletions(-) diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index b1a2a23..f13ef0b 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -8,6 +8,8 @@ import type { Page } from "@playwright/test"; export type HomeFixture = { txns?: { id: string; vendor_name: string; date: string; total_amount: number; category: string | null; created_at: string | null }[]; reminders?: { id: string; vendor: string; amount: number; date: string; type: string; category?: string }[]; + /** GET /api/bills/recurring items as the API sends them; default: built from `reminders` (monthly, active) */ + recurring?: Record[]; forecast?: { dates: string[]; values: number[] } | "fail"; delayMs?: number; forecastDelayMs?: number; @@ -40,6 +42,7 @@ export async function signIn( ): Promise { // a copy: routes like the transactions PUT change it, and tests share their fixture objects const home: HomeFixture = structuredClone(opts.home ?? {}); + const billStates = new Map(); // vendor (lower case) → cancelled | not_a_bill | active // a tiny stateful chat backend: POST /chat creates or extends a thread, the list and messages follow const threads = (opts.chat?.threads ?? []).map((t) => ({ ...t, messages: [...(t.messages ?? [])] })); let nextThread = 1; @@ -73,6 +76,24 @@ export async function signIn( const url = new URL(route.request().url()); const path = url.pathname; if (home.delayMs) await new Promise((r) => setTimeout(r, home.delayMs)); + // recurring charges (api/bills.py): the fixture's items, or simple monthly ones built from `reminders`; state + // changes are kept, like the real table + if (path === "/api/bills/recurring" || path === "/api/bills/recurring/state") { + if (route.request().method() === "POST") { + const { vendor, state } = route.request().postDataJSON() as { vendor: string; state: string }; + billStates.set(vendor.toLowerCase(), state); + } + const all = (home.recurring ?? (home.reminders ?? []).filter((r) => r.vendor).map((r) => ({ + vendor: r.vendor, category: r.category ?? null, cadence: "monthly", typical_amount: r.amount, + last_charge_date: r.date, next_due_date: r.date, charges_count: 3, price_change: null, kind: "subscription", + possibly_stopped: false, state: "active", + }))) as { vendor: string; state: string }[]; + const items = all + .map((i) => ({ ...i, state: billStates.get(i.vendor.toLowerCase()) ?? i.state })) + .filter((i) => i.state !== "not_a_bill"); + const hidden = all.filter((i) => billStates.get(i.vendor.toLowerCase()) === "not_a_bill").map((i) => ({ vendor: i.vendor, vendor_key: i.vendor.toLowerCase() })); + return route.fulfill({ json: { success: true, items, hidden } }); + } // category edits from Spending's table (stateful, like the real PUT) const edit = path.match(/^\/transactions\/([^/]+)$/); if (edit && route.request().method() === "PUT") { diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 93beeca..66e04fe 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -523,3 +523,16 @@ for (const [day, range] of [["2026-10-26", "1M"], ["2026-10-04", "1M"], ["2026-1 } }); } + +test("home's bills come from the recurring charges, active ones only", async ({ page }) => { + const item = (vendor: string, days: number, extra: object = {}) => ({ + vendor, category: "Entertainment", cadence: "monthly", typical_amount: 649, last_charge_date: inDays(days - 30), + next_due_date: inDays(days), charges_count: 3, price_change: null, kind: "subscription", possibly_stopped: false, state: "active", ...extra, + }); + await openHome(page, { + ...typical, + recurring: [item("Netflix", 2), item("Spotify", 3, { state: "cancelled" }), item("Gym", 4, { possibly_stopped: true }), item("Rent", 45)], + }); + await expect(page.getByTestId("bill-row")).toHaveCount(1); + await expect(page.getByTestId("bill-row")).toContainText("Netflix"); +}); diff --git a/frontend/src/app/(app)/bills/upcoming-bills.tsx b/frontend/src/app/(app)/bills/upcoming-bills.tsx index 5e75088..31a212f 100644 --- a/frontend/src/app/(app)/bills/upcoming-bills.tsx +++ b/frontend/src/app/(app)/bills/upcoming-bills.tsx @@ -1,19 +1,18 @@ "use client"; import { useEffect, useState } from "react"; -import { aiAnalyticsApi } from "@/lib/api/client"; +import { billsApi } from "@/lib/api/client"; import { BillsCard } from "../home/_components/bills-card"; -import { toReminders, type Reminder } from "../home/use-home-data"; +import { toBills, 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. +// Interim Bills (replaced in UI revamp Phase 5c Task C3): the same upcoming-bills list as Home. export function UpcomingBills() { - const [reminders, setReminders] = useState(null); + const [bills, setBills] = useState(null); + const today = new Date(); + const day = new Date(today.getFullYear(), today.getMonth(), today.getDate()); useEffect(() => { - aiAnalyticsApi.getReminders(62).then( - (r: { reminders?: Parameters[0] }) => setReminders(toReminders(r.reminders ?? [])), - () => setReminders([]), - ); + billsApi.recurring().then((r) => setBills(toBills(r.items, day)), () => setBills([])); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const today = new Date(); - return ; + return ; } diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts index 6dbf240..da74c73 100644 --- a/frontend/src/app/(app)/home/use-home-data.ts +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -1,32 +1,21 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { aiAnalyticsApi, budgetApi, homeApi, transactionApi, type BudgetState, type HomeSummary } from "@/lib/api/client"; -import { periods, type Range, type Txn } from "@/lib/home-math"; +import { aiAnalyticsApi, billsApi, budgetApi, homeApi, transactionApi, type BudgetState, type HomeSummary, type RecurringItem } from "@/lib/api/client"; +import { parseDay, 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, - }] - : [], - ); +// Home's bills: active recurring charges due in the next 31 days (api/bills.py). Cancelled ones and ones that look +// stopped aren't bills any more. +export function toBills(items: RecurringItem[], today: Date): Reminder[] { + const until = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 31); + return items + .filter((r) => r.state === "active" && !r.possibly_stopped) + .filter((r) => { + const due = parseDay(r.next_due_date); + return due >= today && due <= until; + }) + .map((r) => ({ id: r.vendor, vendor: r.vendor, amount: r.typical_amount, date: r.next_due_date, type: "upcoming", category: r.category ?? undefined })); } export type Forecast = { dates: string[]; values: number[] } | null; export type HomeData = { @@ -106,7 +95,7 @@ export function useHomeData(range: Range, today: Date): HomeData { useEffect(() => { let live = true; Promise.allSettled([ - aiAnalyticsApi.getReminders(31).then((r: { reminders?: ApiReminder[] }) => toReminders(r.reminders ?? [])), + billsApi.recurring().then((r) => toBills(r.items, today)), homeApi.summary(), budgetApi.get(), ]).then(([reminders, summary, budget]) => { @@ -121,6 +110,8 @@ export function useHomeData(range: Range, today: Date): HomeData { return () => { live = false; }; + // today is a fresh Date each render; the day is what matters + // eslint-disable-next-line react-hooks/exhaustive-deps }, [day]); const reload = useCallback(() => { diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index bfc67b8..66ec2c8 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -673,6 +673,32 @@ export interface BudgetState { categories: { category: string; limit: number; spent: number }[]; } +/** One recurring charge (backend ai/recurring.find_recurring) with the user's saved choice (api/bills.py). */ +export interface RecurringItem { + vendor: string; + category: string | null; + cadence: "weekly" | "monthly" | "yearly"; + typical_amount: number; + last_charge_date: string; + next_due_date: string; + charges_count: number; + price_change: { previous: number; latest: number; percent: number } | null; + kind: "subscription" | "bill" | "recurring"; + possibly_stopped: boolean; + state: "active" | "cancelled" | "charged_again"; +} +export interface RecurringState { + items: RecurringItem[]; + /** vendors the user marked "not a bill" */ + hidden: { vendor: string; vendor_key: string }[]; +} + +export const billsApi = { + recurring: (): Promise => cached("/api/bills/recurring", async () => (await apiClient.get("/api/bills/recurring")).data), + setState: async (vendor: string, state: "cancelled" | "not_a_bill" | "active"): Promise => + (await apiClient.post("/api/bills/recurring/state", { vendor, state })).data, +}; + export const homeApi = { summary: (): Promise => cached("/api/home/summary", async () => (await apiClient.get("/api/home/summary")).data), }; From 1fa94ffd26e377de715d13f3cbdc2ec5106b21da Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:26:11 +0530 Subject: [PATCH 33/47] feat: bills maths (monthly equivalents, due days, upcoming groups, tiles) --- frontend/e2e/bills-math.spec.ts | 46 ++++++++++++++++++++++++ frontend/src/lib/bills-math.ts | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 frontend/e2e/bills-math.spec.ts create mode 100644 frontend/src/lib/bills-math.ts diff --git a/frontend/e2e/bills-math.spec.ts b/frontend/e2e/bills-math.spec.ts new file mode 100644 index 0000000..58bf5d0 --- /dev/null +++ b/frontend/e2e/bills-math.spec.ts @@ -0,0 +1,46 @@ +// Node-only unit tests (no browser) for the Bills page's calculations (UI revamp Phase 5c). +import { test, expect } from "@playwright/test"; +import { dueDays, monthlyEquivalent, tiles, upcoming } from "../src/lib/bills-math"; +import type { RecurringItem } from "../src/lib/api/client"; + +const item = (vendor: string, cadence: RecurringItem["cadence"], next: string, amount: number, extra: Partial = {}): RecurringItem => ({ + vendor, category: "Bills", cadence, typical_amount: amount, last_charge_date: "2026-09-24", next_due_date: next, + charges_count: 3, price_change: null, kind: "bill", possibly_stopped: false, state: "active", ...extra, +}); +const today = new Date(2026, 9, 22); // Thu 22 Oct 2026 + +test("monthlyEquivalent spreads weekly and yearly charges per month", () => { + expect(monthlyEquivalent(item("Rent", "monthly", "2026-10-24", 25000))).toBe(25000); + expect(monthlyEquivalent(item("Maid", "weekly", "2026-10-24", 1200))).toBe(5200); + expect(monthlyEquivalent(item("Prime", "yearly", "2026-11-14", 1500))).toBe(125); +}); + +test("dueDays projects each cadence onto a month", () => { + expect(dueDays(item("Rent", "monthly", "2026-10-24", 1), 2026, 9)).toEqual([24]); + expect(dueDays(item("Rent", "monthly", "2026-10-24", 1), 2026, 10)).toEqual([24]); + expect(dueDays(item("Gym", "monthly", "2026-10-31", 1), 2026, 10)).toEqual([30]); // November has 30 days + expect(dueDays(item("Maid", "weekly", "2026-10-24", 1), 2026, 9)).toEqual([3, 10, 17, 24, 31]); + expect(dueDays(item("Prime", "yearly", "2026-11-14", 1), 2026, 10)).toEqual([14]); + expect(dueDays(item("Prime", "yearly", "2026-11-14", 1), 2026, 9)).toEqual([]); +}); + +test("upcoming groups the next 30 days into this week, next week and later", () => { + const g = upcoming([item("Rent", "monthly", "2026-10-24", 25000), item("Jio", "monthly", "2026-10-27", 999), item("Netflix", "monthly", "2026-11-12", 649), item("Old", "monthly", "2026-12-30", 5)], today); + expect(g.map((x) => [x.label, x.rows.map((r) => r.vendor), x.total])).toEqual([ + ["This week", ["Rent"], 25000], + ["Next week", ["Jio"], 999], + ["Later", ["Netflix"], 649], + ]); +}); + +test("tiles: due soon, recurring per month, and subscriptions that look stopped", () => { + const t = tiles([ + item("Rent", "monthly", "2026-10-24", 25000), + item("Prime", "yearly", "2027-03-14", 1500, { kind: "subscription" }), + item("Spotify", "monthly", "2026-08-02", 119, { kind: "subscription", possibly_stopped: true }), + item("Gym", "monthly", "2026-10-30", 1500, { state: "cancelled" }), + ], today); + expect(t.dueSoon).toEqual({ total: 25000, count: 1 }); + expect(t.perMonth).toBe(25125); // rent + prime/12; stopped and cancelled ones don't count + expect(t.stopped).toEqual({ count: 1, perMonth: 119 }); +}); diff --git a/frontend/src/lib/bills-math.ts b/frontend/src/lib/bills-math.ts new file mode 100644 index 0000000..78bf36f --- /dev/null +++ b/frontend/src/lib/bills-math.ts @@ -0,0 +1,63 @@ +// Pure calculations behind the Bills page (UI revamp Phase 5c). No React, no fetching; unit tests in +// e2e/bills-math.spec.ts. Input is GET /api/bills/recurring's items. +import type { RecurringItem } from "@/lib/api/client"; +import { parseDay } from "@/lib/home-math"; + +const DAY = 86_400_000; +const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); +// still being charged: not cancelled (a "charged again" one is) and not looking stopped +const live = (i: RecurringItem) => i.state !== "cancelled" && !i.possibly_stopped; + +export function monthlyEquivalent(i: RecurringItem): number { + return i.cadence === "weekly" ? (i.typical_amount * 52) / 12 : i.cadence === "yearly" ? i.typical_amount / 12 : i.typical_amount; +} + +// The days of `month` (0-based) a charge is expected on, projected from its next due date by its cadence. +export function dueDays(i: RecurringItem, year: number, month: number): number[] { + const next = parseDay(i.next_due_date); + const last = new Date(year, month + 1, 0).getDate(); + if (i.cadence === "monthly") return [Math.min(next.getDate(), last)]; + if (i.cadence === "yearly") return next.getMonth() === month ? [next.getDate()] : []; + const first = new Date(year, month, 1); + const offset = ((Math.round((next.getTime() - first.getTime()) / DAY) % 7) + 7) % 7; + const out: number[] = []; + for (let d = 1 + offset; d <= last; d += 7) out.push(d); + return out; +} + +type Due = { vendor: string; date: Date; amount: number; item: RecurringItem }; + +// Every expected charge from today to 30 days out (weekly ones more than once), soonest first. +function dueSoon(items: RecurringItem[], today: Date): Due[] { + const until = addDays(today, 30); + const out: Due[] = []; + for (const i of items.filter(live)) { + const step = i.cadence === "weekly" ? 7 : null; + for (let d = parseDay(i.next_due_date); d <= until; d = step ? addDays(d, step) : addDays(until, 1)) + if (d >= today) out.push({ vendor: i.vendor, date: d, amount: i.typical_amount, item: i }); + } + return out.sort((a, b) => a.date.getTime() - b.date.getTime()); +} + +// The next 30 days as This week (to Sunday), Next week and Later, each with its total; empty groups are left out. +export function upcoming(items: RecurringItem[], today: Date): { label: string; rows: Due[]; total: number }[] { + const sunday = addDays(today, 6 - ((today.getDay() + 6) % 7)); + const nextSunday = addDays(sunday, 7); + const groups = [ + { label: "This week", rows: [] as Due[] }, + { label: "Next week", rows: [] as Due[] }, + { label: "Later", rows: [] as Due[] }, + ]; + for (const d of dueSoon(items, today)) groups[d.date <= sunday ? 0 : d.date <= nextSunday ? 1 : 2].rows.push(d); + return groups.filter((g) => g.rows.length).map((g) => ({ ...g, total: g.rows.reduce((s, r) => s + r.amount, 0) })); +} + +export function tiles(items: RecurringItem[], today: Date) { + const soon = dueSoon(items, today); + const stopped = items.filter((i) => i.possibly_stopped && i.kind === "subscription" && i.state !== "cancelled"); + return { + dueSoon: { total: soon.reduce((s, d) => s + d.amount, 0), count: soon.length }, + perMonth: Math.round(items.filter(live).reduce((s, i) => s + monthlyEquivalent(i), 0)), + stopped: { count: stopped.length, perMonth: Math.round(stopped.reduce((s, i) => s + monthlyEquivalent(i), 0)) }, + }; +} From 35dd34ce53c4d6d723464e2f7343d6a7ce2338ff Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:37:05 +0530 Subject: [PATCH 34/47] feat: Bills & subscriptions page (tiles, bill calendar, upcoming, cancel / not a bill / how to cancel) --- frontend/e2e/bills.spec.ts | 114 +++++++ frontend/e2e/shell.spec.ts | 2 +- .../src/app/(app)/bills/bills-content.tsx | 300 ++++++++++++++++++ frontend/src/app/(app)/bills/page.tsx | 12 +- .../src/app/(app)/bills/upcoming-bills.tsx | 18 -- 5 files changed, 417 insertions(+), 29 deletions(-) create mode 100644 frontend/e2e/bills.spec.ts create mode 100644 frontend/src/app/(app)/bills/bills-content.tsx delete mode 100644 frontend/src/app/(app)/bills/upcoming-bills.tsx diff --git a/frontend/e2e/bills.spec.ts b/frontend/e2e/bills.spec.ts new file mode 100644 index 0000000..ed716ad --- /dev/null +++ b/frontend/e2e/bills.spec.ts @@ -0,0 +1,114 @@ +import { test, expect, type Page } from "@playwright/test"; +import { signIn, type HomeFixture } from "./fixtures"; + +const now = new Date(); +const inDays = (n: number) => { + const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + n); + return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}`; +}; +const item = (vendor: string, next: number, amount: number, kind: string, extra: object = {}) => ({ + vendor, category: kind === "bill" ? "Rent" : "Entertainment", cadence: "monthly", typical_amount: amount, + last_charge_date: inDays(next - 30), next_due_date: inDays(next), charges_count: 3, price_change: null, kind, + possibly_stopped: false, state: "active", ...extra, +}); +const sample: HomeFixture = { + recurring: [ + item("Rent", 2, 25000, "bill"), + item("Netflix", 9, 649, "subscription", { price_change: { previous: 499, latest: 649, percent: 30.1 } }), + item("Spotify", -50, 119, "subscription", { possibly_stopped: true }), + { ...item("Prime", 120, 1500, "subscription"), cadence: "yearly" }, + ], +}; +async function openBills(page: Page, home: HomeFixture = sample) { + await signIn(page, { home }); + await page.goto("/bills"); +} +const table = (page: Page) => page.getByTestId("recurring-table"); + +test("bills: one heading and the three tiles", async ({ page }) => { + await openBills(page); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Bills & subscriptions"); + await expect(page.getByTestId("tile-due")).toContainText("₹25,649"); + await expect(page.getByTestId("tile-due")).toContainText("2 bills"); + await expect(page.getByTestId("tile-monthly")).toContainText("₹25,774"); + await expect(page.getByTestId("tile-stopped")).toContainText("₹119"); + await expect(page.getByTestId("tile-stopped")).toContainText("Spotify"); +}); + +test("the calendar shows each bill on its day, month after month, without moving", async ({ page }) => { + await openBills(page); + const rent = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2); + const cal = page.getByTestId("bill-calendar"); + const before = (await cal.boundingBox())!; + if (rent.getMonth() !== now.getMonth()) await page.getByRole("button", { name: "Next month" }).click(); + await expect(cal.getByTestId(`bill-day-${rent.getDate()}`)).toContainText("Rent"); + await page.getByRole("button", { name: "Next month" }).click(); + await expect(cal.getByTestId(`bill-day-${Math.min(rent.getDate(), 28)}`)).toBeVisible(); + await expect(cal).toContainText("Rent"); + const after = (await cal.boundingBox())!; + expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1); +}); + +test("upcoming groups the next 30 days", async ({ page }) => { + await openBills(page); + const up = page.getByTestId("upcoming"); + await expect(up).toContainText("Rent"); + await expect(up).toContainText("Netflix"); + await expect(up).not.toContainText("Spotify"); // looks stopped +}); + +test("tabs: subscriptions vs bills", async ({ page }) => { + await openBills(page); + await table(page).getByRole("tab", { name: /Subscriptions/ }).click(); + await expect(table(page).getByTestId("recurring-row")).toHaveCount(3); + await expect(table(page)).not.toContainText("Rent"); + await table(page).getByRole("tab", { name: /^Bills/ }).click(); + await expect(table(page).getByTestId("recurring-row")).toHaveCount(1); + await expect(table(page).getByTestId("recurring-row")).toContainText("Rent"); + await expect(table(page).getByRole("tab", { name: /Subscriptions/ })).toContainText("3"); +}); + +test("mark as cancelled moves the row into Cancelled, and undo brings it back", async ({ page }) => { + const posts: unknown[] = []; + page.on("request", (r) => r.method() === "POST" && r.url().includes("/api/bills/recurring/state") && posts.push(r.postDataJSON())); + await openBills(page); + const netflix = table(page).getByTestId("recurring-row").filter({ hasText: "Netflix" }); + await expect(netflix).toContainText("₹499 → ₹649"); + await netflix.getByRole("button", { name: "Mark as cancelled" }).click(); + await expect(page.getByTestId("cancelled-group")).toContainText("Netflix"); + expect(posts).toEqual([{ vendor: "Netflix", state: "cancelled" }]); + await page.getByTestId("cancelled-group").getByRole("button", { name: "Undo" }).click(); + await expect(page.getByTestId("cancelled-group")).toHaveCount(0); + expect(posts.at(-1)).toEqual({ vendor: "Netflix", state: "active" }); +}); + +test("not a bill hides it; hidden lists it with undo", async ({ page }) => { + await openBills(page); + await table(page).getByTestId("recurring-row").filter({ hasText: "Rent" }).getByRole("button", { name: "Not a bill" }).click(); + await expect(table(page).getByTestId("recurring-row").filter({ hasText: "Rent" })).toHaveCount(0); + await page.getByRole("button", { name: "Hidden (1)" }).click(); + await expect(page.getByTestId("hidden-list")).toContainText("Rent"); + await page.getByTestId("hidden-list").getByRole("button", { name: "Undo" }).click(); + await expect(table(page).getByTestId("recurring-row").filter({ hasText: "Rent" })).toHaveCount(1); +}); + +test("how to cancel opens Ask with the question typed", async ({ page }) => { + await openBills(page); + const link = table(page).getByTestId("recurring-row").filter({ hasText: "Netflix" }).getByRole("link", { name: "How to cancel" }); + await expect(link).toHaveAttribute("href", "/ask?draft=How%20do%20I%20cancel%20Netflix%3F"); +}); + +test("bills has no layout shift while loading", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.addInitScript(() => { + (window as unknown as { cls: number }).cls = 0; + new PerformanceObserver((list) => { + for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[]) + if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value; + }).observe({ type: "layout-shift", buffered: true }); + }); + await openBills(page, { ...sample, delayMs: 1500 }); + await expect(page.getByTestId("brief")).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01); +}); diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 75656f9..d010ecf 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -272,6 +272,6 @@ test("spending and bills show no cards that can never load", async ({ page }) => 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.getByTestId("recurring-table")).toContainText("Netflix"); await expect(page.getByText(/Could not load/)).toHaveCount(0); }); diff --git a/frontend/src/app/(app)/bills/bills-content.tsx b/frontend/src/app/(app)/bills/bills-content.tsx new file mode 100644 index 0000000..09bb917 --- /dev/null +++ b/frontend/src/app/(app)/bills/bills-content.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { PageCard } from "@/components/page/page-card"; +import { PageHeader } from "@/components/page/page-header"; +import { VendorChip } from "@/components/page/vendor-chip"; +import { billsApi, type RecurringItem, type RecurringState } from "@/lib/api/client"; +import { dueDays, tiles, upcoming } from "@/lib/bills-math"; +import { useMoney } from "@/lib/format"; +import { parseDay } from "@/lib/home-math"; +import { toast } from "@/lib/toast"; +import { cn } from "@/lib/utils"; +import { Brief } from "../home/_components/brief"; + +type Tab = "subscriptions" | "bills" | "all"; +const TAB_LABEL: Record = { all: "All", subscriptions: "Subscriptions", bills: "Bills" }; +const monthYear = (y: number, m: number) => new Date(y, m, 1).toLocaleString("en-US", { month: "long", year: "numeric" }); +const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" }); +const weekday = (d: Date) => d.toLocaleString("en-GB", { weekday: "long" }); +const isSubscription = (i: RecurringItem) => i.kind === "subscription"; + +// Bills & subscriptions (UI revamp Phase 5c, spec 2026-10-22 §4.3): what's due, what recurs, what can I cut? +// Everything comes from GET /api/bills/recurring; the user's "cancelled" / "not a bill" choices are saved there. +export default function BillsContent() { + const money = useMoney(); + const today = useMemo(() => { + const n = new Date(); + return new Date(n.getFullYear(), n.getMonth(), n.getDate()); + }, []); + const [data, setData] = useState(null); + const [shown, setShown] = useState({ y: today.getFullYear(), m: today.getMonth() }); + const [tab, setTab] = useState("all"); + const [showHidden, setShowHidden] = useState(false); + + useEffect(() => { + billsApi.recurring().then(setData, () => setData({ items: [], hidden: [] })); + }, []); + + async function choose(vendor: string, state: "cancelled" | "not_a_bill" | "active") { + try { + setData(await billsApi.setState(vendor, state)); + } catch { + toast.error("Couldn't save that. Try again."); + } + } + + const loading = data === null; + const items = data?.items ?? []; + const t = tiles(items, today); + const groups = upcoming(items, today); + const next = groups[0]?.rows[0]; + const brief = loading + ? null + : items.length === 0 + ? "Lumen hasn't spotted any regular payments yet. They show up here after three similar charges." + : [ + next && `${next.vendor} of ${money(next.amount)} is due ${next.date.getTime() === today.getTime() ? "today" : weekday(next.date)}.`, + t.stopped.count > 0 && + `${t.stopped.count} ${t.stopped.count === 1 ? "subscription looks" : "subscriptions look"} stopped, about ${money(t.stopped.perMonth)} a month.`, + ] + .filter(Boolean) + .join(" ") || `${money(t.perMonth)} goes out every month in regular payments.`; + + const listed = items.filter((i) => i.state !== "cancelled"); + const counts = { subscriptions: listed.filter(isSubscription).length, bills: listed.filter((i) => !isSubscription(i)).length, all: listed.length }; + const rows = listed.filter((i) => tab === "all" || (tab === "subscriptions") === isSubscription(i)); + const cancelled = items.filter((i) => i.state === "cancelled"); + + const step = (d: number) => setShown(({ y, m }) => ({ y: new Date(y, m + d, 1).getFullYear(), m: new Date(y, m + d, 1).getMonth() })); + const monthSwitcher = ( +
    + + {monthYear(shown.y, shown.m)} + +
    + ); + + return ( +
    +
    +
    + } actions={monthSwitcher} /> + +
    + +
    {money(t.dueSoon.total)}
    +

    + {t.dueSoon.count} {t.dueSoon.count === 1 ? "bill" : "bills"} +

    +
    + +
    {money(t.perMonth)}
    +

    Yearly and weekly ones spread per month

    +
    + +
    + {money(t.stopped.perMonth)} a month +
    +

    + {t.stopped.count === 0 + ? "None right now" + : items.filter((i) => i.possibly_stopped && isSubscription(i) && i.state !== "cancelled").map((i) => i.vendor).join(", ")} +

    +
    +
    + +
    + + Next 30 days} loading={loading} skeletonHeight={420} data-testid="upcoming"> + {groups.length === 0 ? ( +

    Nothing due in the next 30 days.

    + ) : ( + groups.map((g) => ( +
    +
    + {g.label} + {money(g.total)} +
    + {g.rows.map((r) => ( +
    + + {r.date.toLocaleString("en-GB", { weekday: "short" })} + {r.date.getDate()} + + + {r.vendor} + {r.item.cadence} + + {money(r.amount)} +
    + ))} +
    + )) + )} +
    +
    + + + {(["all", "subscriptions", "bills"] as Tab[]).map((k) => ( + + ))} +
    + } + loading={loading} + skeletonHeight={240} + className="mt-3.5" + data-testid="recurring-table" + > + {rows.length === 0 ? ( +

    Nothing here yet.

    + ) : ( + rows.map((i) => ) + )} + {cancelled.length > 0 && ( +
    +
    Cancelled
    + {cancelled.map((i) => ( +
    + + {i.vendor} + {money(i.typical_amount)} + +
    + ))} +
    + )} + {(data?.hidden.length ?? 0) > 0 && ( +
    + + {showHidden && ( +
    + {data!.hidden.map((h) => ( +
    + {h.vendor} · not a bill + +
    + ))} +
    + )} +
    + )} + +
    +
    + ); +} + +function RecurringRow({ item: i, onChoose }: { item: RecurringItem; onChoose: (vendor: string, state: "cancelled" | "not_a_bill") => void }) { + const money = useMoney(); + return ( +
    + + + + + {i.vendor} + {i.price_change && ( + + {money(i.price_change.previous)} → {money(i.price_change.latest)} + + )} + {i.possibly_stopped && looks stopped} + {i.state === "charged_again" && ( + charged again after you cancelled + )} + + + {i.cadence} · {i.kind === "subscription" ? "subscription" : "bill"} + + + + {dayMonth(i.last_charge_date)} + {i.possibly_stopped ? "—" : dayMonth(i.next_due_date)} + + {money(i.typical_amount)} + a {i.cadence === "weekly" ? "week" : i.cadence === "yearly" ? "year" : "month"} + + + + + + How to cancel + + +
    + ); +} + +// The shown month, Monday first and always six rows tall (no jump between months), with each bill on its due days. +function BillCalendar({ items, year, month, today, loading }: { items: RecurringItem[]; year: number; month: number; today: Date; loading: boolean }) { + const money = useMoney(); + const lead = (new Date(year, month, 1).getDay() + 6) % 7; + const days = new Date(year, month + 1, 0).getDate(); + const byDay = new Map(); + for (const i of items.filter((x) => x.state !== "cancelled" && !x.possibly_stopped)) + for (const d of dueDays(i, year, month)) byDay.set(d, [...(byDay.get(d) ?? []), i]); + const isToday = (d: number) => today.getFullYear() === year && today.getMonth() === month && today.getDate() === d; + return ( + +
    + {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => ( +
    + {d} +
    + ))} + {Array.from({ length: 42 }, (_, cell) => { + const d = cell - lead + 1; + if (d < 1 || d > days) return
    ; + const bills = byDay.get(d) ?? []; + return ( +
    + {d} + {bills.slice(0, 1).map((b) => ( + + {b.vendor} + + ))} + {bills.length > 1 && +{bills.length - 1} more} +
    + ); + })} +
    + + ); +} diff --git a/frontend/src/app/(app)/bills/page.tsx b/frontend/src/app/(app)/bills/page.tsx index 7172d40..52a5b36 100644 --- a/frontend/src/app/(app)/bills/page.tsx +++ b/frontend/src/app/(app)/bills/page.tsx @@ -1,16 +1,8 @@ import type { Metadata } from "next"; -import { DashboardShell } from "@/components/dashboard-shell"; -import { UpcomingBills } from "./upcoming-bills"; +import BillsContent from "./bills-content"; export const metadata: Metadata = { title: "Bills & subscriptions" }; -// Interim until UI revamp Phase 5c redesigns Bills: the upcoming bills Lumen has spotted. export default function Page() { - return ( - -
    - -
    -
    - ); + return ; } diff --git a/frontend/src/app/(app)/bills/upcoming-bills.tsx b/frontend/src/app/(app)/bills/upcoming-bills.tsx deleted file mode 100644 index 31a212f..0000000 --- a/frontend/src/app/(app)/bills/upcoming-bills.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { billsApi } from "@/lib/api/client"; -import { BillsCard } from "../home/_components/bills-card"; -import { toBills, type Reminder } from "../home/use-home-data"; - -// Interim Bills (replaced in UI revamp Phase 5c Task C3): the same upcoming-bills list as Home. -export function UpcomingBills() { - const [bills, setBills] = useState(null); - const today = new Date(); - const day = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - useEffect(() => { - billsApi.recurring().then((r) => setBills(toBills(r.items, day)), () => setBills([])); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return ; -} From 7d4b471f8e9b7314bc1c8f1cd20f520bbcd4ea13 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:39:56 +0530 Subject: [PATCH 35/47] fix: Bills tabs stay on one line on phones; drop the old reminders client --- frontend/e2e/bills.spec.ts | 10 ++++++++++ frontend/e2e/fixtures.ts | 13 ------------- frontend/src/app/(app)/bills/bills-content.tsx | 2 +- frontend/src/lib/api/client.ts | 9 +-------- 4 files changed, 12 insertions(+), 22 deletions(-) diff --git a/frontend/e2e/bills.spec.ts b/frontend/e2e/bills.spec.ts index ed716ad..acf74c7 100644 --- a/frontend/e2e/bills.spec.ts +++ b/frontend/e2e/bills.spec.ts @@ -112,3 +112,13 @@ test("bills has no layout shift while loading", async ({ page }) => { await page.waitForTimeout(500); expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01); }); + +test("tabs stay on one line at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openBills(page); + for (const name of [/^All/, /^Subscriptions/, /^Bills/]) { + const box = (await table(page).getByRole("tab", { name }).boundingBox())!; + expect(box.height, String(name)).toBeLessThan(28); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index f13ef0b..209ceea 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -112,19 +112,6 @@ 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 } } }); } - // 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" } }); diff --git a/frontend/src/app/(app)/bills/bills-content.tsx b/frontend/src/app/(app)/bills/bills-content.tsx index 09bb917..8a3f74c 100644 --- a/frontend/src/app/(app)/bills/bills-content.tsx +++ b/frontend/src/app/(app)/bills/bills-content.tsx @@ -152,7 +152,7 @@ export default function BillsContent() { role="tab" aria-selected={tab === k} onClick={() => setTab(k)} - className={cn("rounded-[7px] px-2.5 py-0.5 text-[12px]", tab === k ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground")} + className={cn("rounded-[7px] px-2.5 py-0.5 text-[12px] whitespace-nowrap max-sm:px-1.5", tab === k ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground")} > {TAB_LABEL[k]} {counts[k]} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 66ec2c8..4e638f8 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -558,15 +558,8 @@ export const transactionApi = { }, }; -// Analytics the redesigned pages still read: upcoming bills (Home, interim Bills) and the forecast line (Home). +// Analytics the redesigned pages still read: the forecast line on Home. export const aiAnalyticsApi = { - getReminders: async (daysAhead: number = 7) => { - const response = await apiClient.get("/api/analytics/reminders", { - params: { days_ahead: daysAhead }, - }); - return response.data; - }, - // insights=false skips the LLM-written insight text (Home only draws the numbers); cached like the other reads getForecast: (daysAhead: number = 30, insights = true) => cached(`/api/analytics/forecast ${daysAhead} ${insights}`, async () => From ea286f234df4def091080c67f3722d1e3517e040 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:40:21 +0530 Subject: [PATCH 36/47] docs: STORY 34, codebase guide and spec backlog for Phase 5c --- docs/CODEBASE-GUIDE.md | 15 ++++++++++++--- docs/direction/STORY.md | 17 +++++++++++++++++ .../specs/2026-10-20-ui-revamp-design.md | 2 +- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index 29b7975..d628543 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -298,6 +298,15 @@ embeds the demo documents once per server and uploads the files in the backgroun pure in `lib/spending-math.ts` (tests: `e2e/spending-math.spec.ts`); data from `GET /transactions` (which now includes `source`) and `/api/home/summary` (spikes link to `/spending?category=`). The old Analytics and AI Insights pages, their components and the API calls only they used are gone. +- **Bills & subscriptions** (`app/(app)/bills/`, Phase 5c): "what's due, what recurs, what can I cut?" Everything + comes from `GET /api/bills/recurring` (`api/bills.py`): `ai/recurring.find_recurring` over the user's transactions + plus their saved choices in `recurring_states` (keyed by the detection's normalised vendor). `not_a_bill` moves a + vendor to `hidden`; `cancelled` keeps it listed as cancelled, or `charged_again` if a charge lands after the + cancel day; posting `active` forgets the choice (`POST /api/bills/recurring/state`). The page: three tiles (due in + 30 days, recurring per month with weekly/yearly spread, subscriptions that look stopped), a six-row month calendar + with each bill on its projected days (‹ month › switcher), Upcoming grouped This week / Next week / Later, and the + table with tabs, price-rise / looks-stopped / charged-again pills, Mark as cancelled, Not a bill, and How to cancel + (`/ask?draft=How do I cancel X?`). Maths in `lib/bills-math.ts` (tests: `e2e/bills-math.spec.ts`). - **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, @@ -305,9 +314,9 @@ embeds the demo documents once per server and uploads the files in the backgroun 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 + bills), the draft-budget promo and Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, recurring + charges, forecast, review queue). Bills come from `GET /api/bills/recurring`: `toBills` keeps active ones that + don't look stopped and are due in the next 31 days. 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: diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index bceeb27..54d743d 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -424,3 +424,20 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in tests; the e2e fixture now copies its data per test after a PUT leaked between tests. A screenshot against the approved mockup caught the wrapping period chips on phones, which a test then pinned. +## 34. Bills & subscriptions you can act on (UI revamp, Phase 5c) +- **S:** Bills had only the payment calendar and reminders cards, which read fields the backend never sent, and Home's + "Coming up" only had rows after a heavy analysis had run. Lumen already detected recurring charges well, but only the + agent and Home's signals used it. +- **T:** One page answering "what's due, what recurs, what can I cut?" from that detection, where telling Lumen + "cancelled" or "that's not a bill" is remembered, and noticing when a cancel didn't stick. +- **A:** A small `/api/bills` (TDD): recurring charges plus a `recurring_states` table keyed by the detection's own + normalised vendor, so "NETFLIX.com" and "Netflix com" are one choice; a charge after the cancel day flips the row to + "charged again". Home's bills switched to it. Pure maths (`lib/bills-math.ts`: monthly equivalents, projected due days + per cadence, a 30-day agenda, the tiles) under unit tests, then the page: tiles, a six-row month calendar that never + changes height, Upcoming, and a table whose actions save in place, with Undo for cancelled and hidden vendors and + "How to cancel" opening Ask with the question typed. Removed the interim list and the last caller of the old reminders. +- **R:** Backend 986 passed (5 new: per-user states, cancel → charged again, hide/restore, vendor spelling, + validation). Frontend: 10 Bills e2e checks (tiles, the calendar across months without moving, groups, tabs, cancel and + undo, hide and undo, the Ask link, CLS under 0.01, tabs on one line at 375 px) and 4 maths tests. A screenshot against + the mockup caught the wrapping tabs on phones, now pinned by a test. + 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 820a5ce..b03b370 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -240,7 +240,7 @@ Small items found while building, parked here so they aren't lost. Tick them off - [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)* +- [x] **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)* *(Done in 5c: Home and Bills read `/api/bills/recurring`.)* - [ ] **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)* - [ ] **Later.** Spending: the category edit (pencil) is hidden below `sm`, so phones can't recategorise; and "Lumen noticed" wraps beside its pill at 375px. *(5b)* - [ ] **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)* From 9d97853a5a383b4da5b453d9be5083a0fa66b2c6 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:37:32 +0530 Subject: [PATCH 37/47] docs: detail the Phase 5d plan (Budget page) --- .../plans/2026-10-22-ui-revamp-phase5.md | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md index 24d59a1..733dd20 100644 --- a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md +++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md @@ -266,8 +266,30 @@ Branch: `ui-revamp/phase5c-bills`, stacked on `ui-revamp/phase5b-spending` (#98) - STORY 34, guide (Bills), spec §12 (tick the 5c item). - Full checks green, screenshot vs `bills.html` at 1440 and 375, PR stacked on #98. -## 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 5d — Budget page (detailed) + +Branch: `ui-revamp/phase5d-budget`, stacked on `ui-revamp/phase5c-bills` (#99). Mockup `budget.html`; spec §4.4 and the builder-check decision "budgets are versioned by month". + +### Task D1: budgets per month (backend, TDD) +- `Budget.effective_from` = `"YYYY-MM"` (String(7)); unique (user_id, category, effective_from) as `u_budget_user_category_month`. `models/database.py`: `_migrate_budget_versions(engine)` after `_add_missing_columns` (column in `_ADDED_COLUMNS`): fills NULLs with the row's `updated_at` month, drops the old `u_budget_user_category` (Postgres: `DROP CONSTRAINT IF EXISTS`; SQLite: rebuild the table, since its autoindex can't be dropped), creates the new unique index. Idempotent. +- `api/budget.py`: `limits_in_force(rows, month) -> {key: (category, limit)}` = latest row per case-folded category with `effective_from <= month`; a `monthly_limit <= 0` row is a removal (PUT with `limit: null` writes one for the current month). `upsert_budget(session, user_id, category, limit, month)`. +- `GET /api/budget?month=YYYY-MM` (default current; 422 on a bad month) → the existing shape for that month. `GET /api/budget/history?months=6` (1–12) → `{success, months: [{month, budget, spent}]}` oldest first ending this month; `budget` = `monthly_budget` or the sum of limits in force, `null` for a past month with no limit in force. +- Draft: `payload.month` = current month; approving writes rows effective from `payload.month` (current month if missing/bad). `draft_budget`'s `basis.months` lists only months with spend (§12). +- Tests: `test_api_budget.py` — editing this month leaves last month's limits; history budget/spent per month and `null` before any budget; removal hides the category only from this month on; approve writes `effective_from`; bad month 422; `test_budget.py` — basis months skip an empty month; unique index allows two months; migration on an old-schema SQLite file keeps rows, fills `effective_from`, and allows a second month. + +### Task D2: the Budget page (frontend) +- `budgetApi.get(month?)`, `budgetApi.history(months)`; type `BudgetMonth {month, budget: number|null, spent}`. `lib/budget-math.ts`: `pace(total, spent, today, month)` → `{pct, evenPct|null, left, daysLeft, perDay}` (evenPct/perDay only for the current month); `usedTone(spent, limit)` → `"good"|"warn"|"bad"` (warn ≥ 90%). +- `app/(app)/budget/` + sidebar **Plan · Budget** between Track and Records. Header "Budget", ‹ month › switcher (next disabled at the current month), actions on the current month only: "Edit budget" (quiet; edits the overall total in place) and "Let Lumen draft" (dark). Brief from real numbers ("You're on track: ₹X under an even pace." / "₹X over an even pace." / "₹X over budget."). Past months read-only. +- Hero: spent of total, bar with even-pace tick, Left / Days left / Safe per day (= left ÷ days left), pace chart = `CumulativeChart` with the even line passed as the dashed series. +- By category table: Budget / Spent / Left / Used bar (green, amber ≥90%, red over). Clicking the budget amount (current month) swaps it for an input of the same height: Enter saves (`PUT`), Esc cancels. Footer "Categories add up to X · Y of your Z is unassigned" (only with an overall total) + Add category (inline name + amount row). +- Draft panel on the page: shows pending `set_budget` proposals (loaded on open, or filed by "Let Lumen draft"; `?draft=1` drafts on arrival). Each line: category, proposed amount, "was X" / "new", basis sentence, Skip / Approve with aria-labels "Skip " / "Approve at ", decided state in place, "Approve remaining N", 409 → "This draft was replaced. Draft again." No month-bars glyph (basis has no per-month amounts; hidden, not faked). +- Last 6 months: dashed budget outline + actual bar (green under, red over), the current month "so far" with a pale run-rate projection; "N of M months under" over past months that had a budget. +- Home: the promo becomes a link to `/budget?draft=1` (dialog removed); the budget card title links to `/budget`. +- Tests (`budget.spec.ts`, `budget-math.spec.ts`): hero numbers and pace tick; month switch requests `?month=` and hides edit actions; inline edit PUTs and the row's box doesn't change; Esc cancels; draft panel approve/skip names, approve-remaining, 409 message; history bars over/under; CLS < 0.01; phone width no sideways scroll. Home's draft-dialog tests move here. + +### Task D3: docs, checks, PR +- STORY 35, guide (Budget), spec §12 ticks (draft accessible names, `basis.months`). +- Full checks green, screenshot vs `budget.html` at 1440 and 375, PR stacked on #99. ## Phase 5e — Receipts & statements (outline) Backend (TDD): `StatementImport` table + write on import; `GET /api/statements/imports`. Frontend: header actions; Needs-your-review cards from the review queue with highlights from validation flags (replaces the Review tab); library list with filter chips (Statements = import rows) and search; Statement imports card; From your inbox card; Activity moves here from Home if it no longer fits. Old `/documents`, `/import`, `/review` routes redirect into `/receipts`. STORY 36, then the whole-branch review for Phase 5. From 8a2eff04a7dc95fae3deb7e0b4614d4430d05f25 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:43:46 +0530 Subject: [PATCH 38/47] feat: budgets are saved per month; month and history endpoints; draft basis lists only months with spend --- backend/ai/budget.py | 12 ++-- backend/api/agent.py | 10 +-- backend/api/budget.py | 112 +++++++++++++++++++++++-------- backend/models/__init__.py | 7 +- backend/models/database.py | 35 ++++++++++ backend/tests/test_api_budget.py | 65 ++++++++++++++++++ backend/tests/test_budget.py | 37 +++++++++- 7 files changed, 236 insertions(+), 42 deletions(-) diff --git a/backend/ai/budget.py b/backend/ai/budget.py index 7192861..5ff264f 100644 --- a/backend/ai/budget.py +++ b/backend/ai/budget.py @@ -55,8 +55,8 @@ def _round_up(avg: float) -> int: def draft_budget(txns: list[dict], today: date) -> list[dict]: - """One line per category: the mean over the 3 complete months before today's (only those with any spend), - rounded up. + """One line per category: the mean over the 3 complete months before today's (only those with any spend, which + are the ones basis.months lists), rounded up. Empty when fewer than 2 of those months have any spend (not enough history to suggest limits).""" months = [] y, m = today.year, today.month @@ -65,15 +65,15 @@ def draft_budget(txns: list[dict], today: date) -> list[dict]: months.append((y, m)) months.reverse() totals = _totals(txns, set(months)) - active = len({mo for (_, mo), v in totals.items() if v}) # months with any spend (a new user may have only 2) - if active < 2: + spent = sorted({mo for (_, mo), v in totals.items() if v}) # months with any spend (a new user may have only 2) + if len(spent) < 2: return [] names = _display_names(txns) lines = [] for key in {k for k, _ in totals}: - avg = sum(totals.get((key, mo), 0.0) for mo in months) / active + avg = sum(totals.get((key, mo), 0.0) for mo in months) / len(spent) if avg >= MIN_MONTHLY: lines.append({"category": names[key], "limit": _round_up(avg), - "basis": {"months": [f"{yy}-{mm:02d}" for yy, mm in months], "average": round(avg, 2)}}) + "basis": {"months": [f"{yy}-{mm:02d}" for yy, mm in spent], "average": round(avg, 2)}}) lines.sort(key=lambda l: l["limit"], reverse=True) return lines[:MAX_LINES] diff --git a/backend/api/agent.py b/backend/api/agent.py index 8dd3eb2..6671c78 100644 --- a/backend/api/agent.py +++ b/backend/api/agent.py @@ -158,16 +158,18 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d p.status, p.decided_at = decision, now applied = None if decision == "approved" and p.type == "set_budget": - from api.budget import upsert_budget + from api.budget import this_month, upsert_budget, valid_month # the agent can file set_budget with any payload: only a real positive limit is ever applied try: - limit = float(json.loads(p.payload or "{}")["limit"]) + payload = json.loads(p.payload or "{}") + limit = float(payload["limit"]) except (KeyError, TypeError, ValueError): - limit = math.nan + payload, limit = {}, math.nan if not math.isfinite(limit) or limit <= 0 or not p.target.strip(): raise HTTPException(422, "This budget suggestion has no valid limit; reject it instead.") - upsert_budget(session, user_id, p.target.strip(), limit) + # from the month the draft was for (a draft made last month still starts there), else this month + upsert_budget(session, user_id, p.target.strip(), limit, valid_month(payload.get("month")) or this_month()) applied = {"category": p.target.strip(), "limit": limit} if decision == "approved" and p.type == "update_category": category = json.loads(p.payload or "{}").get("category") diff --git a/backend/api/budget.py b/backend/api/budget.py index 675a763..5d40c93 100644 --- a/backend/api/budget.py +++ b/backend/api/budget.py @@ -1,12 +1,14 @@ """UI revamp Phase 3: budgets. The overall monthly budget is User.monthly_budget (set in onboarding); per-category limits live in the budgets table. A draft (ai/budget.py) never writes budgets: it files set_budget proposals that the -user approves one by one through /api/agent/proposals/{id}/approve (api/agent.py applies them).""" +user approves one by one through /api/agent/proposals/{id}/approve (api/agent.py applies them). +Phase 5d: limits are versioned by month (models.Budget); every read is for one month, every write is from one month on.""" from __future__ import annotations import json +import re from datetime import date, datetime -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field from sqlalchemy import select, text from sqlalchemy.engine import Engine @@ -18,6 +20,30 @@ router = APIRouter(prefix="/api/budget", tags=["budget"]) _MONTH = "%b" +MONTH_RE = r"^\d{4}-(0[1-9]|1[0-2])$" + + +def this_month(today: date | None = None) -> str: + return (today or date.today()).strftime("%Y-%m") + + +def valid_month(value) -> str | None: + return value if isinstance(value, str) and re.match(MONTH_RE, value) else None + + +def _shift(month: str, n: int) -> str: + y, m = divmod(int(month[:4]) * 12 + int(month[5:]) - 1 + n, 12) + return f"{y}-{m + 1:02d}" + + +def limits_in_force(rows, month: str) -> dict[str, tuple[str, float]]: + """Case-folded category -> (name, limit) for `month`: each category's latest row from that month or before. + A limit of 0 is a removal, so the category has none.""" + latest: dict[str, object] = {} + for b in sorted(rows, key=lambda b: b.effective_from or ""): + if (b.effective_from or "") <= month: + latest[b.category.casefold()] = b + return {k: (b.category, b.monthly_limit) for k, b in latest.items() if b.monthly_limit > 0} class CategoryLimit(BaseModel): @@ -37,31 +63,42 @@ def _txns(conn, user_id: str) -> list[dict]: {"u": user_id}).mappings()] -def _state(engine: Engine, user_id: str, today: date) -> dict: +def _spend(engine: Engine, user_id: str, month: str) -> dict[str, float]: from ai.budget import month_spend_by_category - from models import Budget, User with engine.connect() as conn: - spend = month_spend_by_category(_txns(conn, user_id), today.year, today.month) + return month_spend_by_category(_txns(conn, user_id), int(month[:4]), int(month[5:])) + + +def _rows(session: Session, user_id: str) -> list: + from models import Budget + + return session.scalars(select(Budget).where(Budget.user_id == user_id)).all() + + +def _state(engine: Engine, user_id: str, month: str) -> dict: + from models import User + + spend = _spend(engine, user_id, month) by_key = {k.casefold(): v for k, v in spend.items()} with Session(engine) as session: user = session.get(User, user_id) - rows = session.scalars(select(Budget).where(Budget.user_id == user_id)).all() - cats = [{"category": b.category, "limit": b.monthly_limit, "spent": round(by_key.get(b.category.casefold(), 0), 2)} - for b in rows] + cats = [{"category": name, "limit": limit, "spent": round(by_key.get(key, 0), 2)} + for key, (name, limit) in limits_in_force(_rows(session, user_id), month).items()] cats.sort(key=lambda c: c["spent"] / c["limit"], reverse=True) - return {"success": True, "month": today.strftime("%Y-%m"), "monthly_budget": user.monthly_budget if user else None, + return {"success": True, "month": month, "monthly_budget": user.monthly_budget if user else None, "spent": round(sum(spend.values()), 2), "categories": cats} -def upsert_budget(session: Session, user_id: str, category: str, limit: float) -> None: - """One row per (user, category), matched case-insensitively so 'food' and 'Food' don't become two budgets.""" +def upsert_budget(session: Session, user_id: str, category: str, limit: float, month: str) -> None: + """Sets `category`'s limit from `month` on (0 removes it). One row per (user, category, month), matched + case-insensitively so 'food' and 'Food' don't become two budgets.""" from models import Budget - rows = session.scalars(select(Budget).where(Budget.user_id == user_id)).all() - row = next((b for b in rows if b.category.casefold() == category.casefold()), None) + row = next((b for b in _rows(session, user_id) + if b.category.casefold() == category.casefold() and b.effective_from == month), None) if row is None: - session.add(Budget(user_id=user_id, category=category, monthly_limit=limit)) + session.add(Budget(user_id=user_id, category=category, monthly_limit=limit, effective_from=month)) else: row.monthly_limit, row.updated_at = limit, datetime.utcnow() @@ -85,7 +122,7 @@ def create_draft(engine: Engine, user_id: str, today: date) -> list[dict]: months = line["basis"]["months"] span = f"{date.fromisoformat(months[0] + '-01'):{_MONTH}}–{date.fromisoformat(months[-1] + '-01'):{_MONTH}}" p = Proposal(user_id=user_id, type="set_budget", target=line["category"], risk="low", - payload=json.dumps({"limit": line["limit"], "basis": line["basis"]}), + payload=json.dumps({"limit": line["limit"], "basis": line["basis"], "month": this_month(today)}), reason=f"Averaged {line['basis']['average']:,.0f} a month over {span}, rounded up.", evidence="[]", status="pending") session.add(p) @@ -97,27 +134,46 @@ def create_draft(engine: Engine, user_id: str, today: date) -> list[dict]: @router.get("", dependencies=[Depends(rate_limit())]) -def get_budget(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): - return _state(engine, claims["sub"], date.today()) +def get_budget(month: str | None = Query(default=None, pattern=MONTH_RE), claims: dict = Depends(current_user), + engine: Engine = Depends(get_engine)): + return _state(engine, claims["sub"], month or this_month()) + + +@router.get("/history", dependencies=[Depends(rate_limit())]) +def get_history(months: int = Query(default=6, ge=1, le=12), claims: dict = Depends(current_user), + engine: Engine = Depends(get_engine)): + """Budget vs spent for the last `months` months, oldest first, ending with this one. A past month with no + category limit in force has no budget (null): spend only.""" + from models import User + + user_id, now = claims["sub"], this_month() + with Session(engine) as session: + user = session.get(User, user_id) + overall = user.monthly_budget if user else None + rows = _rows(session, user_id) + from ai.budget import month_spend_by_category + + with engine.connect() as conn: + txns = _txns(conn, user_id) + out = [] + for month in (_shift(now, -n) for n in range(months - 1, -1, -1)): + limits = limits_in_force(rows, month) + budget = (overall or round(sum(v for _, v in limits.values()), 2) or None) if limits or month == now else None + spent = sum(month_spend_by_category(txns, int(month[:4]), int(month[5:])).values()) + out.append({"month": month, "budget": budget, "spent": round(spent, 2)}) + return {"success": True, "months": out} @router.put("", dependencies=[Depends(rate_limit())]) def put_budget(body: BudgetUpdate, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): - from models import Budget - - user_id = claims["sub"] + """Changes apply from the current month on; past months keep the limits they had.""" + user_id, month = claims["sub"], this_month() with Session(engine) as session, session.begin(): if "monthly_budget" in body.model_fields_set: ensure_user(session, user_id, claims.get("email")).monthly_budget = body.monthly_budget for c in body.categories: - name = c.category.strip() - if c.limit is None: - for b in session.scalars(select(Budget).where(Budget.user_id == user_id)).all(): - if b.category.casefold() == name.casefold(): - session.delete(b) - else: - upsert_budget(session, user_id, name, c.limit) - return _state(engine, user_id, date.today()) + upsert_budget(session, user_id, c.category.strip(), c.limit or 0, month) + return _state(engine, user_id, month) @router.post("/draft", dependencies=[Depends(rate_limit("10 per minute"))]) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 2b193f7..a9ced99 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -302,7 +302,9 @@ class Proposal(db.Model): class Budget(db.Model): """A monthly spending limit for one category (UI revamp Phase 3). The overall budget is User.monthly_budget. - Rows are written only by approving a set_budget proposal or by PUT /api/budget.""" + Rows are written only by approving a set_budget proposal or by PUT /api/budget. + Versioned by month (Phase 5d): a month's limit is the latest row with effective_from <= that month, so changing + a budget never rewrites past months. A monthly_limit of 0 removes the category from that month on.""" __tablename__ = "budgets" @@ -310,9 +312,10 @@ class Budget(db.Model): user_id = db.Column(String(36), nullable=False, index=True) category = db.Column(db.String, nullable=False) monthly_limit = db.Column(db.Float, nullable=False) + effective_from = db.Column(db.String(7)) # "YYYY-MM"; always set by the writers (older rows: migrated) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - __table_args__ = (db.UniqueConstraint("user_id", "category", name="u_budget_user_category"),) + __table_args__ = (db.UniqueConstraint("user_id", "category", "effective_from", name="u_budget_user_category_month"),) class RecurringState(db.Model): """What the user said about a recurring charge on the Bills page (UI revamp Phase 5c): "cancelled" keeps it listed diff --git a/backend/models/database.py b/backend/models/database.py index 2848a79..c09f92c 100644 --- a/backend/models/database.py +++ b/backend/models/database.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from flask import Flask from flask_sqlalchemy import SQLAlchemy @@ -34,6 +35,7 @@ def _migrate_transaction_unique_index(app: Flask) -> None: # Columns added after their table first shipped (create_all doesn't alter existing tables). All nullable. _ADDED_COLUMNS = { + "budgets": [("effective_from", "VARCHAR(7)")], # UI revamp Phase 5d: budgets per month "chat_messages": [("thread_id", "VARCHAR(36)")], # UI revamp Phase 4: chat threads "review_items": [("extracted", "TEXT")], # SPEC-FEEDBACK "transactions": [("currency", "VARCHAR(3)"), ("po_number", "VARCHAR"), ("file_key", "VARCHAR"), @@ -58,6 +60,35 @@ def _add_missing_columns() -> None: logger.warning("Column migration for %s skipped: %s", table, e) +def _migrate_budget_versions(engine) -> None: + """Phase 5d: budgets went from one row per (user, category) to one per month. Dates older rows by their last + update and swaps the unique constraint. SQLite can't drop a table constraint, so there the table is rebuilt. + Idempotent.""" + insp = inspect(engine) + if "budgets" not in insp.get_table_names(): + return + old = any(set(u["column_names"]) == {"user_id", "category"} for u in insp.get_unique_constraints("budgets")) + with engine.begin() as conn: + if old and engine.dialect.name == "sqlite": + conn.execute(text("ALTER TABLE budgets RENAME TO budgets_pre_5d")) + conn.execute(text("CREATE TABLE budgets (id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, " + "category VARCHAR NOT NULL, monthly_limit FLOAT NOT NULL, effective_from VARCHAR(7), " + "updated_at DATETIME)")) + have = {c["name"] for c in insp.get_columns("budgets_pre_5d")} + eff = "effective_from" if "effective_from" in have else "NULL" + conn.execute(text(f"INSERT INTO budgets (id, user_id, category, monthly_limit, effective_from, updated_at) " + f"SELECT id, user_id, category, monthly_limit, {eff}, updated_at FROM budgets_pre_5d")) + conn.execute(text("DROP TABLE budgets_pre_5d")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_budgets_user_id ON budgets (user_id)")) + elif old: + conn.execute(text("ALTER TABLE budgets DROP CONSTRAINT IF EXISTS u_budget_user_category")) + for row_id, updated in conn.execute(text("SELECT id, updated_at FROM budgets WHERE effective_from IS NULL")).all(): + conn.execute(text("UPDATE budgets SET effective_from = :m WHERE id = :i"), + {"m": str(updated or datetime.utcnow())[:7], "i": row_id}) + conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS u_budget_user_category_month " + "ON budgets (user_id, category, effective_from)")) + + RAG_TABLES = {"documents", "document_chunks"} @@ -90,6 +121,10 @@ def init_db(app: Flask): db.metadata.create_all(db.engine, tables=tables) _migrate_transaction_unique_index(app) _add_missing_columns() + try: + _migrate_budget_versions(db.engine) + except Exception as e: + logger.warning("Budget versions migration skipped: %s", e) logger.info("Database connected and tables initialized") except Exception as e: logger.warning("Could not connect to database: %s", e) diff --git a/backend/tests/test_api_budget.py b/backend/tests/test_api_budget.py index 7f99a21..d96482d 100644 --- a/backend/tests/test_api_budget.py +++ b/backend/tests/test_api_budget.py @@ -195,3 +195,68 @@ def test_decide_claims_with_a_conditional_update(setup): claim = next(i for i, s in enumerate(statements) if s.startswith("update proposals") and "status = ?" in s.split("where", 1)[1]) writes = [i for i, s in enumerate(statements) if s.startswith("insert into budgets") or s.startswith("insert into audit_events")] assert writes and claim < min(writes) + + +def ym(n): + return months_back(n)[:7] + + +def put_row(engine, user, category, limit, month): + from models import Budget + + with Session(engine) as s, s.begin(): + s.add(Budget(user_id=user, category=category, monthly_limit=limit, effective_from=month)) + + +def test_editing_this_month_keeps_last_months_limits(setup): + c, engine = setup + put_row(engine, A, "Food", 3000, ym(1)) + seed(engine, A, [(months_back(1), 2500, "Food")]) + c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": 5000}]}) + last = c.get(f"/api/budget?month={ym(1)}", headers=bearer(A)).json() + assert last["month"] == ym(1) and last["spent"] == 2500 + assert last["categories"] == [{"category": "Food", "limit": 3000, "spent": 2500}] + assert c.get("/api/budget", headers=bearer(A)).json()["categories"][0]["limit"] == 5000 + + +def test_removing_a_category_only_applies_from_this_month(setup): + c, engine = setup + put_row(engine, A, "Food", 3000, ym(1)) + body = c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "food", "limit": None}]}).json() + assert body["categories"] == [] + assert c.get(f"/api/budget?month={ym(1)}", headers=bearer(A)).json()["categories"][0]["limit"] == 3000 + + +def test_a_bad_month_is_rejected(setup): + c, _ = setup + for bad in ("2026-13", "26-01", "2026-1", "x"): + assert c.get(f"/api/budget?month={bad}", headers=bearer(A)).status_code == 422 + assert c.get("/api/budget/history?months=0", headers=bearer(A)).status_code == 422 + + +def test_history_compares_each_month_with_the_limits_then(setup): + c, engine = setup + put_row(engine, A, "Food", 3000, ym(2)) + put_row(engine, A, "Food", 4000, ym(1)) + seed(engine, A, [(months_back(3), 900, "Food"), (months_back(2), 3500, "Food"), (months_back(1), 1000, "Food")]) + months = c.get("/api/budget/history?months=4", headers=bearer(A)).json()["months"] + assert months == [ + {"month": ym(3), "budget": None, "spent": 900}, # before any budget: spend only + {"month": ym(2), "budget": 3000, "spent": 3500}, + {"month": ym(1), "budget": 4000, "spent": 1000}, + {"month": ym(0), "budget": 4000, "spent": 0}, + ] + c.put("/api/budget", headers=bearer(A), json={"monthly_budget": 50000}) + assert c.get("/api/budget/history?months=1", headers=bearer(A)).json()["months"][0]["budget"] == 50000 + + +def test_approving_a_draft_writes_it_from_the_drafts_month(setup): + from models import Budget + + c, engine = setup + history(engine, A) + food = next(p for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] if p["target"] == "Food") + assert food["payload"]["month"] == ym(0) + c.post(f"/api/agent/proposals/{food['id']}/approve", headers=bearer(A)) + with Session(engine) as s: + assert [(b.category, b.effective_from) for b in s.scalars(select(Budget))] == [("Food", ym(0))] diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py index be19c35..bfdd9f0 100644 --- a/backend/tests/test_budget.py +++ b/backend/tests/test_budget.py @@ -63,15 +63,48 @@ def test_month_spend_nets_refunds(): assert month_spend_by_category(txns, 2026, 10) == {"Food": 600} -def test_budget_unique_per_category(tmp_path): +def test_budget_unique_per_category_and_month(tmp_path): import models # noqa: F401 from models import Budget from models.database import db as flask_db engine = create_engine(f"sqlite:///{tmp_path / 'b.db'}") flask_db.metadata.create_all(engine) + with Session(engine) as s, s.begin(): # one row per month is how a budget changes over time + s.add_all([Budget(user_id="u", category="Food", monthly_limit=1, effective_from="2026-09"), + Budget(user_id="u", category="Food", monthly_limit=2, effective_from="2026-10")]) with pytest.raises(IntegrityError), Session(engine) as s, s.begin(): - s.add_all([Budget(user_id="u", category="Food", monthly_limit=1), Budget(user_id="u", category="Food", monthly_limit=2)]) + s.add(Budget(user_id="u", category="Food", monthly_limit=3, effective_from="2026-10")) + + +def test_migration_versions_an_old_budgets_table(tmp_path): + """A database from before Phase 5d: one row per (user, category), no effective_from. The migration keeps the rows, + dates them by their last update, and lets a category have one row per month. Running it twice is harmless.""" + from sqlalchemy import text + + from models.database import _migrate_budget_versions + + engine = create_engine(f"sqlite:///{tmp_path / 'old.db'}") + with engine.begin() as conn: + conn.execute(text("CREATE TABLE budgets (id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, " + "category VARCHAR NOT NULL, monthly_limit FLOAT NOT NULL, updated_at DATETIME, " + "CONSTRAINT u_budget_user_category UNIQUE (user_id, category))")) + conn.execute(text("INSERT INTO budgets VALUES ('1', 'u', 'Food', 4000, '2026-08-14 10:00:00')")) + _migrate_budget_versions(engine) + _migrate_budget_versions(engine) + with engine.begin() as conn: + assert conn.execute(text("SELECT category, monthly_limit, effective_from FROM budgets")).all() == [ + ("Food", 4000, "2026-08")] + conn.execute(text("INSERT INTO budgets (id, user_id, category, monthly_limit, effective_from) " + "VALUES ('2', 'u', 'Food', 5000, '2026-10')")) + with pytest.raises(IntegrityError), engine.begin() as conn: + conn.execute(text("INSERT INTO budgets (id, user_id, category, monthly_limit, effective_from) " + "VALUES ('3', 'u', 'Food', 6000, '2026-10')")) + + +def test_basis_lists_only_months_with_spend(): + txns = [t("2026-08-10", 6000), t("2026-09-10", 6000)] # nothing in July + assert draft_budget(txns, TODAY)[0]["basis"]["months"] == ["2026-08", "2026-09"] def test_draft_with_two_months_averages_over_two(): From be836217bf92bef61b8774e05edec64729fd2f2f Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:06:52 +0530 Subject: [PATCH 39/47] feat: Budget page (pace hero, 6-month history, inline category edits, draft panel) and the Plan sidebar group --- frontend/e2e/budget-math.spec.ts | 37 ++++ frontend/e2e/budget.spec.ts | 205 ++++++++++++++++++ frontend/e2e/fixtures.ts | 29 ++- frontend/e2e/home.spec.ts | 63 +----- frontend/e2e/shell.spec.ts | 4 +- .../(app)/budget/_components/categories.tsx | 173 +++++++++++++++ .../(app)/budget/_components/draft-panel.tsx | 138 ++++++++++++ .../src/app/(app)/budget/_components/hero.tsx | 131 +++++++++++ .../app/(app)/budget/_components/history.tsx | 77 +++++++ .../src/app/(app)/budget/budget-content.tsx | 156 +++++++++++++ frontend/src/app/(app)/budget/page.tsx | 14 ++ .../src/app/(app)/budget/use-budget-data.ts | 72 ++++++ .../(app)/home/_components/budget-card.tsx | 125 +---------- frontend/src/app/(app)/home/homeContent.tsx | 2 +- frontend/src/app/(app)/home/use-home-data.ts | 12 +- frontend/src/components/app-sidebar.tsx | 11 +- frontend/src/components/page/page-header.tsx | 2 +- frontend/src/lib/api/client.ts | 11 +- frontend/src/lib/budget-math.ts | 33 +++ 19 files changed, 1098 insertions(+), 197 deletions(-) create mode 100644 frontend/e2e/budget-math.spec.ts create mode 100644 frontend/e2e/budget.spec.ts create mode 100644 frontend/src/app/(app)/budget/_components/categories.tsx create mode 100644 frontend/src/app/(app)/budget/_components/draft-panel.tsx create mode 100644 frontend/src/app/(app)/budget/_components/hero.tsx create mode 100644 frontend/src/app/(app)/budget/_components/history.tsx create mode 100644 frontend/src/app/(app)/budget/budget-content.tsx create mode 100644 frontend/src/app/(app)/budget/page.tsx create mode 100644 frontend/src/app/(app)/budget/use-budget-data.ts create mode 100644 frontend/src/lib/budget-math.ts diff --git a/frontend/e2e/budget-math.spec.ts b/frontend/e2e/budget-math.spec.ts new file mode 100644 index 0000000..12b1975 --- /dev/null +++ b/frontend/e2e/budget-math.spec.ts @@ -0,0 +1,37 @@ +// Node-only unit tests (no browser) for the Budget page's calculations (UI revamp Phase 5d). +import { test, expect } from "@playwright/test"; +import { evenLine, pace, runRate, usedTone } from "../src/lib/budget-math"; + +const today = new Date(2026, 9, 22); // 22 Oct 2026; October has 31 days +const october = new Date(2026, 9, 1); + +test("pace: this month has an even-pace mark, days left and a safe amount per day", () => { + const p = pace(100000, 62182, today, october); + expect(p.pct).toBeCloseTo(0.62182); + expect(p.evenPct).toBeCloseTo(22 / 31); + expect(p.left).toBe(37818); + expect(p.daysLeft).toBe(10); // today counts + expect(p.perDay).toBeCloseTo(3781.8); + expect(p.vsPace).toBeCloseTo(62182 - (100000 * 22) / 31); // negative = under an even pace +}); + +test("pace: a past month has no pace, days left or per day; over budget has no per day", () => { + const past = pace(50000, 61000, today, new Date(2026, 8, 1)); + expect([past.evenPct, past.daysLeft, past.perDay, past.vsPace]).toEqual([null, 0, null, null]); + expect(past.left).toBe(-11000); + expect(pace(50000, 61000, today, october).perDay).toBeNull(); + expect(pace(0, 500, today, october).pct).toBe(0); +}); + +test("usedTone: green, amber from 90%, red when over", () => { + expect(usedTone(899, 1000)).toBe("good"); + expect(usedTone(900, 1000)).toBe("warn"); + expect(usedTone(1000, 1000)).toBe("warn"); + expect(usedTone(1001, 1000)).toBe("bad"); +}); + +test("evenLine reaches the budget on the last day; runRate projects the month", () => { + const line = evenLine(31000, 31); + expect([line[0], line[30], line.length]).toEqual([1000, 31000, 31]); + expect(runRate(22000, today)).toBe(31000); +}); diff --git a/frontend/e2e/budget.spec.ts b/frontend/e2e/budget.spec.ts new file mode 100644 index 0000000..2afd00b --- /dev/null +++ b/frontend/e2e/budget.spec.ts @@ -0,0 +1,205 @@ +import { test, expect, type Page } from "@playwright/test"; +import { signIn, type HomeFixture } from "./fixtures"; + +const now = new Date(); +const ym = (n: number) => { + const d = new Date(now.getFullYear(), now.getMonth() - n, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +}; +const monthName = (n: number) => new Date(now.getFullYear(), now.getMonth() - n, 1).toLocaleString("en-US", { month: "long" }); +const daysLeft = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate() - now.getDate() + 1; +const c = (category: string, limit: number, spent: number) => ({ category, limit, spent }); +const line = (id: string, target: string, limit: number) => ({ + id, type: "set_budget", target, risk: "low", reason: "Averaged a month over Jul–Sep, rounded up.", + payload: { limit, basis: { months: [], average: limit } }, status: "pending", created_at: null, +}); +const sample: HomeFixture = { + budget: { monthly_budget: 100000, spent: 62182, categories: [c("Shopping", 8000, 16840), c("Food", 6000, 5930), c("Travel", 5000, 2140)] }, + history: [ + { month: ym(3), budget: null, spent: 70000 }, + { month: ym(2), budget: 100000, spent: 104200 }, + { month: ym(1), budget: 100000, spent: 79900 }, + { month: ym(0), budget: 100000, spent: 62182 }, + ], +}; +async function openBudget(page: Page, home: HomeFixture = sample, path = "/budget") { + await signIn(page, { home }); + await page.goto(path); + await expect(page.getByTestId("budget-hero")).toContainText("₹", { timeout: 15_000 }); +} +const row = (page: Page, name: string) => page.getByTestId("budget-row").filter({ hasText: name }); + +test("budget: heading, spent of total, left, days left and safe per day", async ({ page }) => { + await openBudget(page); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Budget"); + const hero = page.getByTestId("budget-hero"); + await expect(hero).toContainText("₹62,182"); + await expect(hero).toContainText("of ₹100,000"); + await expect(hero.getByTestId("left")).toContainText("₹37,818"); + await expect(hero.getByTestId("days-left")).toContainText(String(daysLeft)); + await expect(hero.getByTestId("per-day")).toContainText(`₹${Math.round(37818 / daysLeft).toLocaleString("en-US")}`); + await expect(hero.getByTestId("even-pace")).toBeVisible(); +}); + +test("the sidebar has a Plan group with Budget", async ({ page }) => { + await openBudget(page); + const link = page.locator('[data-sidebar="menu-button"]', { hasText: "Budget" }); + await expect(link).toHaveAttribute("href", "/budget"); + await expect(link).toHaveAttribute("data-active", "true"); + await expect(page.getByText("Plan", { exact: true })).toBeVisible(); +}); + +test("by category: tones, unassigned footer", async ({ page }) => { + await openBudget(page); + await expect(row(page, "Shopping")).toHaveAttribute("data-tone", "bad"); + await expect(row(page, "Food")).toHaveAttribute("data-tone", "warn"); + await expect(row(page, "Travel")).toHaveAttribute("data-tone", "good"); + await expect(row(page, "Shopping")).toContainText("₹8,840 over"); + await expect(page.getByTestId("budget-footer")).toContainText("Categories add up to ₹19,000"); + await expect(page.getByTestId("budget-footer")).toContainText("₹81,000 of your ₹100,000 is unassigned"); +}); + +test("editing a category's budget in place: Enter saves, the row keeps its height", async ({ page }) => { + const puts: unknown[] = []; + page.on("request", (r) => r.method() === "PUT" && r.url().endsWith("/api/budget") && puts.push(r.postDataJSON())); + await openBudget(page); + const before = (await row(page, "Food").boundingBox())!; + await row(page, "Food").getByRole("button", { name: "Edit Food budget" }).click(); + const input = row(page, "Food").getByRole("textbox", { name: "Food budget" }); + await expect(input).toBeFocused(); + expect((await row(page, "Food").boundingBox())!.height).toBe(before.height); + await input.fill("9000"); + await input.press("Enter"); + await expect(row(page, "Food")).toContainText("₹9,000"); + expect(puts).toEqual([{ categories: [{ category: "Food", limit: 9000 }] }]); +}); + +test("Esc cancels an edit without saving", async ({ page }) => { + let puts = 0; + page.on("request", (r) => r.method() === "PUT" && puts++); + await openBudget(page); + await row(page, "Food").getByRole("button", { name: "Edit Food budget" }).click(); + await row(page, "Food").getByRole("textbox").fill("1"); + await row(page, "Food").getByRole("textbox").press("Escape"); + await expect(row(page, "Food")).toContainText("₹6,000"); + expect(puts).toBe(0); +}); + +test("add a category", async ({ page }) => { + await openBudget(page); + await page.getByRole("button", { name: "Add category" }).click(); + await page.getByRole("textbox", { name: "New category", exact: true }).fill("Health"); + await page.getByRole("textbox", { name: "New category budget" }).fill("3000"); + await page.getByRole("textbox", { name: "New category budget" }).press("Enter"); + await expect(row(page, "Health")).toContainText("₹3,000"); +}); + +test("edit budget changes the overall total", async ({ page }) => { + await openBudget(page); + await page.getByRole("button", { name: "Edit budget" }).click(); + const input = page.getByRole("textbox", { name: "Monthly budget" }); + await expect(input).toBeFocused(); + await input.fill("120000"); + await input.press("Enter"); + await expect(page.getByTestId("budget-hero")).toContainText("of ₹120,000"); +}); + +test("a past month is read-only and loads that month", async ({ page }) => { + const asked = page.waitForRequest((r) => r.url().includes(`/api/budget?month=${ym(1)}`)); + await openBudget(page, { ...sample, budgetMonths: { [ym(1)]: { monthly_budget: 100000, spent: 79900, categories: [c("Food", 5000, 4100)] } } }); + await expect(page.getByRole("button", { name: "Next month" })).toBeDisabled(); + await page.getByRole("button", { name: "Previous month" }).click(); + await asked; + await expect(page.getByTestId("month-label")).toContainText(monthName(1)); + await expect(page.getByTestId("budget-hero")).toContainText("₹79,900"); + await expect(page.getByRole("button", { name: "Let Lumen draft" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Edit Food budget" })).toHaveCount(0); + await expect(page.getByTestId("even-pace")).toHaveCount(0); +}); + +test("draft panel: pending lines with named buttons, skip one, approve the rest", async ({ page }) => { + const approved: string[] = []; + page.on("request", (r) => r.url().endsWith("/approve") && approved.push(r.url().split("/").at(-2)!)); + await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500), line("p2", "Travel", 4000), line("p3", "Health", 2000)] }); + const panel = page.getByTestId("draft-panel"); + await expect(panel.getByTestId("draft-line")).toHaveCount(3); + await expect(panel.getByTestId("draft-line").filter({ hasText: "Food" })).toContainText("was ₹6,000"); + await expect(panel.getByTestId("draft-line").filter({ hasText: "Health" })).toContainText("new"); + await panel.getByRole("button", { name: "Skip Travel" }).click(); + await expect(panel.getByTestId("draft-line").filter({ hasText: "Travel" })).toContainText("Skipped"); + await panel.getByRole("button", { name: "Approve remaining 2" }).click(); + await expect(panel.getByText("Approved")).toHaveCount(2); + expect(approved.sort()).toEqual(["p1", "p3"]); + await expect(panel.getByRole("button", { name: "Approve Food at ₹6,500" })).toHaveCount(0); +}); + +test("let Lumen draft files a draft", async ({ page }) => { + await openBudget(page, { ...sample, draft: [line("p1", "Food", 6500)] }); + await page.getByRole("button", { name: "Let Lumen draft" }).click(); + await expect(page.getByTestId("draft-panel").getByRole("button", { name: "Approve Food at ₹6,500" })).toBeVisible(); +}); + +test("a draft with no history says why", async ({ page }) => { + await openBudget(page, { ...sample, draft: [] }); + await page.getByRole("button", { name: "Let Lumen draft" }).click(); + await expect(page.getByTestId("draft-panel")).toContainText("Not enough history yet"); +}); + +test("?draft=1 drafts on arrival (Home's promo links here)", async ({ page }) => { + const drafted = page.waitForRequest((r) => r.url().endsWith("/api/budget/draft") && r.method() === "POST"); + await openBudget(page, { ...sample, draft: [line("p1", "Food", 6500)] }, "/budget?draft=1"); + await drafted; + await expect(page.getByTestId("draft-line")).toHaveCount(1); +}); + +test("a replaced draft says so", async ({ page }) => { + await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500)], replaced: ["p1"] }); + await page.getByRole("button", { name: "Approve Food at ₹6,500" }).click(); + await expect(page.getByTestId("draft-panel")).toContainText("This draft was replaced. Draft again."); +}); + +test("approve remaining ignores a double click", async ({ page }) => { + let approvals = 0; + page.on("request", (r) => r.url().endsWith("/approve") && approvals++); + await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500), line("p2", "Travel", 4000)] }); + await page.getByRole("button", { name: "Approve remaining 2" }).dblclick(); + await expect(page.getByTestId("draft-panel").getByText("Approved")).toHaveCount(2); + await page.waitForTimeout(300); + expect(approvals).toBe(2); +}); + +test("last months: budget outline vs actual, over in red, track record", async ({ page }) => { + await openBudget(page); + const bars = page.getByTestId("history-month"); + await expect(bars).toHaveCount(4); + await expect(bars.nth(1)).toHaveAttribute("data-over", "true"); + await expect(bars.nth(2)).not.toHaveAttribute("data-over", "true"); + await expect(bars.nth(0).getByTestId("history-budget")).toHaveCount(0); // no budget then + await expect(page.getByTestId("track-record")).toContainText("1 of 2"); +}); + +test("budget has no layout shift while loading", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.addInitScript(() => { + (window as unknown as { cls: number }).cls = 0; + new PerformanceObserver((list) => { + for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[]) + if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value; + }).observe({ type: "layout-shift", buffered: true }); + }); + await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500)], delayMs: 1500 }); + await expect(page.getByTestId("draft-line")).toHaveCount(1); + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01); +}); + +test("budget fits a phone without sideways scroll", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openBudget(page, { ...sample, history: [5, 4, 3, 2, 1, 0].map((n) => ({ month: ym(n), budget: 100000, spent: 104200 })) }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + // the page clips rather than scrolls, so check nothing ends past the screen edge either + const ends = await page + .locator('header button, [data-testid="history-month"], [data-testid="budget-row"] > *') + .evaluateAll((els) => els.map((e) => [e.getAttribute("aria-label") ?? e.textContent, e.getBoundingClientRect().right] as const)); + for (const [what, right] of ends) expect(right, String(what)).toBeLessThanOrEqual(375); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 209ceea..7d0da98 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -17,8 +17,14 @@ export type HomeFixture = { summary?: { found: object[]; more?: number; worth_total?: number; activity?: object[] }; /** GET/PUT /api/budget; default: nothing set */ budget?: { monthly_budget: number | null; spent: number; categories: { category: string; limit: number; spent: number }[] }; + /** GET /api/budget?month=YYYY-MM for other months, keyed by month; default: the current `budget` */ + budgetMonths?: Record; + /** GET /api/budget/history months, oldest first; default [] */ + history?: { month: string; budget: number | null; spent: number }[]; /** POST /api/budget/draft proposals; default [] */ draft?: object[]; + /** GET /api/agent/proposals (pending); default [] */ + proposals?: object[]; currency?: string; /** proposal ids whose approve returns 409 (a newer draft replaced them) */ replaced?: string[]; @@ -130,8 +136,25 @@ export async function signIn( const s = home.summary ?? { found: review }; return route.fulfill({ json: { success: true, more: 0, worth_total: 0, activity: [], ...s } }); } - if (path === "/api/budget") - return route.fulfill({ json: { success: true, month: "", ...(home.budget ?? { monthly_budget: null, spent: 0, categories: [] }) } }); + if (path === "/api/budget") { + const none = { monthly_budget: null, spent: 0, categories: [] }; + // PUT is stateful like the real one: a null limit removes the category + if (route.request().method() === "PUT") { + const body = route.request().postDataJSON() as { monthly_budget?: number | null; categories?: { category: string; limit: number | null }[] }; + const b = (home.budget = structuredClone(home.budget ?? none)); + if ("monthly_budget" in body) b.monthly_budget = body.monthly_budget ?? null; + for (const c of body.categories ?? []) { + const row = b.categories.find((x) => x.category.toLowerCase() === c.category.toLowerCase()); + if (c.limit === null) b.categories = b.categories.filter((x) => x !== row); + else if (row) row.limit = c.limit; + else b.categories.push({ category: c.category, limit: c.limit, spent: 0 }); + } + } + const month = url.searchParams.get("month"); + const state = (month && home.budgetMonths?.[month]) || home.budget || none; + return route.fulfill({ json: { success: true, month: month ?? "", ...state } }); + } + if (path === "/api/budget/history") return route.fulfill({ json: { success: true, months: home.history ?? [] } }); if (path === "/api/budget/draft") return route.fulfill({ json: { success: true, proposals: home.draft ?? [] } }); const proposal = path.match(/^\/api\/agent\/proposals\/([^/]+)\/(approve|reject)$/); if (proposal && home.replaced?.includes(proposal[1])) @@ -143,7 +166,7 @@ export async function signIn( queue = queue.filter((q) => q.id !== decided[1]); return route.fulfill({ json: { item: { ...item, status: `${decided[2]}d` } } }); } - if (path === "/api/agent/proposals") return route.fulfill({ json: { proposals: [] } }); + if (path === "/api/agent/proposals") return route.fulfill({ json: { proposals: home.proposals ?? [] } }); if (path === "/chat/suggestions") return route.fulfill({ json: { suggestions: opts.chat?.suggestions ?? [] } }); if (path === "/chat/threads") return route.fulfill({ diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 66e04fe..b53d04b 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -185,7 +185,7 @@ test("coming up lists the next four bills", async ({ page }) => { test("budget card is honest with no budget", async ({ page }) => { await openHome(page); await expect(page.getByText("No budget yet")).toBeVisible(); - await expect(page.getByRole("button", { name: "DRAFT BUDGET" })).toBeEnabled(); + await expect(page.getByRole("link", { name: "Draft budget" })).toBeVisible(); }); test("activity comes from the summary", async ({ page }) => { @@ -278,30 +278,10 @@ test("budget card says when over", async ({ page }) => { await expect(page.getByTestId("budget-card")).toContainText("₹3,412 over"); }); -const draftLine = (id: string, target: string, limit: number) => ({ - id, type: "set_budget", target, risk: "low", reason: "Averaged a month over Jul–Sep, rounded up.", - payload: { limit, basis: { months: [], average: limit } }, status: "pending", created_at: null, -}); - -test("draft budget opens proposals and approve all applies them", async ({ page }) => { - await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)] }); - await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByTestId("draft-line")).toHaveCount(2); - await expect(dialog).toContainText("₹4,000"); - const approvals = [page.waitForRequest(/proposals\/p1\/approve/), page.waitForRequest(/proposals\/p2\/approve/)]; - await dialog.getByRole("button", { name: "Approve all" }).click(); - await Promise.all(approvals); - await expect(dialog.getByText("Approved")).toHaveCount(2); - const refetch = page.waitForRequest((r) => r.url().endsWith("/api/budget") && r.method() === "GET"); - await page.keyboard.press("Escape"); - await refetch; -}); - -test("draft with no history explains why", async ({ page }) => { - await openHome(page, { ...typical, draft: [] }); - await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); - await expect(page.getByRole("dialog")).toContainText("Not enough history yet"); +test("the draft promo and the budget card lead to the Budget page", async ({ page }) => { + await openHome(page, typical); + await expect(page.getByRole("link", { name: "Draft budget" })).toHaveAttribute("href", "/budget?draft=1"); + await expect(page.getByTestId("budget-card").getByRole("link", { name: /Budget in/ })).toHaveAttribute("href", "/budget"); }); test("calendar uses the user's currency", async ({ page }) => { @@ -316,39 +296,6 @@ test("sidebar badge uses the count endpoint", async ({ page }) => { await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("2"); }); -test("draft dialog ignores a double click", async ({ page }) => { - await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)] }); - let approvals = 0; - page.on("request", (r) => r.url().includes("/proposals/") && r.url().endsWith("/approve") && approvals++); - await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); - await page.getByRole("dialog").getByRole("button", { name: "Approve all" }).dblclick(); - await expect(page.getByRole("dialog").getByText("Approved")).toHaveCount(2); - await page.waitForTimeout(300); - expect(approvals).toBe(2); -}); - -test("closing the draft dialog refreshes activity", async ({ page }) => { - await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000)] }); - await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); - await page.getByRole("dialog").getByRole("button", { name: "Approve all" }).click(); - await expect(page.getByRole("dialog").getByText("Approved")).toHaveCount(1); - const summary = page.waitForRequest((r) => r.url().endsWith("/api/home/summary")); - await page.keyboard.press("Escape"); - await summary; -}); - -test("a replaced draft says so", async ({ page }) => { - await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)], replaced: ["p1"] }); - let approvals = 0; - page.on("request", (r) => r.url().includes("/proposals/") && r.url().endsWith("/approve") && approvals++); - await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); - const dialog = page.getByRole("dialog"); - await dialog.getByRole("button", { name: "Approve all" }).click(); - await expect(dialog).toContainText("This draft was replaced. Draft again."); - 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; diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index d010ecf..203bba8 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -61,9 +61,9 @@ test("sidebar shows the new IA", async ({ page }) => { await signIn(page); await page.goto("/home"); const links = sidebar(page).locator('[data-sidebar="menu-button"]'); - await expect(links).toHaveText(["Home", "Spending", "Bills & subscriptions", "Receipts & statements", "Ask Lumen", /^Ask anything/]); + await expect(links).toHaveText(["Home", "Spending", "Bills & subscriptions", "Budget", "Receipts & statements", "Ask Lumen", /^Ask anything/]); // every group is labelled (owner, 2026-10-22) - for (const group of ["Overview", "Track", "Records", "Assistant"]) { + for (const group of ["Overview", "Track", "Plan", "Records", "Assistant"]) { await expect(sidebar(page).getByText(group, { exact: true })).toBeVisible(); } for (const old of ["Workspace", "Compare", "Financial command center"]) { diff --git a/frontend/src/app/(app)/budget/_components/categories.tsx b/frontend/src/app/(app)/budget/_components/categories.tsx new file mode 100644 index 0000000..59c00cf --- /dev/null +++ b/frontend/src/app/(app)/budget/_components/categories.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { PageCard } from "@/components/page/page-card"; +import type { BudgetState } from "@/lib/api/client"; +import { usedTone } from "@/lib/budget-math"; +import { useMoney } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +const TONE = { good: "bg-good", warn: "bg-amber-500", bad: "bg-bad" } as const; +const COLS = "grid grid-cols-[minmax(0,1.2fr)_76px_minmax(0,1fr)_minmax(0,1fr)] items-center gap-2 sm:gap-3 sm:grid-cols-[minmax(0,1.3fr)_100px_90px_minmax(0,1fr)_minmax(0,1.4fr)]"; + +// By category (spec 2026-10-22 §4.4): budget, spent, left and a used bar per category. On the current month the +// budget amount is a button that turns into an input of the same height (Enter saves, Esc cancels), and new +// categories are added inline. Past months are read-only. +export function BudgetCategories({ + budget, + total, + editable, + onSet, + loading, +}: { + budget: BudgetState | null; + total: number | null; + editable: boolean; + onSet: (category: string, limit: number) => Promise; + loading: boolean; +}) { + const money = useMoney(); + const [editing, setEditing] = useState(null); + const [adding, setAdding] = useState(false); + const cats = budget?.categories ?? []; + const sum = cats.reduce((s, c) => s + c.limit, 0); + const over = cats.filter((c) => usedTone(c.spent, c.limit) === "bad").length; + const close = cats.filter((c) => usedTone(c.spent, c.limit) === "warn").length; + + return ( + + {over > 0 && {over} over} + {close > 0 && {close} close} + + } + loading={loading} + skeletonHeight={180} + data-testid="budget-categories" + > + {cats.length === 0 && !adding ? ( +

    + No category budgets{editable ? " yet. Add one, or let Lumen draft them from your spending." : " this month."} +

    + ) : ( +
    +
    + Category + Budget + Spent + Left + Used +
    + {cats.map((c) => { + const tone = usedTone(c.spent, c.limit); + const left = c.limit - c.spent; + return ( +
    + {c.category} + + {editing === c.category ? ( + setEditing(null)} + onSave={async (v) => { + await onSet(c.category, v); + setEditing(null); + }} + /> + ) : editable ? ( + + ) : ( + money(c.limit) + )} + + {money(c.spent)} + + {left < 0 ? `${money(-left)} over` : money(left)} + + + + + + {Math.round((c.spent / c.limit) * 100)}% + +
    + ); + })} + {adding && ( + setAdding(false)} + onSave={async (name, v) => { + await onSet(name, v); + setAdding(false); + }} + /> + )} +
    + )} + {(total !== null || editable) && ( +
    + {total !== null && cats.length > 0 && ( + + Categories add up to {money(sum)} + {sum < total ? ` · ${money(total - sum)} of your ${money(total)} is unassigned` : sum > total ? ` · ${money(sum - total)} more than your ${money(total)}` : ""} + + )} + {editable && !adding && ( + + )} +
    + )} +
    + ); +} + +function AmountInput({ label, initial, onSave, onCancel }: { label: string; initial: number; onSave: (v: number) => void; onCancel: () => void }) { + const [v, setV] = useState(String(initial)); + const ref = useRef(null); + useEffect(() => ref.current?.select(), []); + return ( + setV(e.target.value)} + onBlur={onCancel} + onKeyDown={(e) => { + if (e.key === "Escape") onCancel(); + if (e.key === "Enter" && Number(v) > 0) onSave(Number(v)); + }} + className="-mx-1.5 h-7 w-full max-w-[96px] rounded-md border border-border bg-card px-1.5 tabular-nums" + /> + ); +} + +function NewCategory({ onSave, onCancel }: { onSave: (name: string, v: number) => void; onCancel: () => void }) { + const [name, setName] = useState(""); + const [v, setV] = useState(""); + const ref = useRef(null); + useEffect(() => ref.current?.focus(), []); + const keys = (e: React.KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + if (e.key === "Enter" && name.trim() && Number(v) > 0) onSave(name.trim(), Number(v)); + }; + return ( +
    + setName(e.target.value)} onKeyDown={keys} maxLength={60} className="h-7 min-w-0 rounded-md border border-border bg-card px-1.5" /> + setV(e.target.value)} onKeyDown={keys} className="h-7 w-full max-w-[96px] rounded-md border border-border bg-card px-1.5" /> + Enter to save · Esc +
    + ); +} diff --git a/frontend/src/app/(app)/budget/_components/draft-panel.tsx b/frontend/src/app/(app)/budget/_components/draft-panel.tsx new file mode 100644 index 0000000..eb49b89 --- /dev/null +++ b/frontend/src/app/(app)/budget/_components/draft-panel.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Sparkles } from "lucide-react"; +import { agentApi, type BudgetState, type Proposal } from "@/lib/api/client"; +import { useMoney } from "@/lib/format"; +import { toast } from "@/lib/toast"; +import { cn } from "@/lib/utils"; + +export type DraftLine = Proposal & { decided?: "approve" | "reject" }; + +// Lumen's draft, on the page (spec 2026-10-22 §4.4; it was a dialog on Home). Each line is a pending set_budget +// proposal: the proposed limit against the one in force, the basis sentence, and Skip / Approve named for the category. +// Nothing changes until a line is approved. No month-bars glyph: the basis has no per-month amounts to draw. +export function DraftPanel({ + lines, + setLines, + budget, + monthName, + onDecided, +}: { + lines: DraftLine[]; + setLines: (f: (ls: DraftLine[] | null) => DraftLine[] | null) => void; + budget: BudgetState | null; + monthName: string; + onDecided: () => void; +}) { + const money = useMoney(); + const [deciding, setDeciding] = useState(false); + const [replaced, setReplaced] = useState(false); + const inFlight = useRef(false); // a ref, so a double click is blocked before React re-renders + // the limits before this draft: approving a line reloads the budget, and "was" must not become "no change" + const [current] = useState(() => new Map((budget?.categories ?? []).map((c) => [c.category.toLowerCase(), c.limit]))); + const was = (l: DraftLine) => current.get(l.target.toLowerCase()); + const limit = (l: DraftLine) => Number(l.payload.limit); + const pending = lines.filter((l) => !l.decided); + const taking = lines.filter((l) => l.decided !== "reject"); + const after = [...current.entries()].filter(([k]) => !taking.some((l) => l.target.toLowerCase() === k)).reduce((s, [, v]) => s + v, 0) + + taking.reduce((s, l) => s + limit(l), 0); + + async function decide(ids: string[], decision: "approve" | "reject") { + if (inFlight.current) return; + inFlight.current = true; + setDeciding(true); + try { + for (const id of ids) { + await agentApi.decide(id, decision); + setLines((ls) => ls?.map((l) => (l.id === id ? { ...l, decided: decision } : l)) ?? ls); + } + } catch (error) { + // a newer draft (from here or from Ask Lumen) retired these lines + if ((error as { response?: { status?: number } })?.response?.status === 409) setReplaced(true); + else toast.error("Couldn't save that decision."); + } finally { + inFlight.current = false; + setDeciding(false); + onDecided(); + } + } + + return ( +
    +
    + +

    Lumen's draft for {monthName}

    + {lines.length > 0 && ( + + {lines.length - pending.length} of {lines.length} decided + + )} +
    + {lines.length === 0 ? ( +

    Not enough history yet. Lumen needs about two months of spending to draft a budget.

    + ) : ( + <> +

    Each line is a monthly limit; nothing changes until you approve it.

    +
    + {lines.map((l) => { + const old = was(l); + return ( +
    +
    + {l.target} + {money(limit(l))} + + {old === undefined ? "new" : old === limit(l) ? "no change" : `was ${money(old)}`} + +
    +
    +

    {l.reason}

    + + {l.decided ? ( + + {l.decided === "approve" ? "✓ Approved" : old === undefined ? "Skipped" : `Skipped · keeps ${money(old)}`} + + ) : ( + <> + + + + )} + +
    +
    + ); + })} +
    + {replaced && ( +

    + This draft was replaced. Draft again. +

    + )} +
    + {pending.length > 0 && ( + + )} + {budget?.monthly_budget ? ( + + Categories would add up to {money(after)} of {money(budget.monthly_budget)} + + ) : null} +
    + + )} +
    + ); +} diff --git a/frontend/src/app/(app)/budget/_components/hero.tsx b/frontend/src/app/(app)/budget/_components/hero.tsx new file mode 100644 index 0000000..41e66e5 --- /dev/null +++ b/frontend/src/app/(app)/budget/_components/hero.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { CumulativeChart } from "@/components/page/cumulative-chart"; +import { PageCard } from "@/components/page/page-card"; +import { evenLine, pace } from "@/lib/budget-math"; +import { useMoney } from "@/lib/format"; +import { cumulative, dayIndex, period, type Txn } from "@/lib/home-math"; +import { cn } from "@/lib/utils"; + +// Budget's hero (spec 2026-10-22 §4.4): spent of total, a bar with the even-pace tick, what's left and safe per day, +// and the running total against a straight line to the budget. `editing` swaps the total for an input in place. +export function BudgetHero({ + month, + monthName, + today, + total, + spent, + txns, + editing, + onSave, + onCancel, + loading, +}: { + month: Date; + monthName: string; + today: Date; + total: number | null; + spent: number; + txns: Txn[]; + editing: boolean; + onSave: (total: number) => void; + onCancel: () => void; + loading: boolean; +}) { + const money = useMoney(); + const p = pace(total ?? 0, spent, today, month); + const days = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); + const span = period(month, new Date(month.getFullYear(), month.getMonth(), days)); + const t = p.evenPct === null ? days - 1 : dayIndex(span, today); + const over = total !== null && spent > total; + const tone = over ? "bad" : p.vsPace !== null && p.vsPace > 0 ? "warn" : "good"; + return ( + + {over ? "Over budget" : p.evenPct === null ? "Within budget" : tone === "warn" ? "Ahead of pace" : "On track"} + + ) + } + loading={loading} + skeletonHeight={250} + data-testid="budget-hero" + > +
    +
    +
    + {money(Math.round(spent))} + + {editing ? : total !== null ? `of ${money(total)}` : "no budget set"} + +
    + {total !== null && ( + <> +
    + + {p.evenPct !== null && ( + + )} +
    +
    + {Math.round(p.pct * 100)}% spent + {p.evenPct !== null && Even pace today: {Math.round(p.evenPct * 100)}%} +
    +
    + + {p.evenPct !== null && } + {p.perDay !== null && } +
    + + )} +
    +
    +
    + Pace + Actual + {total !== null && ( + Even pace to {money(total)} + )} +
    + +
    +
    +
    + ); +} + +function Stat({ label, value, testId, bad }: { label: string; value: string; testId: string; bad?: boolean }) { + return ( +
    + {label} + {value} +
    + ); +} + +function TotalInput({ initial, onSave, onCancel }: { initial: number | null; onSave: (v: number) => void; onCancel: () => void }) { + const [v, setV] = useState(initial ? String(initial) : ""); + const ref = useRef(null); + useEffect(() => ref.current?.focus(), []); + return ( + + ); +} diff --git a/frontend/src/app/(app)/budget/_components/history.tsx b/frontend/src/app/(app)/budget/_components/history.tsx new file mode 100644 index 0000000..ff8021f --- /dev/null +++ b/frontend/src/app/(app)/budget/_components/history.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { PageCard } from "@/components/page/page-card"; +import type { BudgetMonth } from "@/lib/api/client"; +import { runRate } from "@/lib/budget-math"; +import { useMoney } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +const H = 130; +const short = (m: string) => new Date(Number(m.slice(0, 4)), Number(m.slice(5)) - 1, 1).toLocaleString("en-US", { month: "short" }); + +// The last months (spec 2026-10-22 §4.4): each month's budget as a dashed outline (only when it had one) and what was +// spent as a bar inside it, green under and red over. This month is "so far", with a pale bar for where it's heading +// at the pace so far. Beside it the track record over the past months that had a budget. +export function BudgetHistory({ months, today, loading }: { months: BudgetMonth[]; today: Date; loading: boolean }) { + const money = useMoney(); + const now = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`; + const projected = (m: BudgetMonth) => (m.month === now ? runRate(m.spent, today) : m.spent); + const max = Math.max(...months.map((m) => Math.max(m.budget ?? 0, projected(m))), 1); + const h = (v: number) => Math.max(2, (v / max) * H); + const judged = months.filter((m) => m.month !== now && m.budget !== null); + const under = judged.filter((m) => m.spent <= m.budget!).length; + const overMonths = judged.filter((m) => m.spent > m.budget!).map((m) => new Date(Number(m.month.slice(0, 4)), Number(m.month.slice(5)) - 1, 1).toLocaleString("en-US", { month: "long" })); + + return ( + + Within budget + Over + Budget + + } + loading={loading} + skeletonHeight={176} + data-testid="budget-history" + > +
    +
    + {months.map((m) => { + const cur = m.month === now; + const over = m.budget !== null && m.spent > m.budget; + return ( +
    + {/* amounts don't fit six across a phone; the bar's title still has them */} + {money(Math.round(m.spent))} +
    + {m.budget !== null && ( + + )} + {cur && } + +
    + + {short(m.month)} + {cur && · so far} + +
    + ); + })} +
    + {judged.length > 0 && ( +
    + Track record +
    + {under} of {judged.length} months under +
    +

    + {overMonths.length ? `Over in ${overMonths.join(", ")}.` : "Under budget every month."} +

    +
    + )} +
    +
    + ); +} diff --git a/frontend/src/app/(app)/budget/budget-content.tsx b/frontend/src/app/(app)/budget/budget-content.tsx new file mode 100644 index 0000000..3b3d719 --- /dev/null +++ b/frontend/src/app/(app)/budget/budget-content.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ChevronLeft, ChevronRight, Pencil, Sparkles } from "lucide-react"; +import { PageHeader } from "@/components/page/page-header"; +import { Button } from "@/components/ui/button"; +import { budgetApi } from "@/lib/api/client"; +import { pace, usedTone } from "@/lib/budget-math"; +import { useMoney } from "@/lib/format"; +import { toast } from "@/lib/toast"; +import { Brief } from "../home/_components/brief"; +import { BudgetCategories } from "./_components/categories"; +import { DraftPanel, type DraftLine } from "./_components/draft-panel"; +import { BudgetHero } from "./_components/hero"; +import { BudgetHistory } from "./_components/history"; +import { useBudgetData, ym } from "./use-budget-data"; + +const monthYear = (d: Date) => d.toLocaleString("en-US", { month: "long", year: "numeric" }); +const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" }); + +// Budget (UI revamp Phase 5d, spec 2026-10-22 §4.4): am I on track, and what should my plan be? Budgets are saved per +// month, so past months are shown as they were and are read-only; edits and the draft apply from this month on. +// History sits above By category so the cards whose height depends on the data come last (no layout shift). +export default function BudgetContent() { + const money = useMoney(); + const today = useMemo(() => { + const n = new Date(); + return new Date(n.getFullYear(), n.getMonth(), n.getDate()); + }, []); + const [month, setMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1)); + const current = ym(month) === ym(today); + const data = useBudgetData(month, today); + const loading = data.status === "loading"; + const [editingTotal, setEditingTotal] = useState(false); + const [lines, setLines] = useState(null); + const [draftKey, setDraftKey] = useState(0); + const [drafting, setDrafting] = useState(false); + const router = useRouter(); + const params = useSearchParams(); + const autoDraft = useRef(params.get("draft") === "1"); + + // pending lines from an earlier draft show once loaded; ?draft=1 (Home's promo) files a fresh one on arrival + useEffect(() => { + if (loading || lines !== null) return; + if (autoDraft.current) { + autoDraft.current = false; + router.replace("/budget", { scroll: false }); + void draft(); + } else if (data.pending.length) setLines(data.pending); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading]); + + async function draft() { + setDrafting(true); + try { + setLines(await budgetApi.draft()); + setDraftKey((k) => k + 1); + } catch { + toast.error("Couldn't draft a budget right now."); + } finally { + setDrafting(false); + } + } + + async function save(body: Parameters[0]) { + try { + data.setBudget(await budgetApi.put(body)); + data.reload(); + } catch { + toast.error("Couldn't save the budget."); + } + } + + const b = data.budget; + const catSum = (b?.categories ?? []).reduce((s, c) => s + c.limit, 0); + const total = b?.monthly_budget || catSum || null; + const spent = b?.spent ?? 0; + const brief = (() => { + if (loading) return null; + const overCats = (b?.categories ?? []).filter((c) => usedTone(c.spent, c.limit) === "bad").map((c) => c.category); + const overText = overCats.length === 1 ? ` ${overCats[0]} is the one line over.` : overCats.length > 1 ? ` ${overCats.length} categories are over.` : ""; + if (total === null) return current ? `No budget yet. You've spent ${money(Math.round(spent))} this month; set one below or let Lumen draft it.` : `No budget for ${monthLong(month)}. You spent ${money(Math.round(spent))}.`; + if (!current) return spent > total ? `${monthLong(month)} went ${money(Math.round(spent - total))} over its ${money(total)} budget.${overText}` : `${monthLong(month)} came in ${money(Math.round(total - spent))} under its ${money(total)} budget.${overText}`; + if (spent > total) return `You're ${money(Math.round(spent - total))} over this month's budget.${overText}`; + const p = pace(total, spent, today, month); + return p.vsPace! <= 0 ? `You're on track: ${money(Math.round(-p.vsPace!))} under where an even pace would put you.${overText}` : `You're ${money(Math.round(p.vsPace!))} ahead of an even pace, with ${p.daysLeft} days to go.${overText}`; + })(); + + const step = (d: number) => { + setEditingTotal(false); + setMonth((m) => new Date(m.getFullYear(), m.getMonth() + d, 1)); + }; + const actions = ( + <> +
    + + {monthYear(month)} + +
    + {current && ( + <> + + + + )} + + ); + + return ( +
    +
    +
    + } actions={actions} /> + { + setEditingTotal(false); + void save({ monthly_budget: v }); + }} + onCancel={() => setEditingTotal(false)} + loading={loading} + /> +
    + +
    +
    + save({ categories: [{ category, limit }] })} + loading={loading} + /> + {lines && current && ( + + )} +
    +
    +
    + ); +} diff --git a/frontend/src/app/(app)/budget/page.tsx b/frontend/src/app/(app)/budget/page.tsx new file mode 100644 index 0000000..d83d3b4 --- /dev/null +++ b/frontend/src/app/(app)/budget/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import BudgetContent from "./budget-content"; + +export const metadata: Metadata = { title: "Budget" }; + +// useSearchParams (?draft=1) needs a Suspense boundary for the static build +export default function Page() { + return ( + + + + ); +} diff --git a/frontend/src/app/(app)/budget/use-budget-data.ts b/frontend/src/app/(app)/budget/use-budget-data.ts new file mode 100644 index 0000000..4731214 --- /dev/null +++ b/frontend/src/app/(app)/budget/use-budget-data.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { agentApi, budgetApi, type BudgetMonth, type BudgetState, type Proposal } from "@/lib/api/client"; +import type { Txn } from "@/lib/home-math"; +import { fetchTxns } from "../home/use-home-data"; + +export const ym = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fulfilled" ? r.value : fallback); + +export type BudgetData = { + status: "loading" | "ready"; + budget: BudgetState | null; + txns: Txn[]; + history: BudgetMonth[]; + /** set_budget proposals still waiting for a decision when the page opened */ + pending: Proposal[]; + /** after a save: replace the shown month's state */ + setBudget: (b: BudgetState) => void; + /** after budget decisions: refetch this month's budget and the history */ + reload: () => void; +}; + +// The Budget page's data (spec 2026-10-22 §4.4). The shown month's budget and transactions reload when the month +// changes, keeping the old numbers until the new ones arrive (like Spending); history and pending draft lines load once. +export function useBudgetData(month: Date, today: Date): BudgetData { + const [shown, setShown] = useState<{ budget: BudgetState | null; txns: Txn[] } | null>(null); + const [once, setOnce] = useState<{ history: BudgetMonth[]; pending: Proposal[] } | null>(null); + const key = ym(month); + const day = today.toDateString(); + + useEffect(() => { + let live = true; + const end = new Date(month.getFullYear(), month.getMonth() + 1, 0); + Promise.allSettled([budgetApi.get(key === ym(today) ? undefined : key), fetchTxns(month, end < today ? end : today)]).then(([b, t]) => { + if (live) setShown({ budget: value(b, null), txns: value(t, []) }); + }); + return () => { + live = false; + }; + // month is a fresh Date each render; its key is what matters + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, day]); + + useEffect(() => { + let live = true; + Promise.allSettled([budgetApi.history(6), agentApi.proposals()]).then(([h, p]) => { + if (live) setOnce({ history: value(h, []), pending: value(p, []).filter((x) => x.type === "set_budget") }); + }); + return () => { + live = false; + }; + }, [day]); + + const setBudget = useCallback((budget: BudgetState) => setShown((s) => (s ? { ...s, budget } : s)), []); + const reload = useCallback(() => { + Promise.allSettled([budgetApi.get(), budgetApi.history(6)]).then(([b, h]) => { + if (b.status === "fulfilled") setShown((s) => (s ? { ...s, budget: b.value } : s)); + if (h.status === "fulfilled") setOnce((o) => (o ? { ...o, history: h.value } : o)); + }); + }, []); + + return { + status: shown && once ? "ready" : "loading", + budget: shown?.budget ?? null, + txns: shown?.txns ?? [], + history: once?.history ?? [], + pending: once?.pending ?? [], + setBudget, + reload, + }; +} diff --git a/frontend/src/app/(app)/home/_components/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx index b611e2a..79ba7e6 100644 --- a/frontend/src/app/(app)/home/_components/budget-card.tsx +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -1,10 +1,8 @@ "use client"; -import { useRef, useState } from "react"; -import { agentApi, budgetApi, type BudgetState, type Proposal } from "@/lib/api/client"; +import Link from "next/link"; +import type { BudgetState } from "@/lib/api/client"; 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 { PageCard } from "@/components/page/page-card"; @@ -20,7 +18,7 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg const pct = total ? Math.round((spent / total) * 100) : 0; return ( - + {total ? ( <>
    @@ -72,126 +70,17 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg ); } -type Line = Proposal & { decided?: "approve" | "reject" }; - -// "Let Lumen draft your budget": files set_budget proposals (POST /api/budget/draft) and lets you approve each line. -export function DraftBudgetPromo({ onChanged }: { onChanged: () => void }) { - const money = useMoney(); - const [open, setOpen] = useState(false); - const [busy, setBusy] = useState(false); - const [lines, setLines] = useState(null); - const [deciding, setDeciding] = useState(false); - const [replaced, setReplaced] = useState(false); - const inFlight = useRef(false); // a ref, so a double click is blocked before React re-renders - - async function draft() { - setBusy(true); - try { - setLines(await budgetApi.draft()); - setReplaced(false); - setOpen(true); - } catch { - toast.error("Couldn't draft a budget right now."); - } finally { - setBusy(false); - } - } - - async function decide(ids: string[], decision: "approve" | "reject") { - if (inFlight.current) return; - inFlight.current = true; - setDeciding(true); - try { - for (const id of ids) { - await agentApi.decide(id, decision); - setLines((ls) => ls?.map((l) => (l.id === id ? { ...l, decided: decision } : l)) ?? ls); - } - } catch (error) { - // a newer draft (from here or from Ask Lumen) retired these lines - if ((error as { response?: { status?: number } })?.response?.status === 409) setReplaced(true); - else toast.error("Couldn't save that decision."); - } finally { - inFlight.current = false; - setDeciding(false); - } - } - - const pending = (lines ?? []).filter((l) => !l.decided).map((l) => l.id); - +// "Let Lumen draft your budget": the draft itself lives on the Budget page (Phase 5d); ?draft=1 files it on arrival. +export function DraftBudgetPromo() { return (

    Let Lumen draft your budget

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

    - - { - setOpen(o); - if (!o) onChanged(); - }} - > - - Your draft budget - Each line is a monthly limit. Nothing is saved until you approve it. - {lines && lines.length === 0 ? ( -

    - Not enough history yet. Lumen needs about two months of spending to draft a budget. -

    - ) : ( - <> -
    - {lines?.map((l) => ( -
    -
    -

    {l.target}

    -

    {l.reason}

    -
    - {money(Number(l.payload.limit))} - - {l.decided ? ( - - {l.decided === "approve" ? "Approved" : "Skipped"} - - ) : ( - <> - - - - )} - -
    - ))} -
    - {replaced && ( -

    - This draft was replaced. Draft again. -

    - )} - - - )} -
    -
    +
    ); } diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index 122b663..ff4c2a1 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -105,7 +105,7 @@ export default function HomeContent() { {/* once you have category budgets, the promo has done its job */} - {!data.budget?.categories.length && } + {!data.budget?.categories.length && }
    diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts index da74c73..94ee77b 100644 --- a/frontend/src/app/(app)/home/use-home-data.ts +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { aiAnalyticsApi, billsApi, budgetApi, homeApi, transactionApi, type BudgetState, type HomeSummary, type RecurringItem } from "@/lib/api/client"; import { parseDay, periods, type Range, type Txn } from "@/lib/home-math"; @@ -28,8 +28,6 @@ export type HomeData = { forecast: Forecast; summary: HomeSummary | null; budget: BudgetState | null; - /** refetch the summary and the budget (after budget decisions) */ - reload: () => void; }; const ymd = (d: Date) => @@ -114,13 +112,6 @@ export function useHomeData(range: Range, today: Date): HomeData { // eslint-disable-next-line react-hooks/exhaustive-deps }, [day]); - const reload = useCallback(() => { - // decisions are POSTs, which already cleared the cached summary, so this refetches it - Promise.allSettled([homeApi.summary(), budgetApi.get()]).then(([summary, budget]) => - setExtras((e) => (e ? { ...e, summary: value(summary, e.summary), budget: value(budget, e.budget) } : e)), - ); - }, []); - return { status: txns && extras ? "ready" : "loading", txns: txns?.txns ?? [], @@ -130,7 +121,6 @@ export function useHomeData(range: Range, today: Date): HomeData { reminders: extras?.reminders ?? [], summary: extras?.summary ?? null, budget: extras?.budget ?? null, - reload, forecast, }; } diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 4e54e0d..0dc6e33 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { CalendarClock, ChartLine, House, Receipt, Sparkles, type LucideIcon } from "lucide-react"; +import { CalendarClock, ChartLine, House, PieChart, Receipt, Sparkles, type LucideIcon } from "lucide-react"; import { Sidebar, @@ -26,12 +26,13 @@ import { eventBus, EVENTS } from "@/lib/events"; type Item = { label: string; url: string; icon: LucideIcon }; // Spec section 3. Every group is labelled (owner, 2026-10-22); labels say what you do, not what the backend is. -// Budget and Forecast (Plan) join once their pages exist. +// Forecast (Plan) joins once its page exists. const HOME: Item = { label: "Home", url: "/home", icon: House }; const TRACK: Item[] = [ { label: "Spending", url: "/spending", icon: ChartLine }, { label: "Bills & subscriptions", url: "/bills", icon: CalendarClock }, ]; +const BUDGET: Item = { label: "Budget", url: "/budget", icon: PieChart }; const RECEIPTS: Item = { label: "Receipts & statements", url: "/receipts", icon: Receipt }; const ASK: Item = { label: "Ask Lumen", url: "/ask", icon: Sparkles }; @@ -106,6 +107,12 @@ export function AppSidebar({ onAsk, ...props }: React.ComponentProps + + Plan + + + + Records diff --git a/frontend/src/components/page/page-header.tsx b/frontend/src/components/page/page-header.tsx index 38a4480..b98fe2b 100644 --- a/frontend/src/components/page/page-header.tsx +++ b/frontend/src/components/page/page-header.tsx @@ -17,7 +17,7 @@ export function PageHeader({

    {title}

    - {actions &&
    {actions}
    } + {actions &&
    {actions}
    }
    {brief &&
    {brief}
    } {children &&
    {children}
    } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 4e638f8..c412267 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -696,8 +696,17 @@ export const homeApi = { summary: (): Promise => cached("/api/home/summary", async () => (await apiClient.get("/api/home/summary")).data), }; +/** One month of GET /api/budget/history: `budget` is null for a past month that had none. */ +export interface BudgetMonth { + month: string; + budget: number | null; + spent: number; +} + export const budgetApi = { - get: async (): Promise => (await apiClient.get("/api/budget")).data, + /** `month` "YYYY-MM"; default the current month. Limits are the ones in force that month. */ + get: async (month?: string): Promise => (await apiClient.get("/api/budget", { params: month ? { month } : {} })).data, + history: async (months = 6): Promise => (await apiClient.get("/api/budget/history", { params: { months } })).data.months, put: async (body: { monthly_budget?: number | null; categories?: { category: string; limit: number | null }[] }): Promise => (await apiClient.put("/api/budget", body)).data, /** Files set_budget proposals (nothing is saved until each is approved); [] = not enough history. */ diff --git a/frontend/src/lib/budget-math.ts b/frontend/src/lib/budget-math.ts new file mode 100644 index 0000000..781f031 --- /dev/null +++ b/frontend/src/lib/budget-math.ts @@ -0,0 +1,33 @@ +// Pure calculations behind the Budget page (UI revamp Phase 5d). No React, no fetching; unit tests in +// e2e/budget-math.spec.ts. + +const daysIn = (d: Date) => new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); +const sameMonth = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth(); + +// Where a month stands against its budget. The even-pace mark, days left, safe-per-day and the gap to an even pace +// only exist for the current month (null / 0 otherwise); safe-per-day is null once the budget is gone. +export function pace(total: number, spent: number, today: Date, month: Date) { + const days = daysIn(month); + const current = sameMonth(today, month); + const evenPct = current ? today.getDate() / days : null; + const left = total - spent; + const daysLeft = current ? days - today.getDate() + 1 : 0; + return { + pct: total > 0 ? spent / total : 0, + evenPct, + left, + daysLeft, + perDay: current && left > 0 ? left / daysLeft : null, + vsPace: evenPct === null ? null : spent - total * evenPct, + }; +} + +export function usedTone(spent: number, limit: number): "good" | "warn" | "bad" { + return spent > limit ? "bad" : spent >= limit * 0.9 ? "warn" : "good"; +} + +// The straight line from nothing to the whole budget on the last day, one value per day. +export const evenLine = (total: number, days: number) => Array.from({ length: days }, (_, i) => (total * (i + 1)) / days); + +// This month's total if the rest of it goes like it has so far. +export const runRate = (spent: number, today: Date) => (spent / today.getDate()) * daysIn(today); From 990f969b2e84325646a75c4a983438f7e91688b6 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:15:01 +0530 Subject: [PATCH 40/47] docs: STORY 35, codebase guide and spec backlog for Phase 5d --- docs/CODEBASE-GUIDE.md | 27 ++++++++++++++----- docs/direction/STORY.md | 20 ++++++++++++++ .../specs/2026-10-20-ui-revamp-design.md | 7 +++-- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index d628543..3e826a2 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -257,7 +257,7 @@ embeds the demo documents once per server and uploads the files in the backgroun (`?tab=documents|import|review`), `/ask`, `/settings`. The old URLs (`/dashboard`, `/analytics`, `/ai-analytics`, `/chatbot`, `/documents`, `/import`, `/review`, `/upload`) redirect to them in `next.config.js` (temporary 307s, query strings carried). Until UI revamp Phase 5, Spending, Bills and Receipts reuse the old pages' `*Content.tsx`. -- **Sidebar** (`components/app-sidebar.tsx`): Home · Track (Spending, Bills & subscriptions) · Receipts & statements +- **Sidebar** (`components/app-sidebar.tsx`): Home · Track (Spending, Bills & subscriptions) · Plan (Budget) · Receipts & statements (badge = flagged review items) · Ask Lumen (Ctrl/⌘+K). It collapses to a 56px icon rail with Ctrl/⌘+B, remembered in the `sidebar_state` cookie. The layout reads that cookie on the sidebar's first render, which is always client-side because the auth guard shows a loader on the server, so there's no jump and no server read. Shortcuts @@ -307,6 +307,15 @@ embeds the demo documents once per server and uploads the files in the backgroun with each bill on its projected days (‹ month › switcher), Upcoming grouped This week / Next week / Later, and the table with tabs, price-rise / looks-stopped / charged-again pills, Mark as cancelled, Not a bill, and How to cancel (`/ask?draft=How do I cancel X?`). Maths in `lib/bills-math.ts` (tests: `e2e/bills-math.spec.ts`). +- **Budget** (`app/(app)/budget/`, Phase 5d): "am I on track, and what should my plan be?" A ‹ month › switcher + (past months read-only, as they were), Edit budget (the overall total, in place) and Let Lumen draft. Hero: spent of + total, a bar with the even-pace tick, left / days left / safe per day (left ÷ days left), and the running total + against a straight line to the budget (`CumulativeChart` with that line as its dashed series). Last 6 months: dashed + budget outline vs actual (green under, red over), this month with a run-rate projection, and the track record. By + category: budget / spent / left / used, the amount editable in place (Enter saves, Esc cancels, same row height), + Add category, and the unassigned footer. The draft panel shows pending `set_budget` lines (Skip / Approve named for + the category, "Approve remaining N"). History sits above By category so the data-height cards come last. + Maths in `lib/budget-math.ts` (tests: `e2e/budget-math.spec.ts`). - **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, @@ -327,12 +336,18 @@ embeds the demo documents once per server and uploads the files in the backgroun Text carries `{0}` placeholders plus `amounts`, which the client fills in the user's currency (`fill` in `lib/home-math.ts`). The brief adds "The one thing worth a look" from the top item. - Budgets: the overall budget is `User.monthly_budget` (onboarding); per-category limits are the `budgets` table. - - `GET/PUT /api/budget` (`api/budget.py`). - - `POST /api/budget/draft` runs the deterministic `ai/budget.py` (3-month mean, rounded up) and files `set_budget` - proposals. Approving one (`/api/agent/proposals/{id}/approve`) upserts the row. A new draft retires pending ones. + Budgets: the overall budget is `User.monthly_budget` (onboarding); per-category limits are the `budgets` table, + one row per (user, category, `effective_from` month) since Phase 5d. A month's limit is the latest row from that + month or before (`limits_in_force`), so edits never rewrite past months; a 0 limit removes a category from that + month on. `models/database._migrate_budget_versions` dates older rows by `updated_at` and swaps the unique + constraint (SQLite rebuilds the table). + - `GET /api/budget?month=YYYY-MM` (default this month), `PUT /api/budget` (writes from the current month), + `GET /api/budget/history?months=6` (budget vs spent per month; `budget` null for a past month with no limits). + - `POST /api/budget/draft` runs the deterministic `ai/budget.py` (3-month mean over the months that had spend, + rounded up; `basis.months` lists those) and files `set_budget` proposals with `payload.month` = this month. + Approving one (`/api/agent/proposals/{id}/approve`) writes the row from that month. A new draft retires pending ones. - Ask Lumen's `draft_budget` tool files the same proposals. - - Home's draft dialog approves lines one by one or all at once. + - The Budget page's draft panel approves lines; Home's promo links to `/budget?draft=1`. The sidebar badge uses `GET /api/review?count=1`. `(app)/layout.tsx` is a tiny server file exporting the light `theme-color`; the client shell is `components/app-shell.tsx`. diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index 54d743d..7f2e78b 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -441,3 +441,23 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in undo, hide and undo, the Ask link, CLS under 0.01, tabs on one line at 375 px) and 4 maths tests. A screenshot against the mockup caught the wrapping tabs on phones, now pinned by a test. +## 35. A budget you can plan with (UI revamp, Phase 5d) +- **S:** Budgets were one limit per category with no history: changing one rewrote every past month, there was no + way to look back, and the only place to approve Lumen's draft was a dialog on Home with buttons named just + "Approve" / "Skip". +- **T:** A Budget page that answers "am I on track, and what should my plan be?", where past months stay as they were + and the draft lives on the page. +- **A:** Budgets became versioned by month (TDD): an `effective_from` column, reads that take each category's latest + row from that month or before, writes from the current month on, and a migration that dates old rows and swaps the + unique constraint (rebuilding the table on SQLite, where a constraint can't be dropped). New `?month=` and + `/history` endpoints; approved drafts write from the month they were for; `basis.months` lists only months with + spend. Pure maths (`lib/budget-math.ts`), then the page: a pace hero with the even-pace tick and safe per day, six + months of budget-vs-actual, a category table edited in place, and the draft panel with category-named buttons. A + Plan group joined the sidebar; Home's promo now links to the page. +- **R:** Backend 993 passed (8 new: last month's limits survive an edit, removal only from this month, history with + and without a budget, draft month, bad input, the old-schema migration, basis months). Frontend: 17 Budget e2e + checks (hero numbers, month switch read-only, inline edit without the row moving, Esc, add category, edit total, + draft skip/approve/approve-remaining/double click/409/`?draft=1`/no history, history bars, CLS under 0.01, phone fit) + and 4 maths tests. A phone screenshot showed buttons and bars clipped past the screen edge, which the old + "no sideways scroll" check missed; the test now checks every header button, bar and table cell ends on screen. + 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 b03b370..d3974a3 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -233,7 +233,7 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **Phase 4.** Drafting a budget (HTTP or the agent tool) retires all pending `set_budget` proposals. A draft from Ask while Home's dialog is open makes that dialog's approvals return 409. Two quick drafts can leave two pending sets. Consider scoping by draft id and a per-user lock. *(Phase 3 review; Phase 4: Home's dialog now says "This draft was replaced. Draft again." instead of erroring. Scoping is still open.)* - [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)* +- [x] **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; 5d: the draft moved to the Budget page with named buttons, and `basis.months` lists only months with spend.)* - [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)* @@ -241,6 +241,9 @@ Small items found while building, parked here so they aren't lost. Tick them off - [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)* - [x] **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)* *(Done in 5c: Home and Bills read `/api/bills/recurring`.)* -- [ ] **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)* +- [ ] **Phase 6.** 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; not done in 5d, moved to Phase 6)* +- [ ] **Later.** Budget page: "Safe per day" is left ÷ days left; subtract bills still due this month (the Bills data) once both are on the page. *(5d)* +- [ ] **Later.** The overall monthly budget (`User.monthly_budget`) isn't versioned like category limits, so history uses today's total for past budgeted months. Version it if people change it often. *(5d)* +- [ ] **Later.** Draft lines could show a small month-by-month glyph once `basis` carries per-month amounts. *(5d)* - [ ] **Later.** Spending: the category edit (pencil) is hidden below `sm`, so phones can't recategorise; and "Lumen noticed" wraps beside its pill at 375px. *(5b)* - [ ] **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)* From b08666f6f39e61d0eb7da5aa92e08b4613e772e7 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:25:41 +0530 Subject: [PATCH 41/47] docs: detail the Phase 5e plan (Receipts & statements) --- .../plans/2026-10-22-ui-revamp-phase5.md | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md index 733dd20..4fdb82b 100644 --- a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md +++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md @@ -291,5 +291,23 @@ Branch: `ui-revamp/phase5d-budget`, stacked on `ui-revamp/phase5c-bills` (#99). - STORY 35, guide (Budget), spec §12 ticks (draft accessible names, `basis.months`). - Full checks green, screenshot vs `budget.html` at 1440 and 375, PR stacked on #99. -## 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. +## Phase 5e — Receipts & statements (detailed) + +Branch: `ui-revamp/phase5e-receipts`, stacked on `ui-revamp/phase5d-budget` (#100). Mockup `receipts.html`; spec §4.5 and the builder-check decisions on import history and the Statements filter. + +### Task E1: statement import history (backend, TDD) +- Model `StatementImport` (`statement_imports`: id, user_id, file_name, label nullable, period_from/period_to "YYYY-MM-DD" from the readable rows, lines, imported, matched, duplicates, credits, unreadable, created_at); created by `create_all`. +- `POST /api/statements/import` takes an optional `label` form field (≤60 chars) and writes one row per import with the counts it returns (`matched` "at import"). `GET /api/statements/imports` → `{success, imports: [...]}` newest first, the user's own only. +- Tests (`tests/test_statements.py` style): an import writes one history row with period and counts; a re-import is a second row with duplicates counted; per-user; label trimmed and optional. + +### Task E2: the Receipts & statements page +- `statementsApi.imports()`, `emailConfigApi.getStatus()` typed. Header "Receipts & statements" with Upload (dialog: Receipts & invoices vs Documents; documents upload via `documentsApi.upload(file, type)`; a line pointing to Import statement for CSVs), Import statement (→ `/receipts/import`, the existing flow plus an optional account label), Connect email (scrolls to the inbox card). Brief: "N receipts need your OK before they count." / "Everything you've given Lumen is filed." +- **Needs your review** (only when the queue is non-empty): one card per flagged item: vendor, how it came in, amount; fields Vendor / Date / Total (+ Bill no. for duplicates) with the flagged ones highlighted (`total_mismatch` → Total, `duplicate` → Bill no., `bad_date` → Date, `unknown_vendor` → Vendor, `no_currency` → Total); the reason in plain words from the first flag; Approve / Edit (inline vendor/date/total inputs, then Save & approve) / Reject; a duplicate shows "Keep both" instead of Approve. Deciding removes the card and updates the sidebar badge (`REVIEW_DECIDED`). Compare is left out (no "other record" in the payload; hidden, not faked). +- **Library**: one list of receipts (non-statement transactions, latest 100), flagged review items (status Needs review), documents (with delete) and statement imports; chips All / Receipts & invoices / Statements / Documents (a chip only when it has rows), search over title/vendor/file name, 10 rows then "Show N more". Columns: file (icon, title, kind · file/sender), came from (Upload / Gmail / Import), date, amount, status. +- Right column: **Statement imports** (label or file name, period, imported date, lines, a new / matched / duplicates / credits bar with counts) and **From your inbox** (connected: address, last check, receipts added, Check now, Pause/Resume; not connected: Connect Gmail). +- Routes: `/import` → `/receipts/import`; `/review`, `/documents` → `/receipts`; the tabs component, `review/reviewContent.tsx` and `documents/documentsContent.tsx` go (their APIs stay). +- Tests (`receipts.spec.ts`): review card highlights and reason per flag; approve with edits posts them; duplicate shows Keep both; reject removes the card and the badge drops; library chips and search; statements in the library and the imports card; inbox connected vs not; upload dialog's document path; old routes redirect; CLS < 0.01; phone fit (nothing past the edge). + +### Task E3: docs, checks, PR, whole-branch review +- STORY 36, guide (Receipts), spec §12. +- Full checks green, screenshots at 1440 and 375, PR stacked on #100. Then the whole-branch review of Phase 5 (5a–5e) on the most capable model; Critical/Important fixed test-first, Minors to §12. From d32db1854e364fe242167a1d420f3d943738a935 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:28:59 +0530 Subject: [PATCH 42/47] feat: keep a history of statement imports (GET /api/statements/imports) --- backend/api/statements.py | 30 +++++++++++++++++++++++++--- backend/models/__init__.py | 21 +++++++++++++++++++ backend/tests/test_api_statements.py | 28 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/backend/api/statements.py b/backend/api/statements.py index 4bd9445..bec9bd9 100644 --- a/backend/api/statements.py +++ b/backend/api/statements.py @@ -70,10 +70,14 @@ def preview(file: UploadFile = File(...), claims: dict = Depends(current_user)): @router.post("/import", dependencies=[Depends(rate_limit("10 per minute"))]) def import_statement(file: UploadFile = File(...), mapping: str = Form(...), currency: str | None = Form(None), - header_line: int | None = Form(None), claims: dict = Depends(current_user), - engine: Engine = Depends(get_engine)): + header_line: int | None = Form(None), label: str | None = Form(None), + claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): from config import Config - from models import Transaction, User + from models import StatementImport, Transaction, User + + label = (label or "").strip() or None + if label and len(label) > 60: + raise HTTPException(400, "The account name is too long (60 characters at most).") try: parsed, header = _load(file) @@ -127,6 +131,26 @@ def import_statement(file: UploadFile = File(...), mapping: str = Form(...), cur category=vendor.category(name), currency=code, source="statement", import_key=key, invoice_number=None)) imported += 1 + days = sorted(ln.date for ln in ok) + session.add(StatementImport(user_id=user_id, file_name=file.filename, label=label, + period_from=days[0] if days else None, period_to=days[-1] if days else None, + lines=len(lines), imported=imported, matched=matched, duplicates=duplicates, + credits=credits, unreadable=len(unreadable))) session.commit() return {"success": True, "imported": imported, "duplicates": duplicates, "matched": matched, "credits": credits, "unreadable": unreadable} + + +@router.get("/imports", dependencies=[Depends(rate_limit())]) +def imports(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): + """The user's statement imports, newest first (Receipts & statements page).""" + from models import StatementImport + + with Session(engine) as session: + rows = session.scalars(select(StatementImport).where(StatementImport.user_id == claims["sub"]) + .order_by(StatementImport.created_at.desc())).all() + return {"success": True, "imports": [ + {"id": r.id, "file_name": r.file_name, "label": r.label, "period_from": r.period_from, + "period_to": r.period_to, "lines": r.lines, "imported": r.imported, "matched": r.matched, + "duplicates": r.duplicates, "credits": r.credits, "unreadable": r.unreadable, + "created_at": r.created_at.isoformat()} for r in rows]} diff --git a/backend/models/__init__.py b/backend/models/__init__.py index a9ced99..8a938c6 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -408,3 +408,24 @@ class UserKey(db.Model): last_error = db.Column(Text) __table_args__ = (db.UniqueConstraint("user_id", "provider", name="u_user_key"),) + + +class StatementImport(db.Model): + """One bank-statement import (UI revamp Phase 5e), for the Receipts page's history. Counts are as the import + returned them ("matched" is at import time; a re-import is a new row). The CSV itself isn't kept.""" + + __tablename__ = "statement_imports" + + id = db.Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = db.Column(String(36), nullable=False, index=True) + file_name = db.Column(db.String) + label = db.Column(db.String(60)) + period_from = db.Column(db.String(10)) + period_to = db.Column(db.String(10)) + lines = db.Column(db.Integer, nullable=False, default=0) + imported = db.Column(db.Integer, nullable=False, default=0) + matched = db.Column(db.Integer, nullable=False, default=0) + duplicates = db.Column(db.Integer, nullable=False, default=0) + credits = db.Column(db.Integer, nullable=False, default=0) + unreadable = db.Column(db.Integer, nullable=False, default=0) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) diff --git a/backend/tests/test_api_statements.py b/backend/tests/test_api_statements.py index 92b8603..80b35e6 100644 --- a/backend/tests/test_api_statements.py +++ b/backend/tests/test_api_statements.py @@ -195,3 +195,31 @@ def test_bad_currency_is_400(client): c, _ = client m = json.dumps({"date": 0, "description": 1, "amount": 2}) assert post(c, "import", mapping=m, currency="RUPEES").status_code == 400 + + +def history(c, who=USER): + r = c.get("/api/statements/imports", headers=bearer(who)) + assert r.status_code == 200, r.text + return r.json()["imports"] + + +def test_each_import_is_kept_in_history(client): + """UI revamp Phase 5e: one history row per import, with the period and the counts it returned.""" + c, _ = client + m = json.dumps(post(c, "preview").json()["mapping"]) + post(c, "import", mapping=m, label=" HDFC Savings ··4417 ") + post(c, "import", mapping=m) + second, first = history(c) + assert first["label"] == "HDFC Savings ··4417" and first["file_name"] == "s.csv" + assert (first["period_from"], first["period_to"]) == ("2026-10-03", "2026-10-06") + assert (first["lines"], first["imported"], first["duplicates"], first["matched"], first["credits"], first["unreadable"]) \ + == (3, 2, 0, 0, 1, 0) + assert second["label"] is None and (second["imported"], second["duplicates"]) == (0, 2) + assert first["created_at"] and history(c, OTHER) == [] + + +def test_history_needs_auth_and_label_is_capped(client): + c, _ = client + assert c.get("/api/statements/imports").status_code == 401 + m = json.dumps(post(c, "preview").json()["mapping"]) + assert post(c, "import", mapping=m, label="x" * 61).status_code == 400 From cf5d2af7e9d356767a675248d348aaa3301bab2a Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:40:59 +0530 Subject: [PATCH 43/47] feat: Receipts & statements page (review cards, one library, statement imports, inbox) replaces the old tabs --- backend/ai/home_signals.py | 2 +- backend/tests/test_home_signals.py | 2 +- frontend/e2e/fixtures.ts | 37 +++- frontend/e2e/home.spec.ts | 2 +- frontend/e2e/receipts.spec.ts | 174 ++++++++++++++++ frontend/e2e/shell.spec.ts | 8 +- frontend/next.config.js | 4 +- .../app/(app)/documents/documentsContent.tsx | 194 ------------------ .../(app)/home/_components/activity-card.tsx | 2 +- .../(app)/receipts/_components/library.tsx | 137 +++++++++++++ .../receipts/_components/review-cards.tsx | 163 +++++++++++++++ .../(app)/receipts/_components/side-cards.tsx | 127 ++++++++++++ .../import/import-content.tsx} | 30 ++- .../src/app/(app)/receipts/import/page.tsx | 8 + frontend/src/app/(app)/receipts/page.tsx | 5 +- .../app/(app)/receipts/receipts-content.tsx | 81 ++++++++ .../src/app/(app)/receipts/receipts-tabs.tsx | 42 ---- .../app/(app)/receipts/use-receipts-data.ts | 52 +++++ .../src/app/(app)/review/reviewContent.tsx | 76 ------- frontend/src/components/uploadDialog.tsx | 69 ++++++- frontend/src/lib/api/client.ts | 23 ++- 21 files changed, 886 insertions(+), 352 deletions(-) create mode 100644 frontend/e2e/receipts.spec.ts delete mode 100644 frontend/src/app/(app)/documents/documentsContent.tsx create mode 100644 frontend/src/app/(app)/receipts/_components/library.tsx create mode 100644 frontend/src/app/(app)/receipts/_components/review-cards.tsx create mode 100644 frontend/src/app/(app)/receipts/_components/side-cards.tsx rename frontend/src/app/(app)/{import/importContent.tsx => receipts/import/import-content.tsx} (84%) create mode 100644 frontend/src/app/(app)/receipts/import/page.tsx create mode 100644 frontend/src/app/(app)/receipts/receipts-content.tsx delete mode 100644 frontend/src/app/(app)/receipts/receipts-tabs.tsx create mode 100644 frontend/src/app/(app)/receipts/use-receipts-data.ts delete mode 100644 frontend/src/app/(app)/review/reviewContent.tsx diff --git a/backend/ai/home_signals.py b/backend/ai/home_signals.py index 32f0857..b5d8f03 100644 --- a/backend/ai/home_signals.py +++ b/backend/ai/home_signals.py @@ -115,7 +115,7 @@ def find_signals(txns: list[dict], today: date, review_count: int, recurring: li review = [{"kind": "review", "tag": "RECEIPTS", "subject": None, "headline": f"{review_count} {'receipt needs' if review_count == 1 else 'receipts need'} a glance", "detail": "Lumen wasn't sure about these. Nothing is saved until you approve.", - "amounts": [], "worth": None, "href": "/receipts?tab=review"}] + "amounts": [], "worth": None, "href": "/receipts"}] return _duplicates(txns, today) + review + _spikes(txns, today) + _price_rises(recurring, today) diff --git a/backend/tests/test_home_signals.py b/backend/tests/test_home_signals.py index 0124935..3e0c246 100644 --- a/backend/tests/test_home_signals.py +++ b/backend/tests/test_home_signals.py @@ -71,7 +71,7 @@ def test_order_and_review_item(): assert kinds(items) == ["duplicate", "review", "spike", "price_rise"] assert items[1] == {"kind": "review", "tag": "RECEIPTS", "subject": None, "headline": "3 receipts need a glance", "detail": "Lumen wasn't sure about these. Nothing is saved until you approve.", - "amounts": [], "worth": None, "href": "/receipts?tab=review"} + "amounts": [], "worth": None, "href": "/receipts"} assert find_signals([], TODAY, 1, [])[0]["headline"] == "1 receipt needs a glance" diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 7d0da98..611308a 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -6,7 +6,7 @@ import type { Page } from "@playwright/test"; // - the `lumen_session` cookie gets past middleware. // - every backend call (NEXT_PUBLIC_BACKEND_URL in playwright.config.ts) is stubbed. export type HomeFixture = { - txns?: { id: string; vendor_name: string; date: string; total_amount: number; category: string | null; created_at: string | null }[]; + txns?: { id: string; vendor_name: string; date: string; total_amount: number; category: string | null; created_at: string | null; source?: string | null; invoice_number?: string | null }[]; reminders?: { id: string; vendor: string; amount: number; date: string; type: string; category?: string }[]; /** GET /api/bills/recurring items as the API sends them; default: built from `reminders` (monthly, active) */ recurring?: Record[]; @@ -28,6 +28,14 @@ export type HomeFixture = { currency?: string; /** proposal ids whose approve returns 409 (a newer draft replaced them) */ replaced?: string[]; + /** the flagged review queue as GET /api/review sends it; default: `reviewCount` plain items */ + review?: Record[]; + /** GET /api/documents; default [] (DELETE removes, POST adds) */ + documents?: Record[]; + /** GET /api/statements/imports; default [] */ + imports?: Record[]; + /** GET /api/v1/email-config/status; default not configured */ + email?: Record; }; export type ChatFixture = { @@ -68,20 +76,37 @@ export async function signIn( ); }); // a tiny stateful review queue: approving/rejecting removes the item, so the sidebar badge can be checked - let queue = Array.from({ length: opts.reviewCount ?? 0 }, (_, i) => ({ + const generated = Array.from({ length: opts.reviewCount ?? 0 }, (_, i) => ({ id: `r${i}`, status: "flagged", confidence: "low", - invoice: { vendor_name: `Vendor ${i}` }, - flags: [], + invoice: { vendor_name: `Vendor ${i}` } as Record, + flags: [] as unknown[], transaction_id: null, note: null, - created_at: null, + created_at: null as string | null, })); + let queue = (home.review as typeof generated | undefined) ?? generated; + const documents = [...(home.documents ?? [])]; await page.route("http://127.0.0.1:9/**", async (route) => { const url = new URL(route.request().url()); const path = url.pathname; if (home.delayMs) await new Promise((r) => setTimeout(r, home.delayMs)); + const method = route.request().method(); + if (path === "/api/documents" && method === "GET") return route.fulfill({ json: { success: true, documents } }); + if (path === "/api/documents" && method === "POST") { + const doc = { id: `d${documents.length + 1}`, title: "New document", doc_type: "other", filename: "new.pdf", chunk_count: 3, created_at: new Date().toISOString() }; + documents.unshift(doc); + return route.fulfill({ json: { success: true, document: doc } }); + } + const doc = path.match(/^\/api\/documents\/([^/]+)$/); + if (doc && method === "DELETE") { + documents.splice(documents.findIndex((d) => d.id === doc[1]), 1); + return route.fulfill({ json: { success: true } }); + } + if (path === "/api/statements/imports") return route.fulfill({ json: { success: true, imports: home.imports ?? [] } }); + if (path === "/api/v1/email-config/status") return route.fulfill({ json: home.email ?? { configured: false } }); + if (path.startsWith("/api/v1/email-config/")) return route.fulfill({ json: { success: true } }); // recurring charges (api/bills.py): the fixture's items, or simple monthly ones built from `reminders`; state // changes are kept, like the real table if (path === "/api/bills/recurring" || path === "/api/bills/recurring/state") { @@ -131,7 +156,7 @@ export async function signIn( if (path === "/api/home/summary") { const review = queue.length ? [{ kind: "review", tag: "RECEIPTS", headline: `${queue.length} ${queue.length === 1 ? "receipt needs" : "receipts need"} a glance`, - detail: "Lumen wasn't sure about these. Nothing is saved until you approve.", amounts: [], worth: null, href: "/receipts?tab=review" }] + detail: "Lumen wasn't sure about these. Nothing is saved until you approve.", amounts: [], worth: null, href: "/receipts" }] : []; const s = home.summary ?? { found: review }; return route.fulfill({ json: { success: true, more: 0, worth_total: 0, activity: [], ...s } }); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index b53d04b..0a10899 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -137,7 +137,7 @@ test("lumen found links to review", async ({ page }) => { await openHome(page, typical, 2); const found = page.getByTestId("lumen-found"); await expect(found.getByText("2 receipts need a glance")).toBeVisible(); - await expect(found.getByRole("link", { name: /review/i })).toHaveAttribute("href", "/receipts?tab=review"); + await expect(found.getByRole("link", { name: /review/i })).toHaveAttribute("href", "/receipts"); }); test("lumen found is honest when there is nothing", async ({ page }) => { diff --git a/frontend/e2e/receipts.spec.ts b/frontend/e2e/receipts.spec.ts new file mode 100644 index 0000000..8d40b1a --- /dev/null +++ b/frontend/e2e/receipts.spec.ts @@ -0,0 +1,174 @@ +import { test, expect, type Page } from "@playwright/test"; +import { signIn, type HomeFixture } from "./fixtures"; + +const flagged = (id: string, invoice: Record, rules: string[]) => ({ + id, status: "flagged", confidence: "low", invoice, transaction_id: null, note: null, created_at: "2026-10-12T10:00:00", + flags: rules.map((rule) => ({ rule, severity: "fail", detail: `detail for ${rule}` })), +}); +const sample: HomeFixture = { + review: [ + flagged("r1", { vendor_name: "Croma", date: "2026-10-12", total_amount: 9999, invoice_number: "CR-2210" }, ["total_mismatch"]), + flagged("r2", { vendor_name: "FreshMart", date: "2026-10-18", total_amount: 1284, invoice_number: "FM-55120", source: "email" }, ["duplicate"]), + ], + txns: [ + { id: "t1", vendor_name: "Jio Fiber", date: "2026-10-09", total_amount: 999, category: "Bills", created_at: null, source: "email", invoice_number: "JIO-1" }, + { id: "t2", vendor_name: "Uber", date: "2026-10-08", total_amount: 212, category: "Transport", created_at: null, source: null, invoice_number: "U-9" }, + { id: "t3", vendor_name: "Swiggy", date: "2026-10-07", total_amount: 450, category: "Food", created_at: null, source: "statement" }, + ], + documents: [{ id: "d1", title: "Rental agreement", doc_type: "contract", filename: "rent.pdf", chunk_count: 14, created_at: "2026-10-01T09:00:00" }], + imports: [ + { id: "i1", file_name: "hdfc-sep.csv", label: "HDFC Savings ··4417", period_from: "2026-09-01", period_to: "2026-09-30", lines: 142, + imported: 97, matched: 21, duplicates: 3, credits: 21, unreadable: 0, created_at: "2026-10-03T08:00:00" }, + ], + email: { configured: true, email_address: "me@gmail.com", provider: "gmail", polling_enabled: true, transactions_added: 11, + last_successful_poll: new Date(Date.now() - 12 * 60_000).toISOString() }, +}; +async function openReceipts(page: Page, home: HomeFixture = sample) { + await signIn(page, { home }); + await page.goto("/receipts"); + await expect(page.getByTestId("library")).toContainText("Rental agreement", { timeout: 15_000 }); +} +const card = (page: Page, vendor: string) => page.getByTestId("review-card").filter({ hasText: vendor }); + +test("receipts: heading and a brief from the queue", async ({ page }) => { + await openReceipts(page); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Receipts & statements"); + await expect(page.getByTestId("brief")).toContainText("2 receipts need your OK before they count."); +}); + +test("review cards highlight the doubtful field and say why", async ({ page }) => { + await openReceipts(page); + await expect(page.getByTestId("review-card")).toHaveCount(2); + await expect(card(page, "Croma").locator("[data-doubtful]")).toHaveText(["Total"]); + await expect(card(page, "Croma")).toContainText("Totals don't add up."); + await expect(card(page, "FreshMart").locator("[data-doubtful]")).toHaveText(["Bill no."]); + await expect(card(page, "FreshMart")).toContainText("Looks like a duplicate."); + await expect(card(page, "FreshMart")).toContainText("from email"); + await expect(card(page, "FreshMart").getByRole("button", { name: "Keep both" })).toBeVisible(); + await expect(card(page, "FreshMart").getByRole("button", { name: "Approve", exact: true })).toHaveCount(0); +}); + +test("edit then approve sends only the changed fields", async ({ page }) => { + const bodies: unknown[] = []; + page.on("request", (r) => r.url().endsWith("/api/review/r1/approve") && bodies.push(r.postDataJSON())); + await openReceipts(page); + await card(page, "Croma").getByRole("button", { name: "Edit" }).click(); + await card(page, "Croma").getByRole("textbox", { name: "Total" }).fill("9990"); + await card(page, "Croma").getByRole("button", { name: "Save & approve" }).click(); + await expect(card(page, "Croma")).toHaveCount(0); + expect(bodies).toEqual([{ edits: { total_amount: 9990 } }]); +}); + +test("reject removes the card and the sidebar badge drops", async ({ page }) => { + await openReceipts(page); + await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("2"); + await card(page, "FreshMart").getByRole("button", { name: "Reject" }).click(); + await expect(card(page, "FreshMart")).toHaveCount(0); + await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("1"); +}); + +test("no queue, no review section", async ({ page }) => { + await openReceipts(page, { ...sample, review: [] }); + await expect(page.getByTestId("review")).toHaveCount(0); + await expect(page.getByTestId("brief")).toContainText("Everything you've given Lumen is filed."); +}); + +test("library: receipts, review items, documents and statements in one list", async ({ page }) => { + await openReceipts(page); + const rows = page.getByTestId("library-row"); + await expect(rows).toHaveCount(6); // 2 receipts (not the statement line), 2 in review, 1 document, 1 import + await expect(rows.filter({ hasText: "Jio Fiber" })).toContainText("Gmail"); + await expect(rows.filter({ hasText: "Croma" })).toContainText("Needs review"); + await page.getByRole("button", { name: "Statements", exact: true }).click(); + await expect(rows).toHaveCount(1); + await expect(rows).toContainText("HDFC Savings ··4417"); + await expect(rows).toContainText("Import"); + await page.getByRole("button", { name: "All", exact: true }).click(); + await page.getByRole("searchbox", { name: "Search" }).fill("uber"); + await expect(rows).toHaveCount(1); +}); + +test("deleting a document from the library", async ({ page }) => { + await openReceipts(page); + await page.getByRole("button", { name: "Delete Rental agreement" }).click(); + await expect(page.getByTestId("library-row").filter({ hasText: "Rental agreement" })).toHaveCount(0); +}); + +test("statement imports card shows counts", async ({ page }) => { + await openReceipts(page); + const imports = page.getByTestId("imports"); + await expect(imports).toContainText("HDFC Savings ··4417"); + await expect(imports).toContainText("142 lines"); + for (const text of ["97 new", "21 matched", "3 duplicates", "21 credits skipped"]) await expect(imports).toContainText(text); +}); + +test("inbox: connected shows the address and checks now", async ({ page }) => { + const polled = page.waitForRequest((r) => r.url().endsWith("/api/v1/email-config/poll-now") && r.method() === "POST"); + await openReceipts(page); + const inbox = page.getByTestId("inbox"); + await expect(inbox).toContainText("me@gmail.com"); + await expect(inbox).toContainText("11 receipts added"); + await inbox.getByRole("button", { name: "Check now" }).click(); + await polled; + await expect(inbox.getByRole("button", { name: "Pause" })).toBeVisible(); +}); + +test("inbox: not connected offers Gmail", async ({ page }) => { + await openReceipts(page, { ...sample, email: { configured: false } }); + await expect(page.getByTestId("inbox").getByRole("button", { name: /Connect with Google/ })).toBeVisible(); +}); + +test("upload dialog can file a document", async ({ page }) => { + const posted = page.waitForRequest((r) => r.url().endsWith("/api/documents") && r.method() === "POST"); + await openReceipts(page); + await page.getByRole("button", { name: "Upload" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("radio", { name: "Documents" }).click(); + await dialog.locator('input[type="file"]').setInputFiles({ name: "policy.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4") }); + await dialog.getByRole("button", { name: "Upload", exact: true }).click(); + const body = (await posted).postDataBuffer()!.toString(); + expect(body).toContain("policy.pdf"); + expect(body).toContain('name="doc_type"'); + await expect(dialog).toContainText("Statements are CSV files"); +}); + +test("import statement goes to the import flow", async ({ page }) => { + await openReceipts(page); + await page.getByRole("link", { name: "Import statement" }).first().click(); + await expect(page).toHaveURL(/\/receipts\/import$/); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Import a statement"); +}); + +test("old routes land on the new page", async ({ page }) => { + await signIn(page, { home: sample }); + await page.goto("/review"); + await expect(page).toHaveURL(/\/receipts$/); + await page.goto("/import"); + await expect(page).toHaveURL(/\/receipts\/import$/); +}); + +test("receipts has no layout shift while loading", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.addInitScript(() => { + (window as unknown as { cls: number }).cls = 0; + new PerformanceObserver((list) => { + for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[]) + if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value; + }).observe({ type: "layout-shift", buffered: true }); + }); + await signIn(page, { home: { ...sample, delayMs: 1500 } }); + await page.goto("/receipts"); + await expect(page.getByTestId("library")).toContainText("Rental agreement", { timeout: 15_000 }); + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01); +}); + +test("receipts fits a phone", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openReceipts(page); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + const ends = await page + .locator('header a, header button, [data-testid="review-card"], [data-testid="library-row"] > *') + .evaluateAll((els) => els.map((e) => [e.getAttribute("aria-label") ?? e.textContent, e.getBoundingClientRect().right] as const)); + for (const [what, right] of ends) expect(right, String(what)).toBeLessThanOrEqual(375); +}); diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 203bba8..2e81faf 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -17,8 +17,8 @@ const moved: [string, string][] = [ ["/ai-analytics", "/spending"], ["/chatbot", "/ask"], ["/documents", "/receipts"], - ["/import", "/receipts?tab=import"], - ["/review", "/receipts?tab=review"], + ["/import", "/receipts/import"], + ["/review", "/receipts"], ["/upload", "/home?upload=1"], ]; @@ -44,7 +44,7 @@ test("new pages render for a signed-in user", async ({ page }) => { ["/spending", "Spending"], ["/bills", "Bills & subscriptions"], ["/receipts", "Receipts & statements"], - ["/receipts?tab=review", "Receipts & statements"], + ["/receipts/import", "Import a statement"], ["/ask", "Ask Lumen"], ]; for (const [p, title] of pages) { @@ -236,7 +236,7 @@ test("body is never its own scroll container (it would lose its scrollbar when a test("review badge updates after a decision on the same page", async ({ page }) => { await signIn(page, { reviewCount: 2 }); - await page.goto("/receipts?tab=review"); + await page.goto("/receipts"); const badge = sidebar(page).locator('[data-sidebar="menu-badge"]'); await expect(badge).toHaveText("2"); await page.getByRole("button", { name: "Approve" }).first().click(); diff --git a/frontend/next.config.js b/frontend/next.config.js index 200d02c..c5db174 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -32,8 +32,8 @@ const nextConfig = { ["/ai-analytics", "/spending"], ["/chatbot", "/ask"], ["/documents", "/receipts"], - ["/import", "/receipts?tab=import"], - ["/review", "/receipts?tab=review"], + ["/import", "/receipts/import"], + ["/review", "/receipts"], ["/upload", "/home?upload=1"], ].map(([source, destination]) => ({ source, destination, permanent: false })); return [{ source: "/signin", destination: "/login", permanent: true }, ...moved]; diff --git a/frontend/src/app/(app)/documents/documentsContent.tsx b/frontend/src/app/(app)/documents/documentsContent.tsx deleted file mode 100644 index 5118239..0000000 --- a/frontend/src/app/(app)/documents/documentsContent.tsx +++ /dev/null @@ -1,194 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { FileText, Loader2, Trash2, Upload } from "lucide-react"; -import { DashboardShell } from "@/components/dashboard-shell"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { - documentsApi, - filesApi, - type DocumentAnswer, - type LumenDocument, -} from "@/lib/api/client"; -import { toast } from "@/lib/toast"; - -const DOC_TYPES = [ - { value: "purchase_order", label: "Purchase order" }, - { value: "contract", label: "Contract" }, - { value: "policy", label: "Policy" }, - { value: "invoice", label: "Invoice" }, - { value: "other", label: "Other" }, -]; - -function errorMessage(error: unknown, fallback: string): string { - const data = (error as { response?: { data?: { error?: string } } })?.response?.data; - return data?.error ?? fallback; -} - -export default function DocumentsContent() { - const [documents, setDocuments] = useState([]); - const [loading, setLoading] = useState(true); - const [uploading, setUploading] = useState(false); - const [docType, setDocType] = useState("contract"); - const [question, setQuestion] = useState(""); - const [asking, setAsking] = useState(false); - const [answer, setAnswer] = useState(null); - const fileInput = useRef(null); - - const refresh = useCallback(async () => { - try { - setDocuments(await documentsApi.list()); - } catch (error) { - toast.error(errorMessage(error, "Couldn't load your documents.")); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void refresh(); - }, [refresh]); - - async function onUpload(file: File | undefined) { - if (!file) return; - setUploading(true); - try { - const doc = await documentsApi.upload(file, docType); - toast.success(`Indexed "${doc.title}" (${doc.chunk_count} sections)`); - await refresh(); - } catch (error) { - toast.error(errorMessage(error, "Upload failed.")); - } finally { - setUploading(false); - if (fileInput.current) fileInput.current.value = ""; - } - } - - async function onDelete(doc: LumenDocument) { - try { - await documentsApi.remove(doc.id); - setDocuments((docs) => docs.filter((d) => d.id !== doc.id)); - } catch (error) { - toast.error(errorMessage(error, "Couldn't delete the document.")); - } - } - - async function onAsk(event: React.FormEvent) { - event.preventDefault(); - if (!question.trim()) return; - setAsking(true); - setAnswer(null); - try { - setAnswer(await documentsApi.ask(question.trim())); - } catch (error) { - const code = (error as { response?: { data?: { code?: string } } })?.response?.data?.code; - const hint = code === "needs_key" || code === "key_rejected" ? " Add or fix your key in Settings." : ""; - toast.error(errorMessage(error, "Couldn't answer right now.") + hint); - } finally { - setAsking(false); - } - } - - return ( - - -
    - setQuestion(e.target.value)} - placeholder="e.g. What are the payment terms on PO-U1-202605-06?" - maxLength={500} - /> - -
    - {answer && ( -
    -

    {answer.answer}

    - {answer.sources.length > 0 && ( -
    -

    Sources

    - {answer.sources.map((s) => ( -
    -

    - {s.title} - {s.section ? ` · ${s.section}` : ""} -

    -

    {s.text}

    -
    - ))} -
    - )} -
    - )} -
    - - -
    -

    Your documents

    -
    - - void onUpload(e.target.files?.[0])} - /> - -
    -
    - {loading ? ( -

    Loading…

    - ) : documents.length === 0 ? ( -

    No documents yet. Upload a PDF to start asking questions about it.

    - ) : ( -
      - {documents.map((doc) => ( -
    • -
      - -
      -

      {doc.title}

      -

      - {DOC_TYPES.find((t) => t.value === doc.doc_type)?.label ?? doc.doc_type} · {doc.chunk_count} sections -

      -
      -
      - {doc.file_key && ( - - )} - -
    • - ))} -
    - )} -
    -
    - ); -} diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx index 7475e7a..278c785 100644 --- a/frontend/src/app/(app)/home/_components/activity-card.tsx +++ b/frontend/src/app/(app)/home/_components/activity-card.tsx @@ -12,7 +12,7 @@ export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; n {items.length === 0 ? (

    Nothing imported yet.{" "} - + Upload a receipt or a statement {" "} to get started. diff --git a/frontend/src/app/(app)/receipts/_components/library.tsx b/frontend/src/app/(app)/receipts/_components/library.tsx new file mode 100644 index 0000000..7fb156b --- /dev/null +++ b/frontend/src/app/(app)/receipts/_components/library.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState } from "react"; +import { FileSpreadsheet, FileText, Receipt, Search, Trash2 } from "lucide-react"; +import { PageCard } from "@/components/page/page-card"; +import type { LumenDocument, ReviewItem, StatementImport } from "@/lib/api/client"; +import { useMoney } from "@/lib/format"; +import type { Txn } from "@/lib/home-math"; +import { cn } from "@/lib/utils"; + +type Kind = "receipt" | "document" | "statement"; +export type LibraryRow = { + key: string; + kind: Kind; + title: string; + sub: string; + from: "Upload" | "Gmail" | "Import"; + date: string | null; + amount: number | null; + review: boolean; + onDelete?: () => void; +}; +const CHIPS: { label: string; kind: Kind | null }[] = [ + { label: "All", kind: null }, + { label: "Receipts & invoices", kind: "receipt" }, + { label: "Statements", kind: "statement" }, + { label: "Documents", kind: "document" }, +]; +const ICON = { receipt: Receipt, document: FileText, statement: FileSpreadsheet }; +const DOC_TYPE: Record = { purchase_order: "Purchase order", contract: "Contract", policy: "Policy", invoice: "Invoice", other: "Document" }; +const dayMonth = (s: string | null) => (s ? new Date(s.length === 10 ? `${s}T00:00` : s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : "—"); +const range = (a: string | null, b: string | null) => (a && b ? `${dayMonth(a)} – ${dayMonth(b)}` : ""); +const PAGE = 10; + +// Everything the user has given Lumen in one list, newest first (spec 2026-10-22 §4.5): receipts and invoices (with +// the ones still in review), documents, and statement imports. Statement lines themselves are transactions, not files, +// so a statement shows once, as its import. Reading/Failed states aren't stored yet, so every file is Done or in review. +export function toRows(d: { review: ReviewItem[]; receipts: Txn[]; documents: LumenDocument[]; imports: StatementImport[] }, remove: (id: string) => void): LibraryRow[] { + const rows: LibraryRow[] = [ + ...d.review.map((r) => ({ + key: `r-${r.id}`, kind: "receipt" as const, title: String(r.invoice.vendor_name ?? "Unknown vendor"), + sub: `Receipt · ${String(r.invoice.invoice_number ?? "no bill number")}`, from: r.invoice.source === "email" ? ("Gmail" as const) : ("Upload" as const), + date: r.created_at, amount: typeof r.invoice.total_amount === "number" ? r.invoice.total_amount : null, review: true, + })), + ...d.receipts.map((t) => ({ + key: `t-${t.id}`, kind: "receipt" as const, title: t.vendor_name ?? "Unknown vendor", + sub: `Receipt${t.category ? ` · ${t.category}` : ""}`, from: t.source === "email" ? ("Gmail" as const) : ("Upload" as const), + date: t.date, amount: t.total_amount, review: false, + })), + ...d.documents.map((doc) => ({ + key: `d-${doc.id}`, kind: "document" as const, title: doc.title, sub: `${DOC_TYPE[doc.doc_type] ?? "Document"} · ${doc.filename ?? `${doc.chunk_count} sections`}`, + from: "Upload" as const, date: doc.created_at, amount: null, review: false, onDelete: () => remove(doc.id), + })), + ...d.imports.map((i) => ({ + key: `i-${i.id}`, kind: "statement" as const, title: i.label ?? i.file_name ?? "Statement", + sub: `Statement · ${i.lines} lines${i.period_from ? ` · ${range(i.period_from, i.period_to)}` : ""}`, from: "Import" as const, + date: i.created_at, amount: null, review: false, + })), + ]; + return rows.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? "")); +} + +export function Library({ rows }: { rows: LibraryRow[] }) { + const money = useMoney(); + const [kind, setKind] = useState(null); + const [q, setQ] = useState(""); + const [shown, setShown] = useState(PAGE); + const needle = q.trim().toLowerCase(); + const list = rows.filter((r) => (!kind || r.kind === kind) && (!needle || `${r.title} ${r.sub}`.toLowerCase().includes(needle))); + + return ( + {rows.length} {rows.length === 1 ? "file" : "files"}} skeletonHeight={0} data-testid="library"> +

    +
    + {CHIPS.filter((c) => !c.kind || rows.some((r) => r.kind === c.kind)).map((c) => ( + + ))} +
    + +
    +
    + File + Came from + Date + Amount + Status +
    + {list.length === 0 &&

    {rows.length ? "Nothing matches." : "Nothing here yet. Upload a receipt or import a statement."}

    } + {list.slice(0, shown).map((r) => { + const Icon = ICON[r.kind]; + return ( +
    + + + + {r.title} + + {r.sub} + · {r.from} · {dayMonth(r.date)} + + + + {r.from} + {dayMonth(r.date)} + {r.amount === null ? — : money(r.amount)} + + + {r.review ? "Needs review" : "Done"} + + {r.onDelete && ( + + )} + +
    + ); + })} + {list.length > shown && ( + + )} + + ); +} diff --git a/frontend/src/app/(app)/receipts/_components/review-cards.tsx b/frontend/src/app/(app)/receipts/_components/review-cards.tsx new file mode 100644 index 0000000..4535a55 --- /dev/null +++ b/frontend/src/app/(app)/receipts/_components/review-cards.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { PageCard } from "@/components/page/page-card"; +import { VendorChip } from "@/components/page/vendor-chip"; +import { reviewApi, type ReviewItem } from "@/lib/api/client"; +import { eventBus, EVENTS } from "@/lib/events"; +import { useMoney } from "@/lib/format"; +import { toast } from "@/lib/toast"; +import { cn } from "@/lib/utils"; + +type Field = { key: "vendor_name" | "date" | "invoice_number" | "total_amount"; label: string }; +const FIELDS: Field[] = [ + { key: "vendor_name", label: "Vendor" }, + { key: "date", label: "Date" }, + { key: "invoice_number", label: "Bill no." }, + { key: "total_amount", label: "Total" }, +]; +// which field each validation flag (backend extract/validate.py) is about, and the reason in plain words +const DOUBT: Record = { total_mismatch: "total_amount", duplicate: "invoice_number", bad_date: "date", unknown_vendor: "vendor_name", no_currency: "total_amount" }; +const REASON: Record = { + total_mismatch: "Totals don't add up.", + duplicate: "Looks like a duplicate.", + bad_date: "Date unclear.", + unknown_vendor: "New to you.", + no_currency: "No currency printed.", + unknown_po: "Purchase order not found.", + po_mismatch: "Doesn't match its purchase order.", + possible_injection: "Unusual text on the bill.", +}; +const dayMonth = (s: string | null) => (s ? new Date(s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : ""); +// the checks' details are terse ("line items 80 + tax 20 != total 110"); the arithmetic one gets a sentence instead +const plain = (rule: string, detail: string) => + rule === "total_mismatch" ? "The items and tax don't add up to the printed total." : `${detail.charAt(0).toUpperCase()}${detail.slice(1)}.`; +const errorText = (e: unknown) => (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail; + +// Needs your review (spec 2026-10-22 §4.5; replaces the old Review tab): one card per flagged receipt with the fields +// the checks doubted highlighted, the reason in plain words, and Approve / Edit / Reject. A duplicate offers "Keep both" +// instead of Approve. Compare is left out: the item doesn't carry the record it duplicates. +export function ReviewCards({ items, onDecided }: { items: ReviewItem[]; onDecided: (id: string) => void }) { + return ( + + {items.length} + Highlighted fields are the ones Lumen wasn't sure about + + } + skeletonHeight={0} + data-testid="review" + > +
    + {items.map((item) => ( + + ))} +
    +
    + ); +} + +function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: string) => void }) { + const money = useMoney(); + const inv = item.invoice; + const vendor = String(inv.vendor_name ?? "Unknown vendor"); + const rules = item.flags.map((f) => f.rule); + const doubtful = new Set(rules.map((r) => DOUBT[r]).filter(Boolean)); + const duplicate = rules.includes("duplicate"); + const first = item.flags.find((f) => f.severity !== "note") ?? item.flags[0]; + const [editing, setEditing] = useState(false); + const [edits, setEdits] = useState>({}); + const [busy, setBusy] = useState(false); + const show = (k: Field["key"]) => { + const v = inv[k]; + if (k === "total_amount" && typeof v === "number") return money(v, inv.currency as string | undefined); + // an ISO date reads as "12 Oct 2026"; anything else is shown as printed, since that's what is in doubt + if (k === "date" && typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v)) return new Date(`${v}T00:00`).toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric" }); + return String(v ?? "—"); + }; + + async function decide(decision: "approve" | "reject") { + setBusy(true); + const changed = Object.fromEntries( + Object.entries(edits) + .filter(([k, v]) => v.trim() !== String(inv[k] ?? "")) + .map(([k, v]) => [k, k === "total_amount" ? Number(v) : v.trim()]), + ); + try { + await reviewApi.decide(item.id, decision, decision === "approve" && Object.keys(changed).length ? { edits: changed } : {}); + onDecided(item.id); + eventBus.emit(EVENTS.REVIEW_DECIDED); + } catch (e) { + toast.error(errorText(e) ?? "Couldn't save that decision."); + setBusy(false); + } + } + + return ( +
    +
    + + + {vendor} + + Receipt · {inv.source === "email" ? "from email" : "uploaded"} {dayMonth(item.created_at)} + + + {typeof inv.total_amount === "number" && {money(inv.total_amount, inv.currency as string | undefined)}} +
    +
    + {FIELDS.map((f) => ( +
    +
    {f.label}
    +
    + {editing ? ( + setEdits((x) => ({ ...x, [f.key]: e.target.value }))} + className="h-6 w-full rounded border border-border bg-card px-1.5" + /> + ) : ( + show(f.key) + )} +
    +
    + ))} +
    + {first && ( +

    + {REASON[first.rule] ?? "Lumen wasn't sure."} {plain(first.rule, first.detail)} +

    + )} +
    + {editing ? ( + <> + + + + ) : ( + <> + + {!duplicate && ( + + )} + + + )} +
    +
    + ); +} diff --git a/frontend/src/app/(app)/receipts/_components/side-cards.tsx b/frontend/src/app/(app)/receipts/_components/side-cards.tsx new file mode 100644 index 0000000..db7feef --- /dev/null +++ b/frontend/src/app/(app)/receipts/_components/side-cards.tsx @@ -0,0 +1,127 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { PageCard } from "@/components/page/page-card"; +import { VendorChip } from "@/components/page/vendor-chip"; +import { GmailOAuthButton } from "@/components/gmailOAuthButton"; +import { emailConfigApi, type StatementImport } from "@/lib/api/client"; +import { toast } from "@/lib/toast"; +import type { EmailStatus } from "../use-receipts-data"; + +const dayMonth = (s: string | null) => (s ? new Date(s.length === 10 ? `${s}T00:00` : s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : ""); +const ago = (s: string) => { + const m = Math.round((Date.now() - new Date(s).getTime()) / 60_000); + return m < 1 ? "just now" : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`; +}; +const PARTS = [ + { key: "imported", label: "new", className: "bg-primary" }, + { key: "matched", label: "matched", className: "bg-primary/45" }, + { key: "duplicates", label: "duplicates", className: "bg-amber-500" }, + { key: "credits", label: "credits skipped", className: "bg-muted-foreground/25" }, +] as const; + +// Statement imports (spec 2026-10-22 §4.5): one entry per import with what became of its lines. Counts are as the +// import returned them; "matched" lines were already recorded from a receipt. +export function ImportsCard({ imports }: { imports: StatementImport[] }) { + return ( + Import statement} + skeletonHeight={0} + data-testid="imports" + > + {imports.length === 0 ? ( +

    No statements yet. Import a CSV from your bank or card and its spending lines become transactions.

    + ) : ( +
    + {imports.slice(0, 4).map((i) => { + const total = PARTS.reduce((s, p) => s + i[p.key], 0) || 1; + const name = i.label ?? i.file_name ?? "Statement"; + return ( +
    +
    + + + {name} + + {i.period_from ? `${dayMonth(i.period_from)} – ${dayMonth(i.period_to)} · ` : ""}imported {dayMonth(i.created_at)} + + + {i.lines} lines +
    +
    + {PARTS.map((p) => i[p.key] > 0 && )} +
    +
    + {PARTS.map((p) => i[p.key] > 0 && {i[p.key]} {p.key === "duplicates" && i[p.key] === 1 ? "duplicate" : p.label})} +
    +
    + ); + })} +
    + )} +
    + ); +} + +// From your inbox: Gmail (or IMAP) receipt collection. Connected: what it's done and Check now / Pause. Not connected: +// connect Gmail here (it had no home in the app since the old dashboard went). +export function InboxCard({ email, onChanged }: { email: EmailStatus | null; onChanged: () => void }) { + const [busy, setBusy] = useState(false); + async function act(f: () => Promise, done?: string) { + setBusy(true); + try { + await f(); + if (done) toast.success(done); + onChanged(); + } catch { + toast.error("Couldn't reach your inbox right now."); + } finally { + setBusy(false); + } + } + const on = email?.configured; + return ( + {email.polling_enabled ? "Connected" : "Paused"}} + skeletonHeight={0} + data-testid="inbox" + > + {on ? ( + <> +
    + + + {email.email_address} + + {email.last_successful_poll ? `Checked ${ago(email.last_successful_poll)} · ` : ""} + {email.transactions_added ?? 0} receipts added + + +
    + {email.last_error &&

    {email.last_error}

    } +
    + + +
    + + ) : ( + <> +

    Connect Gmail and Lumen files receipts and invoices from your inbox. You still approve anything it isn't sure about.

    + onChanged()} /> + + )} +
    + ); +} diff --git a/frontend/src/app/(app)/import/importContent.tsx b/frontend/src/app/(app)/receipts/import/import-content.tsx similarity index 84% rename from frontend/src/app/(app)/import/importContent.tsx rename to frontend/src/app/(app)/receipts/import/import-content.tsx index 804140c..c51b70b 100644 --- a/frontend/src/app/(app)/import/importContent.tsx +++ b/frontend/src/app/(app)/receipts/import/import-content.tsx @@ -1,10 +1,11 @@ "use client"; -// Bare-bones CSV statement import (SPEC-STATEMENTS). The real screen comes with the UI refactor. +// CSV statement import (SPEC-STATEMENTS), reached from Receipts & statements. Each import is kept in its history +// (UI revamp Phase 5e) under the optional account name. import { Loader2 } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; -import { DashboardShell } from "@/components/dashboard-shell"; +import { PageHeader } from "@/components/page/page-header"; import { useProfile } from "@/components/profile/profile-provider"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -35,6 +36,7 @@ export default function ImportContent() { const [preview, setPreview] = useState(null); const [mapping, setMapping] = useState(null); const [currency, setCurrency] = useState(profile?.currency ?? "USD"); + const [label, setLabel] = useState(""); const [busy, setBusy] = useState<"preview" | "import" | null>(null); const [error, setError] = useState(null); const [raw, setRaw] = useState(null); @@ -72,7 +74,7 @@ export default function ImportContent() { setError(null); setRaw(null); try { - setResult(await statementApi.import(file, mapping, currency, preview.header_line)); + setResult(await statementApi.import(file, mapping, currency, preview.header_line, label)); } catch (e) { fail(e, "Couldn't import that statement."); } finally { @@ -83,10 +85,12 @@ export default function ImportContent() { const money = (n: number | null) => (n == null ? "—" : n.toFixed(2)); return ( - +
    + A CSV from your bank or card. Spending lines become transactions; credits are skipped for now.

    } + actions={‹ Receipts & statements} + />
    ))} +
    + + setLabel(e.target.value)} className={selectClass} /> +
    ); } diff --git a/frontend/src/app/(app)/receipts/import/page.tsx b/frontend/src/app/(app)/receipts/import/page.tsx new file mode 100644 index 0000000..a4c593e --- /dev/null +++ b/frontend/src/app/(app)/receipts/import/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import ImportContent from "./import-content"; + +export const metadata: Metadata = { title: "Import a statement" }; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/(app)/receipts/page.tsx b/frontend/src/app/(app)/receipts/page.tsx index 66fb472..ad60095 100644 --- a/frontend/src/app/(app)/receipts/page.tsx +++ b/frontend/src/app/(app)/receipts/page.tsx @@ -1,9 +1,8 @@ import type { Metadata } from "next"; -import { ReceiptsTabs } from "./receipts-tabs"; +import ReceiptsContent from "./receipts-content"; export const metadata: Metadata = { title: "Receipts & statements" }; -// Interim (UI revamp Phase 1): the old Documents, Import and Review pages as tabs until Phase 5 redesigns this page. export default function Page() { - return ; + return ; } diff --git a/frontend/src/app/(app)/receipts/receipts-content.tsx b/frontend/src/app/(app)/receipts/receipts-content.tsx new file mode 100644 index 0000000..6a6a0c0 --- /dev/null +++ b/frontend/src/app/(app)/receipts/receipts-content.tsx @@ -0,0 +1,81 @@ +"use client"; + +import Link from "next/link"; +import { FileSpreadsheet, Mail } from "lucide-react"; +import { PageHeader } from "@/components/page/page-header"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { DialogDemo as UploadDialog } from "@/components/uploadDialog"; +import { documentsApi } from "@/lib/api/client"; +import { toast } from "@/lib/toast"; +import { Brief } from "../home/_components/brief"; +import { Library, toRows } from "./_components/library"; +import { ReviewCards } from "./_components/review-cards"; +import { ImportsCard, InboxCard } from "./_components/side-cards"; +import { useReceiptsData } from "./use-receipts-data"; + +// Receipts & statements (UI revamp Phase 5e, spec 2026-10-22 §4.5): what have I given Lumen, and what needs my OK? +// Replaces the Documents / Import / Review tabs. Everything loads together and appears in one step, so the review +// row (only there when something is flagged) never pushes the rest down. +export default function ReceiptsContent() { + const { data, reload, update } = useReceiptsData(); + + async function remove(id: string) { + try { + await documentsApi.remove(id); + update((d) => ({ documents: d.documents.filter((x) => x.id !== id) })); + } catch { + toast.error("Couldn't delete that document."); + } + } + + const n = data?.review.length ?? 0; + const brief = !data ? null : n ? `${n} ${n === 1 ? "receipt needs" : "receipts need"} your OK before ${n === 1 ? "it counts" : "they count"}.` : "Everything you've given Lumen is filed."; + + return ( +
    +
    +
    + } + actions={ + <> + + + {data && !data.email?.configured && ( + + )} + + } + /> + {!data ? ( + + ) : ( + <> + {n > 0 && ( +
    + update((d) => ({ review: d.review.filter((r) => r.id !== id) }))} /> +
    + )} +
    + void remove(id))} /> +
    + + void reload()} /> +
    +
    + + )} +
    +
    + ); +} diff --git a/frontend/src/app/(app)/receipts/receipts-tabs.tsx b/frontend/src/app/(app)/receipts/receipts-tabs.tsx deleted file mode 100644 index 694ea98..0000000 --- a/frontend/src/app/(app)/receipts/receipts-tabs.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { cn } from "@/lib/utils"; -import DocumentsContent from "../documents/documentsContent"; -import ImportContent from "../import/importContent"; -import ReviewContent from "../review/reviewContent"; - -const TABS = [ - { id: "documents", label: "Documents", Content: DocumentsContent }, - { id: "import", label: "Import", Content: ImportContent }, - { id: "review", label: "Review", Content: ReviewContent }, -] as const; - -// Reads ?tab= on the client so /receipts stays a static page (no server render per visit). -export function ReceiptsTabs() { - const tab = useSearchParams().get("tab"); - const active = TABS.find((t) => t.id === tab) ?? TABS[0]; - return ( - <> - - - - ); -} diff --git a/frontend/src/app/(app)/receipts/use-receipts-data.ts b/frontend/src/app/(app)/receipts/use-receipts-data.ts new file mode 100644 index 0000000..3f9bfb6 --- /dev/null +++ b/frontend/src/app/(app)/receipts/use-receipts-data.ts @@ -0,0 +1,52 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { documentsApi, emailConfigApi, reviewApi, statementApi, transactionApi, type LumenDocument, type ReviewItem, type StatementImport } from "@/lib/api/client"; +import { eventBus, EVENTS } from "@/lib/events"; +import type { Txn } from "@/lib/home-math"; + +export type EmailStatus = { + configured: boolean; + email_address?: string; + provider?: string; + polling_enabled?: boolean; + transactions_added?: number; + last_successful_poll?: string | null; + last_error?: string | null; +}; + +type Data = { review: ReviewItem[]; documents: LumenDocument[]; receipts: Txn[]; imports: StatementImport[]; email: EmailStatus | null }; +const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fulfilled" ? r.value : fallback); + +// Everything the Receipts & statements page shows, loaded together so the page appears in one step (no layout shift +// from the review row arriving late). A failed call empties only its own part. Uploads elsewhere refresh it. +// ponytail: the library lists the latest 100 receipts; page through /transactions if people need older ones here. +export function useReceiptsData() { + const [data, setData] = useState(null); + + const load = useCallback(async () => { + const [review, documents, txns, imports, email] = await Promise.allSettled([ + reviewApi.list(), + documentsApi.list(), + transactionApi.getTransactions({ page: 1, page_size: 100 }), + statementApi.imports(), + emailConfigApi.getStatus() as Promise, + ]); + const rows = (value(txns, { data: [] }) as { data?: Txn[] }).data ?? []; + setData({ + review: value(review, []) ?? [], + documents: value(documents, []) ?? [], + receipts: rows.filter((t) => t.source !== "statement"), + imports: value(imports, []) ?? [], + email: value(email, null), + }); + }, []); + + useEffect(() => { + void load(); + return eventBus.on(EVENTS.INVOICE_UPDATED, () => void load()); + }, [load]); + + const update = useCallback((change: (d: Data) => Partial) => setData((d) => (d ? { ...d, ...change(d) } : d)), []); + return { data, reload: load, update }; +} diff --git a/frontend/src/app/(app)/review/reviewContent.tsx b/frontend/src/app/(app)/review/reviewContent.tsx deleted file mode 100644 index 6806ff2..0000000 --- a/frontend/src/app/(app)/review/reviewContent.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -// Minimal page to exercise the review queue API. The real review screen comes with the UI refactor. -import { useCallback, useEffect, useState } from "react"; -import { DashboardShell } from "@/components/dashboard-shell"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { filesApi, reviewApi, type ReviewItem } from "@/lib/api/client"; -import { eventBus, EVENTS } from "@/lib/events"; -import { toast } from "@/lib/toast"; - -function errorMessage(error: unknown, fallback: string): string { - return (error as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback; -} - -export default function ReviewContent() { - const [items, setItems] = useState([]); - const [totals, setTotals] = useState>({}); - - const load = useCallback(async () => { - try { - setItems(await reviewApi.list()); - } catch (error) { - toast.error(errorMessage(error, "Couldn't load the review queue.")); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - async function decide(item: ReviewItem, decision: "approve" | "reject") { - const total = totals[item.id]; - const edits = decision === "approve" && total ? { total_amount: Number(total) } : {}; - try { - const done = await reviewApi.decide(item.id, decision, { edits }); - toast.success(decision === "approve" ? `Approved${done.flags.length ? " (with warnings)" : ""}` : "Rejected"); - setItems((list) => list.filter((i) => i.id !== item.id)); - eventBus.emit(EVENTS.REVIEW_DECIDED); - } catch (error) { - toast.error(errorMessage(error, "Couldn't save the decision.")); - } - } - - return ( - - - {items.length === 0 ? ( -

    Nothing to review. Upload an invoice whose total doesn't match its line items, or the same invoice twice.

    - ) : items.map((item) => ( -
    -

    - {String(item.invoice.vendor_name ?? "Unknown vendor")} · {String(item.invoice.invoice_number ?? "no number")} ·{" "} - {String(item.invoice.date ?? "no date")} · total {String(item.invoice.total_amount ?? "?")} · confidence {item.confidence} -

    -
      - {item.flags.map((f) =>
    • {f.severity === "fail" ? "Fail" : "Warning"}: {f.detail}
    • )} -
    -
    - setTotals((t) => ({ ...t, [item.id]: e.target.value }))} /> - {typeof item.invoice.file_key === "string" && ( - - )} - - -
    -
    - ))} -
    -
    - ); -} diff --git a/frontend/src/components/uploadDialog.tsx b/frontend/src/components/uploadDialog.tsx index 2d4230d..4985d9c 100644 --- a/frontend/src/components/uploadDialog.tsx +++ b/frontend/src/components/uploadDialog.tsx @@ -5,7 +5,9 @@ import { Button } from "@/components/ui/button"; import { Upload, CheckCircle, AlertCircle } from "lucide-react"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { DialogTitle } from "@radix-ui/react-dialog"; -import { ocrApi } from "@/lib/api/client"; +import Link from "next/link"; +import { documentsApi, ocrApi } from "@/lib/api/client"; +import { cn } from "@/lib/utils"; import { eventBus, EVENTS } from "@/lib/events"; import { logger } from "@/lib/logger"; @@ -17,7 +19,17 @@ interface FileWithStatus { extractedData?: any; } +const DOC_TYPES = [ + { value: "contract", label: "Contract" }, + { value: "policy", label: "Policy" }, + { value: "purchase_order", label: "Purchase order" }, + { value: "invoice", label: "Invoice" }, + { value: "other", label: "Other" }, +]; + // Pass `open`/`onOpenChange` to open it from elsewhere too (Home's hero +); otherwise it manages itself. +// Receipts & invoices are read into transactions (/extract); Documents (PDF contracts, policies…) are indexed for +// Ask Lumen's document search (/api/documents). Statements have their own import. export function DialogDemo({ defaultOpen = false, open: openProp, @@ -29,6 +41,8 @@ export function DialogDemo({ }) { const [files, setFiles] = useState([]); const [uploading, setUploading] = useState(false); + const [kind, setKind] = useState<"receipts" | "documents">("receipts"); + const [docType, setDocType] = useState("contract"); const [ownOpen, setOwnOpen] = useState(defaultOpen); const open = openProp ?? ownOpen; const setOpen = (next: boolean) => { @@ -87,7 +101,10 @@ export function DialogDemo({ try { logger.debug("📤 Uploading file:", fileItem.file.name); // Use OCR API to extract data - this saves to database automatically - const result = await ocrApi.extractInvoice(fileItem.file); + const result = + kind === "documents" + ? { success: true, data: await documentsApi.upload(fileItem.file, docType), duplicate: false } + : await ocrApi.extractInvoice(fileItem.file); logger.debug("📥 OCR API Response:", result); @@ -185,7 +202,7 @@ export function DialogDemo({ - Upload documents + Upload
    @@ -193,27 +210,63 @@ export function DialogDemo({

    - Upload documents + Upload

    - Drag & drop files here or click to browse. - Supported: PDF, PNG, JPG + {kind === "receipts" + ? "Lumen reads each one into a transaction. PDF, PNG or JPG." + : "Contracts, policies and other PDFs, searchable from Ask Lumen."}

    +
    +
    + {([["receipts", "Receipts & invoices"], ["documents", "Documents"]] as const).map(([value, label]) => ( + + ))} +
    + {kind === "documents" && ( + + )} +
    +

    + Statements are CSV files:{" "} + setOpen(false)} className="text-foreground underline underline-offset-4"> + import a statement + {" "} + instead. +

    +
    e.preventDefault()} onDrop={handleDrop} - className="mt-6 rounded-xl border-2 border-dashed border-border p-6 flex flex-col items-center justify-center text-center bg-transparent" + className="mt-4 rounded-xl border-2 border-dashed border-border p-6 flex flex-col items-center justify-center text-center bg-transparent" > diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index c412267..eae48bf 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -729,7 +729,7 @@ export const agentApi = { export interface ReviewFlag { rule: string; - severity: "warn" | "fail"; + severity: "warn" | "fail" | "note"; // note: a warning not raised because you kept approving it detail: string; } @@ -832,14 +832,31 @@ const upload = (file: File, extra: Record = {}) => { return form; }; +/** One statement import (GET /api/statements/imports); counts as the import returned them. */ +export interface StatementImport { + id: string; + file_name: string | null; + label: string | null; + period_from: string | null; + period_to: string | null; + lines: number; + imported: number; + matched: number; + duplicates: number; + credits: number; + unreadable: number; + created_at: string; +} + export const statementApi = { + imports: async (): Promise => (await apiClient.get("/api/statements/imports")).data.imports, preview: async (file: File): Promise => (await apiClient.post("/api/statements/preview", upload(file), { headers: { "Content-Type": undefined } })).data, - import: async (file: File, mapping: StatementMapping, currency: string, headerLine: number): Promise => + import: async (file: File, mapping: StatementMapping, currency: string, headerLine: number, label = ""): Promise => ( await apiClient.post( "/api/statements/import", - upload(file, { mapping: JSON.stringify(mapping), currency, header_line: String(headerLine) }), + upload(file, { mapping: JSON.stringify(mapping), currency, header_line: String(headerLine), ...(label.trim() ? { label: label.trim() } : {}) }), { headers: { "Content-Type": undefined } }, ) ).data, From 748dc97d8a8deb3451467fd07ebbe2496f86d89f Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:46:40 +0530 Subject: [PATCH 44/47] docs: STORY 36, codebase guide and spec backlog for Phase 5e --- docs/CODEBASE-GUIDE.md | 22 ++++++++++++++----- docs/direction/STORY.md | 18 +++++++++++++++ .../specs/2026-10-20-ui-revamp-design.md | 2 ++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index 3e826a2..636823d 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -253,10 +253,10 @@ embeds the demo documents once per server and uploads the files in the backgroun the landing or sign-in pages, which don't need the API; they still ping it so it's warm by the time the app opens). ## 7b2. App frame and navigation (frontend) -- The signed-in pages live in the route group `app/(app)/`: `/home`, `/spending`, `/bills`, `/receipts` - (`?tab=documents|import|review`), `/ask`, `/settings`. The old URLs (`/dashboard`, `/analytics`, `/ai-analytics`, +- The signed-in pages live in the route group `app/(app)/`: `/home`, `/spending`, `/bills`, `/budget`, `/receipts` + (and `/receipts/import`), `/ask`, `/settings`. The old URLs (`/dashboard`, `/analytics`, `/ai-analytics`, `/chatbot`, `/documents`, `/import`, `/review`, `/upload`) redirect to them in `next.config.js` (temporary 307s, query - strings carried). Until UI revamp Phase 5, Spending, Bills and Receipts reuse the old pages' `*Content.tsx`. + strings carried). - **Sidebar** (`components/app-sidebar.tsx`): Home · Track (Spending, Bills & subscriptions) · Plan (Budget) · Receipts & statements (badge = flagged review items) · Ask Lumen (Ctrl/⌘+K). It collapses to a 56px icon rail with Ctrl/⌘+B, remembered in the `sidebar_state` cookie. The layout reads that cookie on the sidebar's first render, which is always @@ -316,6 +316,15 @@ embeds the demo documents once per server and uploads the files in the backgroun Add category, and the unassigned footer. The draft panel shows pending `set_budget` lines (Skip / Approve named for the category, "Approve remaining N"). History sits above By category so the data-height cards come last. Maths in `lib/budget-math.ts` (tests: `e2e/budget-math.spec.ts`). +- **Receipts & statements** (`app/(app)/receipts/`, Phase 5e): "what have I given Lumen, and what needs my OK?" It + replaced the Documents / Import / Review tabs. Everything loads together and appears in one step. Needs your review + (only when the queue isn't empty): a card per flagged item with the doubtful field highlighted from its validation + flag (`total_mismatch`/`no_currency` → Total, `duplicate` → Bill no., `bad_date` → Date, `unknown_vendor` → Vendor), + the reason in plain words, Approve / Edit (sends only changed fields) / Reject; a duplicate offers Keep both. The + library merges receipts (non-statement transactions, latest 100), review items, RAG documents (deletable) and + statement imports, with chips, search and Show more. Right column: Statement imports (counts bar) and From your + inbox (`/api/v1/email-config/status`: Check now, Pause/Resume, or Connect with Google). The upload dialog + (`components/uploadDialog.tsx`) chooses Receipts & invoices (`/extract`) or Documents (PDF, `/api/documents`). - **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, @@ -514,8 +523,11 @@ embeds the demo documents once per server and uploads the files in the backgroun into a vendor name and a category. - `api/statements.py`: `POST /api/statements/preview` (columns, guessed mapping, 5 sample lines, counts; a file with no header is a 400 with `code: "no_header"` and the first raw rows) and `POST /api/statements/import` (file + - mapping JSON + currency + header_line). Only debits become transactions (`source="statement"`). + mapping JSON + currency + header_line + optional account `label`). Only debits become transactions + (`source="statement"`). Each import also writes a `statement_imports` row (file, label, period, counts as returned; + the CSV isn't kept), listed by `GET /api/statements/imports` (UI revamp Phase 5e). - Dedupe: each line has an `import_key` (sha1 of user, date, amount, description), so re-importing is safe. Invoice matching: a debit with the same amount within 3 days and a shared word in the vendor name is "already recorded", not added. Credits are counted and skipped; unreadable lines are returned by line number. -- `/import` (frontend, minimal): choose a CSV, adjust the mapping and currency, preview, import, see the result line. +- `/receipts/import` (frontend): choose a CSV, adjust the mapping, currency and account name, preview, import, see the + result line. diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index 7f2e78b..2cada52 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -461,3 +461,21 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in and 4 maths tests. A phone screenshot showed buttons and bars clipped past the screen edge, which the old "no sideways scroll" check missed; the test now checks every header button, bar and table cell ends on screen. +## 36. One place for everything you've given Lumen (UI revamp, Phase 5e) +- **S:** Receipts & statements was three old pages as tabs (Documents, Import, Review). The review queue showed raw + rule text and a "correct total" box; statement imports left no trace once the result line was gone; and connecting + Gmail had no entry point in the app at all since the old dashboard was removed. +- **T:** One page that answers "what have I given Lumen, and what needs my OK?", with the review as cards that point at + the doubtful field, and a history of statement imports. +- **A:** A `statement_imports` table written by every import (file, optional account name, period, counts as returned) + and `GET /api/statements/imports` (TDD). The page: review cards that map each validation flag to the field it doubts + and a plain reason, Edit that sends only changed fields, Keep both for duplicates; one library of receipts, review + items, documents and imports with chips and search; Statement imports and From your inbox cards (Check now, Pause, + Connect with Google). The upload dialog now files PDFs as documents too. The import flow moved to + `/receipts/import`; the old routes redirect, and the three old pages were deleted. +- **R:** Backend 995 passed (2 new: history per import with period and counts, re-import as its own row, per-user, + label limit). Frontend: 15 Receipts e2e checks (brief, highlights and reasons, edit-and-approve body, reject and the + sidebar badge, empty queue, library mix, chips, search, delete, imports counts, inbox both ways, document upload, + import link, redirects, CLS under 0.01, nothing past the edge at 375 px). A screenshot pass fixed raw ISO dates on the + cards. + 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 d3974a3..52b4433 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -245,5 +245,7 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **Later.** Budget page: "Safe per day" is left ÷ days left; subtract bills still due this month (the Bills data) once both are on the page. *(5d)* - [ ] **Later.** The overall monthly budget (`User.monthly_budget`) isn't versioned like category limits, so history uses today's total for past budgeted months. Version it if people change it often. *(5d)* - [ ] **Later.** Draft lines could show a small month-by-month glyph once `basis` carries per-month amounts. *(5d)* +- [ ] **Later.** Receipts: tell receipts from invoices in the data so the library can have both chips; "Compare" on a duplicate needs the review item to carry the record it duplicates. *(5e)* +- [ ] **Later.** Receipts library lists the latest 100 receipts; page through `/transactions` if people need older ones there. *(5e)* - [ ] **Later.** Spending: the category edit (pencil) is hidden below `sm`, so phones can't recategorise; and "Lumen noticed" wraps beside its pill at 375px. *(5b)* - [ ] **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)* From b3f1fa52ce092fa9a6241d775b08af4547a8cf7a Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:16:39 +0530 Subject: [PATCH 45/47] fix: Phase 5 review findings (receipts filter, past-month and stale-draft budgets, month skew, late bills, open original, review errors and totals, CSV formulas) --- backend/api/agent.py | 4 +- backend/api/budget.py | 31 ++++++++++---- backend/api/review.py | 13 +++++- backend/routes/database_query.py | 4 ++ backend/tests/test_api_budget.py | 42 +++++++++++++++++++ backend/tests/test_api_review.py | 7 ++++ backend/tests/test_invoice_upload.py | 15 +++++++ docs/direction/STORY.md | 7 ++++ .../specs/2026-10-20-ui-revamp-design.md | 9 ++++ frontend/e2e/bills-math.spec.ts | 22 +++++++++- frontend/e2e/budget.spec.ts | 12 +++++- frontend/e2e/fixtures.ts | 6 ++- frontend/e2e/receipts.spec.ts | 37 ++++++++++++++++ frontend/e2e/spending-math.spec.ts | 11 ++++- .../src/app/(app)/budget/budget-content.tsx | 4 +- .../src/app/(app)/budget/use-budget-data.ts | 11 +++-- frontend/src/app/(app)/home/use-home-data.ts | 15 +------ .../(app)/receipts/_components/library.tsx | 23 ++++++++-- .../receipts/_components/review-cards.tsx | 28 +++++++++++-- .../app/(app)/receipts/use-receipts-data.ts | 7 ++-- .../spending/_components/transactions.tsx | 7 +--- frontend/src/lib/api/client.ts | 10 +++-- frontend/src/lib/bills-math.ts | 25 ++++++++--- frontend/src/lib/spending-math.ts | 13 ++++++ 24 files changed, 305 insertions(+), 58 deletions(-) diff --git a/backend/api/agent.py b/backend/api/agent.py index 6671c78..184fd93 100644 --- a/backend/api/agent.py +++ b/backend/api/agent.py @@ -168,8 +168,8 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d payload, limit = {}, math.nan if not math.isfinite(limit) or limit <= 0 or not p.target.strip(): raise HTTPException(422, "This budget suggestion has no valid limit; reject it instead.") - # from the month the draft was for (a draft made last month still starts there), else this month - upsert_budget(session, user_id, p.target.strip(), limit, valid_month(payload.get("month")) or this_month()) + # from the month the draft was for, but never earlier than this one: past months stay as they were + upsert_budget(session, user_id, p.target.strip(), limit, max(valid_month(payload.get("month")) or "", this_month())) applied = {"category": p.target.strip(), "limit": limit} if decision == "approved" and p.type == "update_category": category = json.loads(p.payload or "{}").get("category") diff --git a/backend/api/budget.py b/backend/api/budget.py index 5d40c93..8fea860 100644 --- a/backend/api/budget.py +++ b/backend/api/budget.py @@ -36,6 +36,18 @@ def _shift(month: str, n: int) -> str: return f"{y}-{m + 1:02d}" +def near_month(month: str | None) -> str: + """`month` if it's the server's month or next to it, else 422; None means the server's month.""" + from fastapi import HTTPException + + now = this_month() + if month is None or month == now: + return now + if month in (_shift(now, -1), _shift(now, 1)): + return month + raise HTTPException(422, "Budgets can only be changed for the current month.") + + def limits_in_force(rows, month: str) -> dict[str, tuple[str, float]]: """Case-folded category -> (name, limit) for `month`: each category's latest row from that month or before. A limit of 0 is a removal, so the category has none.""" @@ -53,6 +65,8 @@ class CategoryLimit(BaseModel): class BudgetUpdate(BaseModel): monthly_budget: float | None = Field(default=None, ge=0) + # the browser's month: around midnight on the 1st it can be a month off the server's (UTC) date + month: str | None = Field(default=None, pattern=MONTH_RE) categories: list[CategoryLimit] = Field(default_factory=list, max_length=50) @@ -83,10 +97,13 @@ def _state(engine: Engine, user_id: str, month: str) -> dict: by_key = {k.casefold(): v for k, v in spend.items()} with Session(engine) as session: user = session.get(User, user_id) + limits = limits_in_force(_rows(session, user_id), month) cats = [{"category": name, "limit": limit, "spent": round(by_key.get(key, 0), 2)} - for key, (name, limit) in limits_in_force(_rows(session, user_id), month).items()] + for key, (name, limit) in limits.items()] cats.sort(key=lambda c: c["spent"] / c["limit"], reverse=True) - return {"success": True, "month": month, "monthly_budget": user.monthly_budget if user else None, + # the overall budget isn't versioned, so a past month only has it once category limits were in force + overall = user.monthly_budget if user and (limits or month >= this_month()) else None + return {"success": True, "month": month, "monthly_budget": overall, "spent": round(sum(spend.values()), 2), "categories": cats} @@ -140,13 +157,13 @@ def get_budget(month: str | None = Query(default=None, pattern=MONTH_RE), claims @router.get("/history", dependencies=[Depends(rate_limit())]) -def get_history(months: int = Query(default=6, ge=1, le=12), claims: dict = Depends(current_user), - engine: Engine = Depends(get_engine)): +def get_history(months: int = Query(default=6, ge=1, le=12), to: str | None = Query(default=None, pattern=MONTH_RE), + claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): """Budget vs spent for the last `months` months, oldest first, ending with this one. A past month with no category limit in force has no budget (null): spend only.""" from models import User - user_id, now = claims["sub"], this_month() + user_id, now = claims["sub"], near_month(to) with Session(engine) as session: user = session.get(User, user_id) overall = user.monthly_budget if user else None @@ -158,7 +175,7 @@ def get_history(months: int = Query(default=6, ge=1, le=12), claims: dict = Depe out = [] for month in (_shift(now, -n) for n in range(months - 1, -1, -1)): limits = limits_in_force(rows, month) - budget = (overall or round(sum(v for _, v in limits.values()), 2) or None) if limits or month == now else None + budget = (overall or round(sum(v for _, v in limits.values()), 2) or None) if limits or month >= now else None spent = sum(month_spend_by_category(txns, int(month[:4]), int(month[5:])).values()) out.append({"month": month, "budget": budget, "spent": round(spent, 2)}) return {"success": True, "months": out} @@ -167,7 +184,7 @@ def get_history(months: int = Query(default=6, ge=1, le=12), claims: dict = Depe @router.put("", dependencies=[Depends(rate_limit())]) def put_budget(body: BudgetUpdate, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): """Changes apply from the current month on; past months keep the limits they had.""" - user_id, month = claims["sub"], this_month() + user_id, month = claims["sub"], near_month(body.month) with Session(engine) as session, session.begin(): if "monthly_budget" in body.model_fields_set: ensure_user(session, user_id, claims.get("email")).monthly_budget = body.monthly_budget diff --git a/backend/api/review.py b/backend/api/review.py index 123b842..ada9943 100644 --- a/backend/api/review.py +++ b/backend/api/review.py @@ -7,12 +7,13 @@ from __future__ import annotations import json +import math import uuid from datetime import date, datetime from functools import lru_cache from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from sqlalchemy import create_engine, func, select from sqlalchemy.engine import Engine from sqlalchemy.exc import IntegrityError @@ -43,6 +44,16 @@ class Decision(BaseModel): edits: dict = {} note: str | None = None + @field_validator("edits") + @classmethod + def _total_is_an_amount(cls, edits: dict) -> dict: + # an edited total becomes the transaction's amount: it has to be a real, positive number + if "total_amount" in edits: + v = edits["total_amount"] + if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) or v <= 0: + raise ValueError("total_amount must be a positive number") + return edits + def _history(session: Session, user_id: str) -> list[dict]: """The user's review decisions, newest first, for SPEC-FEEDBACK loop B.""" diff --git a/backend/routes/database_query.py b/backend/routes/database_query.py index 1c9170a..768a889 100644 --- a/backend/routes/database_query.py +++ b/backend/routes/database_query.py @@ -39,6 +39,7 @@ def get_transactions(user_id=None): vendor = request.args.get('vendor') min_amount = request.args.get('min_amount', type=float) max_amount = request.args.get('max_amount', type=float) + exclude_source = request.args.get('exclude_source') # e.g. "statement": Receipts lists files, not bank lines # Get sorting parameters sort_by = request.args.get('sort_by', 'created_at') @@ -60,6 +61,9 @@ def get_transactions(user_id=None): if vendor: query = query.filter(Transaction.vendor_name.like(f'%{vendor}%')) + if exclude_source: + query = query.filter(or_(Transaction.source.is_(None), Transaction.source != exclude_source)) + if min_amount is not None: query = query.filter(Transaction.total_amount >= min_amount) diff --git a/backend/tests/test_api_budget.py b/backend/tests/test_api_budget.py index d96482d..35460df 100644 --- a/backend/tests/test_api_budget.py +++ b/backend/tests/test_api_budget.py @@ -260,3 +260,45 @@ def test_approving_a_draft_writes_it_from_the_drafts_month(setup): c.post(f"/api/agent/proposals/{food['id']}/approve", headers=bearer(A)) with Session(engine) as s: assert [(b.category, b.effective_from) for b in s.scalars(select(Budget))] == [("Food", ym(0))] + + +def test_a_past_month_without_limits_has_no_overall_budget(setup): + """The overall budget isn't versioned: a month before any category limit shows spend only, like history.""" + c, engine = setup + c.put("/api/budget", headers=bearer(A), json={"monthly_budget": 50000}) + assert c.get(f"/api/budget?month={ym(3)}", headers=bearer(A)).json()["monthly_budget"] is None + put_row(engine, A, "Food", 3000, ym(2)) + assert c.get(f"/api/budget?month={ym(2)}", headers=bearer(A)).json()["monthly_budget"] == 50000 + assert c.get("/api/budget", headers=bearer(A)).json()["monthly_budget"] == 50000 + + +def test_approving_last_months_draft_applies_from_this_month(setup): + import json + + from models import Budget, Proposal + + c, engine = setup + history(engine, A) + food = next(p for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] if p["target"] == "Food") + with Session(engine) as s, s.begin(): # a draft left pending since last month + p = s.get(Proposal, food["id"]) + p.payload = json.dumps({**json.loads(p.payload), "month": ym(1)}) + c.post(f"/api/agent/proposals/{food['id']}/approve", headers=bearer(A)) + with Session(engine) as s: + assert [b.effective_from for b in s.scalars(select(Budget))] == [ym(0)] + + +def test_writes_can_name_the_clients_month_within_one_of_the_servers(setup): + """Around midnight on the 1st the browser and server can be a month apart: the client's month wins if adjacent.""" + from models import Budget + + c, engine = setup + nxt = date.today().replace(day=1) + __import__("datetime").timedelta(days=32) + nxt = nxt.strftime("%Y-%m") + body = c.put("/api/budget", headers=bearer(A), json={"month": nxt, "categories": [{"category": "Food", "limit": 900}]}).json() + assert body["month"] == nxt + with Session(engine) as s: + assert [b.effective_from for b in s.scalars(select(Budget))] == [nxt] + assert c.put("/api/budget", headers=bearer(A), json={"month": ym(2), "categories": []}).status_code == 422 + assert c.get(f"/api/budget/history?months=2&to={nxt}", headers=bearer(A)).json()["months"][-1]["month"] == nxt + assert c.get(f"/api/budget/history?months=2&to={ym(3)}", headers=bearer(A)).status_code == 422 diff --git a/backend/tests/test_api_review.py b/backend/tests/test_api_review.py index 338952b..b7c43af 100644 --- a/backend/tests/test_api_review.py +++ b/backend/tests/test_api_review.py @@ -192,3 +192,10 @@ def test_list_count_only(client): client.post("/api/review", headers=bearer(ALICE), json={"invoice": invoice(total_amount=total, invoice_number=f"N{total}")}) assert client.get("/api/review?status=flagged&count=1", headers=bearer(ALICE)).json() == {"success": True, "count": 3} assert client.get("/api/review?count=1", headers=bearer(BOB)).json() == {"success": True, "count": 0} + + +@pytest.mark.parametrize("total", [0, -5, None, "lots", True]) +def test_an_edit_cannot_approve_a_bad_total(client, total): + item = client.post("/api/review", headers=bearer(ALICE), json={"invoice": invoice(total_amount=999.0)}).json()["item"] + r = client.post(f"/api/review/{item['id']}/approve", headers=bearer(ALICE), json={"edits": {"total_amount": total}}) + assert r.status_code == 422 and _txn_count(client.engine, ALICE) == 1 diff --git a/backend/tests/test_invoice_upload.py b/backend/tests/test_invoice_upload.py index 3690199..4c252d6 100644 --- a/backend/tests/test_invoice_upload.py +++ b/backend/tests/test_invoice_upload.py @@ -621,3 +621,18 @@ def test_the_transaction_list_says_where_each_row_came_from(client, ocr): listed = client.get("/transactions", headers=AUTH).get_json() [tx] = [t for t in listed["data"] if t["id"] == body["transaction_id"]] assert tx["source"] == "statement" + + +def test_the_list_can_leave_out_statement_lines(client, ocr): + # Receipts & statements lists files, not bank lines: one big import must not push the receipts off the page + from models import Transaction, db + + body = _upload(client, _png_bytes()).get_json() + with client.application.app_context(): + for i in range(3): + db.session.add(Transaction(user_id=db.session.get(Transaction, body["transaction_id"]).user_id, + vendor_name=f"Line {i}", date="2026-10-01", total_amount=1, source="statement", + import_key=f"k{i}")) + db.session.commit() + listed = client.get("/transactions?exclude_source=statement&page_size=2", headers=AUTH).get_json() + assert [t["id"] for t in listed["data"]] == [body["transaction_id"]] diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index 2cada52..1b98fd6 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -478,4 +478,11 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in sidebar badge, empty queue, library mix, chips, search, delete, imports counts, inbox both ways, document upload, import link, redirects, CLS under 0.01, nothing past the edge at 375 px). A screenshot pass fixed raw ISO dates on the cards. +- **Phase 5 review:** one fresh reviewer read all 43 commits of Phase 5. No Critical issues; 8 Important ones, plus a + CSV-formula risk upgraded to Important, all fixed test-first: receipts lost behind a big statement import (now + filtered server-side), a past month showing today's overall budget, a stale draft writing into last month, the + first night of a month (browser and UTC server a month apart; every budget call now names the browser's month, + writes only within one month of the server's), late bills vanishing from "what's due" (now "Not seen yet"), no way + to open the original receipt, the wrong error text, an edit that could approve a ₹0 total (now refused by the API + too), and spreadsheet formulas in the CSV export. Backend 1004 passed, e2e 207. Nine minors went to the backlog. 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 52b4433..9491a80 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -247,5 +247,14 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **Later.** Draft lines could show a small month-by-month glyph once `basis` carries per-month amounts. *(5d)* - [ ] **Later.** Receipts: tell receipts from invoices in the data so the library can have both chips; "Compare" on a duplicate needs the review item to carry the record it duplicates. *(5e)* - [ ] **Later.** Receipts library lists the latest 100 receipts; page through `/transactions` if people need older ones there. *(5e)* +- [ ] **Phase 6.** Phase 5 review minors: refunds (negative spend) draw a full bar instead of an empty one (budget hero/categories, Home budget card, Spending by category) — clamp at 0. *(5 review)* +- [ ] **Phase 6.** Bills calendar projects a monthly bill into months before its first charge, and hides cancelled bills in months they really were charged. Project from `last_charge_date` on; show real charges for past months. *(5 review)* +- [ ] **Phase 6.** Naive UTC timestamps (`created_at`, `last_successful_poll`) are parsed as local time: "Checked 6 h ago" right after a poll in IST, late uploads dated a day off. Send them with `Z`. *(5 review)* +- [ ] **Phase 6.** Spending brief can name a payment bigger than the category's change ("one ₹9,999 payment is most of a ₹2,000 jump"); check the size first. *(5 review)* +- [ ] **Phase 6.** Receipts brief says "Everything … is filed" when the review list failed to load; say nothing instead. *(5 review)* +- [ ] **Phase 6.** Small layout shifts: "Connect email" joins the header after load (wraps on phones); Budget hero/History taller than their skeletons on phones; a pending draft narrows By category one frame after load on lg. *(5 review)* +- [ ] **Phase 6.** Home's spike link `?category=` is matched case-sensitively on Spending ("food" rows missed under "Food"). *(5 review)* +- [ ] **Phase 6.** Import page currency starts as USD until the profile loads; an INR user who doesn't notice imports in USD. *(5 review; predates 5e)* +- [ ] **Phase 6.** If the budget-versions migration is skipped on Postgres (only a warning), edits to an existing category in a new month hit the old unique constraint and 500. Make that failure loud. *(5 review; migration smoke-tested on Postgres 16 locally)* - [ ] **Later.** Spending: the category edit (pencil) is hidden below `sm`, so phones can't recategorise; and "Lumen noticed" wraps beside its pill at 375px. *(5b)* - [ ] **Owner, manual.** In Firefox, check that the profile/avatar menu doesn't shift the Ask Lumen column, and click through the old pages on light in a demo session. *(Phase 0 PR #89)* diff --git a/frontend/e2e/bills-math.spec.ts b/frontend/e2e/bills-math.spec.ts index 58bf5d0..78b48e2 100644 --- a/frontend/e2e/bills-math.spec.ts +++ b/frontend/e2e/bills-math.spec.ts @@ -1,6 +1,6 @@ // Node-only unit tests (no browser) for the Bills page's calculations (UI revamp Phase 5c). import { test, expect } from "@playwright/test"; -import { dueDays, monthlyEquivalent, tiles, upcoming } from "../src/lib/bills-math"; +import { dueDays, monthlyEquivalent, tiles, toBills, upcoming } from "../src/lib/bills-math"; import type { RecurringItem } from "../src/lib/api/client"; const item = (vendor: string, cadence: RecurringItem["cadence"], next: string, amount: number, extra: Partial = {}): RecurringItem => ({ @@ -44,3 +44,23 @@ test("tiles: due soon, recurring per month, and subscriptions that look stopped" expect(t.perMonth).toBe(25125); // rent + prime/12; stopped and cancelled ones don't count expect(t.stopped).toEqual({ count: 1, perMonth: 119 }); }); + +test("a bill that's late but not stopped stays on the list as not seen yet", () => { + const late = item("Power", "monthly", "2026-10-18", 1800); + expect(upcoming([late, item("Rent", "monthly", "2026-10-24", 25000)], today).map((g) => [g.label, g.rows.map((r) => r.vendor)])).toEqual([ + ["Not seen yet", ["Power"]], + ["This week", ["Rent"]], + ]); + expect(tiles([late], today).dueSoon).toEqual({ total: 1800, count: 1 }); +}); + +test("toBills: Home keeps late and charged-again bills, drops cancelled and stopped ones", () => { + const bills = toBills([ + item("Power", "monthly", "2026-10-18", 1800), + item("Netflix", "monthly", "2026-10-30", 649, { state: "charged_again" }), + item("Gym", "monthly", "2026-10-28", 1500, { state: "cancelled" }), + item("Spotify", "monthly", "2026-08-02", 119, { possibly_stopped: true }), + item("Far", "monthly", "2026-12-30", 5), + ], today); + expect(bills.map((b) => b.vendor)).toEqual(["Power", "Netflix"]); +}); diff --git a/frontend/e2e/budget.spec.ts b/frontend/e2e/budget.spec.ts index 2afd00b..97a12a6 100644 --- a/frontend/e2e/budget.spec.ts +++ b/frontend/e2e/budget.spec.ts @@ -71,7 +71,7 @@ test("editing a category's budget in place: Enter saves, the row keeps its heigh await input.fill("9000"); await input.press("Enter"); await expect(row(page, "Food")).toContainText("₹9,000"); - expect(puts).toEqual([{ categories: [{ category: "Food", limit: 9000 }] }]); + expect(puts).toEqual([{ month: ym(0), categories: [{ category: "Food", limit: 9000 }] }]); }); test("Esc cancels an edit without saving", async ({ page }) => { @@ -203,3 +203,13 @@ test("budget fits a phone without sideways scroll", async ({ page }) => { .evaluateAll((els) => els.map((e) => [e.getAttribute("aria-label") ?? e.textContent, e.getBoundingClientRect().right] as const)); for (const [what, right] of ends) expect(right, String(what)).toBeLessThanOrEqual(375); }); + +test("every budget request names the browser's month (the server's date can differ around midnight on the 1st)", async ({ page }) => { + const urls: string[] = []; + page.on("request", (r) => r.url().includes("/api/budget") && r.method() === "GET" && urls.push(r.url())); + await openBudget(page); + expect(urls.some((u) => u.includes(`/api/budget?month=${ym(0)}`))).toBe(true); + expect(urls.some((u) => u.includes(`/api/budget/history?months=6&to=${ym(0)}`))).toBe(true); + expect(urls.filter((u) => /\/api\/budget$/.test(u))).toEqual([]); +}); + diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 611308a..ab9f57e 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -104,6 +104,7 @@ export async function signIn( documents.splice(documents.findIndex((d) => d.id === doc[1]), 1); return route.fulfill({ json: { success: true } }); } + if (path.startsWith("/api/files/")) return route.fulfill({ body: "original", contentType: "image/png" }); if (path === "/api/statements/imports") return route.fulfill({ json: { success: true, imports: home.imports ?? [] } }); if (path === "/api/v1/email-config/status") return route.fulfill({ json: home.email ?? { configured: false } }); if (path.startsWith("/api/v1/email-config/")) return route.fulfill({ json: { success: true } }); @@ -136,7 +137,8 @@ export async function signIn( if (path === "/transactions") { const from = url.searchParams.get("date_from") ?? "0000"; const to = url.searchParams.get("date_to") ?? "9999"; - const all = (home.txns ?? []).filter((t) => t.date >= from && t.date <= to).sort((a, b) => b.date.localeCompare(a.date)); + const skip = url.searchParams.get("exclude_source"); + const all = (home.txns ?? []).filter((t) => t.date >= from && t.date <= to && (!skip || t.source !== skip)).sort((a, b) => b.date.localeCompare(a.date)); // like the real API: at most 100 rows per page const size = Math.min(Number(url.searchParams.get("page_size") ?? 10), 100); const page = Number(url.searchParams.get("page") ?? 1); @@ -186,6 +188,8 @@ export async function signIn( return route.fulfill({ status: 409, json: { detail: "Proposal is already rejected" } }); if (proposal) return route.fulfill({ json: { success: true, proposal: { id: proposal[1], status: `${proposal[2]}d` }, applied: null } }); const decided = path.match(/^\/api\/review\/([^/]+)\/(approve|reject)$/); + if (decided && home.replaced?.includes(decided[1])) + return route.fulfill({ status: 409, json: { success: false, error: "This invoice is already recorded", code: "conflict" } }); if (decided) { const item = queue.find((q) => q.id === decided[1])!; queue = queue.filter((q) => q.id !== decided[1]); diff --git a/frontend/e2e/receipts.spec.ts b/frontend/e2e/receipts.spec.ts index 8d40b1a..007dac1 100644 --- a/frontend/e2e/receipts.spec.ts +++ b/frontend/e2e/receipts.spec.ts @@ -172,3 +172,40 @@ test("receipts fits a phone", async ({ page }) => { .evaluateAll((els) => els.map((e) => [e.getAttribute("aria-label") ?? e.textContent, e.getBoundingClientRect().right] as const)); for (const [what, right] of ends) expect(right, String(what)).toBeLessThanOrEqual(375); }); + +test("a big statement import doesn't push the receipts out of the library", async ({ page }) => { + const lines = Array.from({ length: 120 }, (_, i) => ({ id: `s${i}`, vendor_name: `Line ${i}`, date: "2026-10-20", total_amount: 10, category: null, created_at: null, source: "statement" })); + await openReceipts(page, { ...sample, txns: [...lines, ...sample.txns!] }); + await expect(page.getByTestId("library-row").filter({ hasText: "Jio Fiber" })).toHaveCount(1); +}); + +test("a review card and a document open their original", async ({ page }) => { + const opened: string[] = []; + page.on("request", (r) => r.url().includes("/api/files/") && opened.push(r.url().split("/api/files/")[1])); + const review = [flagged("r1", { vendor_name: "Croma", total_amount: 9999, file_key: "inv/croma.png" }, ["total_mismatch"])]; + await openReceipts(page, { ...sample, review, documents: [{ ...sample.documents![0], file_key: "doc/rent.pdf" }] }); + await card(page, "Croma").getByRole("button", { name: "Open original" }).click(); + await page.getByRole("button", { name: "Open Rental agreement" }).click(); + await expect.poll(() => opened).toEqual(["inv/croma.png", "doc/rent.pdf"]); +}); + +test("a decision the server refuses says why", async ({ page }) => { + await openReceipts(page, { ...sample, replaced: ["r1"] }); + await card(page, "Croma").getByRole("button", { name: "Approve", exact: true }).click(); + await expect(page.getByText("This invoice is already recorded")).toBeVisible(); + await expect(card(page, "Croma")).toHaveCount(1); +}); + +test("an edit can't approve an empty or non-numeric total", async ({ page }) => { + await openReceipts(page); + await card(page, "Croma").getByRole("button", { name: "Edit" }).click(); + const total = card(page, "Croma").getByRole("textbox", { name: "Total" }); + const save = card(page, "Croma").getByRole("button", { name: "Save & approve" }); + await total.fill(""); + await expect(save).toBeDisabled(); + await total.fill("1,234"); + await expect(save).toBeDisabled(); + await total.fill("1234.5"); + await expect(save).toBeEnabled(); +}); + diff --git a/frontend/e2e/spending-math.spec.ts b/frontend/e2e/spending-math.spec.ts index cf6ff68..74ffca4 100644 --- a/frontend/e2e/spending-math.spec.ts +++ b/frontend/e2e/spending-math.spec.ts @@ -1,6 +1,6 @@ // Node-only unit tests (no browser) for the Spending page's calculations (UI revamp Phase 5b). import { test, expect } from "@playwright/test"; -import { byCategory, monthlyTotals, needsCategory, spendPeriods, topMerchants } from "../src/lib/spending-math"; +import { byCategory, monthlyTotals, needsCategory, spendPeriods, toCsv, topMerchants } from "../src/lib/spending-math"; const tx = (id: string, vendor_name: string | null, date: string, total_amount: number, category: string | null = "Food") => ({ id, @@ -58,3 +58,12 @@ test("monthlyTotals: oldest first, current month included, empty months are zero test("needsCategory counts rows with no real category", () => { expect(needsCategory([tx("1", "a", "2026-10-02", 1, null), tx("2", "a", "2026-10-02", 1, ""), tx("3", "a", "2026-10-02", 1, "Other"), tx("4", "a", "2026-10-02", 1, "Uncategorised"), tx("5", "a", "2026-10-02", 1, "Food")])).toBe(4); }); + +test("toCsv: text that a spreadsheet would run as a formula is kept as text; amounts stay numbers", () => { + const row = (vendor_name: string, total_amount: number) => ({ id: 1, vendor_name, date: "2026-10-01", total_amount, category: null, created_at: null }); + const csv = toCsv([row("=HYPERLINK(\"http://x\")", 10), row("+91 Store", -400), row("@sum", 5), row("Plain, Co", 1)]).split("\n"); + expect(csv[1]).toBe(`2026-10-01,"'=HYPERLINK(""http://x"")",,Receipt,10`); + expect(csv[2]).toBe("2026-10-01,'+91 Store,,Receipt,-400"); + expect(csv[3]).toBe("2026-10-01,'@sum,,Receipt,5"); + expect(csv[4]).toBe('2026-10-01,"Plain, Co",,Receipt,1'); +}); diff --git a/frontend/src/app/(app)/budget/budget-content.tsx b/frontend/src/app/(app)/budget/budget-content.tsx index 3b3d719..343cd62 100644 --- a/frontend/src/app/(app)/budget/budget-content.tsx +++ b/frontend/src/app/(app)/budget/budget-content.tsx @@ -63,9 +63,9 @@ export default function BudgetContent() { } } - async function save(body: Parameters[0]) { + async function save(body: Omit[0], "month">) { try { - data.setBudget(await budgetApi.put(body)); + data.setBudget(await budgetApi.put({ month: ym(today), ...body })); data.reload(); } catch { toast.error("Couldn't save the budget."); diff --git a/frontend/src/app/(app)/budget/use-budget-data.ts b/frontend/src/app/(app)/budget/use-budget-data.ts index 4731214..6b19957 100644 --- a/frontend/src/app/(app)/budget/use-budget-data.ts +++ b/frontend/src/app/(app)/budget/use-budget-data.ts @@ -32,7 +32,7 @@ export function useBudgetData(month: Date, today: Date): BudgetData { useEffect(() => { let live = true; const end = new Date(month.getFullYear(), month.getMonth() + 1, 0); - Promise.allSettled([budgetApi.get(key === ym(today) ? undefined : key), fetchTxns(month, end < today ? end : today)]).then(([b, t]) => { + Promise.allSettled([budgetApi.get(key), fetchTxns(month, end < today ? end : today)]).then(([b, t]) => { if (live) setShown({ budget: value(b, null), txns: value(t, []) }); }); return () => { @@ -44,21 +44,24 @@ export function useBudgetData(month: Date, today: Date): BudgetData { useEffect(() => { let live = true; - Promise.allSettled([budgetApi.history(6), agentApi.proposals()]).then(([h, p]) => { + Promise.allSettled([budgetApi.history(6, ym(today)), agentApi.proposals()]).then(([h, p]) => { if (live) setOnce({ history: value(h, []), pending: value(p, []).filter((x) => x.type === "set_budget") }); }); return () => { live = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [day]); const setBudget = useCallback((budget: BudgetState) => setShown((s) => (s ? { ...s, budget } : s)), []); const reload = useCallback(() => { - Promise.allSettled([budgetApi.get(), budgetApi.history(6)]).then(([b, h]) => { + Promise.allSettled([budgetApi.get(ym(today)), budgetApi.history(6, ym(today))]).then(([b, h]) => { if (b.status === "fulfilled") setShown((s) => (s ? { ...s, budget: b.value } : s)); if (h.status === "fulfilled") setOnce((o) => (o ? { ...o, history: h.value } : o)); }); - }, []); + // today is fixed for the page's life; its day is what matters + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [day]); return { status: shown && once ? "ready" : "loading", diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts index 94ee77b..1934f9f 100644 --- a/frontend/src/app/(app)/home/use-home-data.ts +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -5,18 +5,7 @@ import { aiAnalyticsApi, billsApi, budgetApi, homeApi, transactionApi, type Budg import { parseDay, 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 }; -// Home's bills: active recurring charges due in the next 31 days (api/bills.py). Cancelled ones and ones that look -// stopped aren't bills any more. -export function toBills(items: RecurringItem[], today: Date): Reminder[] { - const until = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 31); - return items - .filter((r) => r.state === "active" && !r.possibly_stopped) - .filter((r) => { - const due = parseDay(r.next_due_date); - return due >= today && due <= until; - }) - .map((r) => ({ id: r.vendor, vendor: r.vendor, amount: r.typical_amount, date: r.next_due_date, type: "upcoming", category: r.category ?? undefined })); -} +import { toBills } from "@/lib/bills-math"; export type Forecast = { dates: string[]; values: number[] } | null; export type HomeData = { status: "loading" | "ready"; @@ -95,7 +84,7 @@ export function useHomeData(range: Range, today: Date): HomeData { Promise.allSettled([ billsApi.recurring().then((r) => toBills(r.items, today)), homeApi.summary(), - budgetApi.get(), + budgetApi.get(`${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`), ]).then(([reminders, summary, budget]) => { if (live) setExtras({ reminders: value(reminders, []), summary: value(summary, null), budget: value(budget, null) }); }); diff --git a/frontend/src/app/(app)/receipts/_components/library.tsx b/frontend/src/app/(app)/receipts/_components/library.tsx index 7fb156b..176a759 100644 --- a/frontend/src/app/(app)/receipts/_components/library.tsx +++ b/frontend/src/app/(app)/receipts/_components/library.tsx @@ -3,7 +3,8 @@ import { useState } from "react"; import { FileSpreadsheet, FileText, Receipt, Search, Trash2 } from "lucide-react"; import { PageCard } from "@/components/page/page-card"; -import type { LumenDocument, ReviewItem, StatementImport } from "@/lib/api/client"; +import { filesApi, type LumenDocument, type ReviewItem, type StatementImport } from "@/lib/api/client"; +import { toast } from "@/lib/toast"; import { useMoney } from "@/lib/format"; import type { Txn } from "@/lib/home-math"; import { cn } from "@/lib/utils"; @@ -18,6 +19,8 @@ export type LibraryRow = { date: string | null; amount: number | null; review: boolean; + /** the original upload, when one was kept */ + fileKey?: string | null; onDelete?: () => void; }; const CHIPS: { label: string; kind: Kind | null }[] = [ @@ -41,15 +44,16 @@ export function toRows(d: { review: ReviewItem[]; receipts: Txn[]; documents: Lu key: `r-${r.id}`, kind: "receipt" as const, title: String(r.invoice.vendor_name ?? "Unknown vendor"), sub: `Receipt · ${String(r.invoice.invoice_number ?? "no bill number")}`, from: r.invoice.source === "email" ? ("Gmail" as const) : ("Upload" as const), date: r.created_at, amount: typeof r.invoice.total_amount === "number" ? r.invoice.total_amount : null, review: true, + fileKey: typeof r.invoice.file_key === "string" ? r.invoice.file_key : null, })), ...d.receipts.map((t) => ({ key: `t-${t.id}`, kind: "receipt" as const, title: t.vendor_name ?? "Unknown vendor", sub: `Receipt${t.category ? ` · ${t.category}` : ""}`, from: t.source === "email" ? ("Gmail" as const) : ("Upload" as const), - date: t.date, amount: t.total_amount, review: false, + date: t.date, amount: t.total_amount, review: false, fileKey: (t as Txn & { file_key?: string | null }).file_key, })), ...d.documents.map((doc) => ({ key: `d-${doc.id}`, kind: "document" as const, title: doc.title, sub: `${DOC_TYPE[doc.doc_type] ?? "Document"} · ${doc.filename ?? `${doc.chunk_count} sections`}`, - from: "Upload" as const, date: doc.created_at, amount: null, review: false, onDelete: () => remove(doc.id), + from: "Upload" as const, date: doc.created_at, amount: null, review: false, fileKey: doc.file_key, onDelete: () => remove(doc.id), })), ...d.imports.map((i) => ({ key: `i-${i.id}`, kind: "statement" as const, title: i.label ?? i.file_name ?? "Statement", @@ -104,7 +108,18 @@ export function Library({ rows }: { rows: LibraryRow[] }) { - {r.title} + {r.fileKey ? ( + + ) : ( + {r.title} + )} {r.sub} · {r.from} · {dayMonth(r.date)} diff --git a/frontend/src/app/(app)/receipts/_components/review-cards.tsx b/frontend/src/app/(app)/receipts/_components/review-cards.tsx index 4535a55..76e63cc 100644 --- a/frontend/src/app/(app)/receipts/_components/review-cards.tsx +++ b/frontend/src/app/(app)/receipts/_components/review-cards.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { PageCard } from "@/components/page/page-card"; import { VendorChip } from "@/components/page/vendor-chip"; -import { reviewApi, type ReviewItem } from "@/lib/api/client"; +import { filesApi, reviewApi, type ReviewItem } from "@/lib/api/client"; import { eventBus, EVENTS } from "@/lib/events"; import { useMoney } from "@/lib/format"; import { toast } from "@/lib/toast"; @@ -32,7 +32,13 @@ const dayMonth = (s: string | null) => (s ? new Date(s).toLocaleString("en-GB", // the checks' details are terse ("line items 80 + tax 20 != total 110"); the arithmetic one gets a sentence instead const plain = (rule: string, detail: string) => rule === "total_mismatch" ? "The items and tax don't add up to the printed total." : `${detail.charAt(0).toUpperCase()}${detail.slice(1)}.`; -const errorText = (e: unknown) => (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail; +// API errors are {error} (api/errors.py); FastAPI's own validation errors are {detail} +const errorText = (e: unknown) => { + const data = (e as { response?: { data?: { error?: string; detail?: unknown } } })?.response?.data; + return data?.error ?? (typeof data?.detail === "string" ? data.detail : undefined); +}; +// a total someone typed: digits with an optional decimal part, more than zero (no "1,234", no blank) +const validTotal = (v: string) => /^\d+(\.\d+)?$/.test(v.trim()) && Number(v) > 0; // Needs your review (spec 2026-10-22 §4.5; replaces the old Review tab): one card per flagged receipt with the fields // the checks doubted highlighted, the reason in plain words, and Approve / Edit / Reject. A duplicate offers "Keep both" @@ -82,7 +88,7 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str setBusy(true); const changed = Object.fromEntries( Object.entries(edits) - .filter(([k, v]) => v.trim() !== String(inv[k] ?? "")) + .filter(([k, v]) => v.trim() !== "" && v.trim() !== String(inv[k] ?? "")) .map(([k, v]) => [k, k === "total_amount" ? Number(v) : v.trim()]), ); try { @@ -127,6 +133,15 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
    ))} + {typeof inv.file_key === "string" && ( + + )} {first && (

    {REASON[first.rule] ?? "Lumen wasn't sure."} {plain(first.rule, first.detail)} @@ -135,7 +150,12 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str

    {editing ? ( <> -
    diff --git a/frontend/src/app/(app)/receipts/_components/library.tsx b/frontend/src/app/(app)/receipts/_components/library.tsx index 176a759..04096eb 100644 --- a/frontend/src/app/(app)/receipts/_components/library.tsx +++ b/frontend/src/app/(app)/receipts/_components/library.tsx @@ -6,7 +6,7 @@ import { PageCard } from "@/components/page/page-card"; import { filesApi, type LumenDocument, type ReviewItem, type StatementImport } from "@/lib/api/client"; import { toast } from "@/lib/toast"; import { useMoney } from "@/lib/format"; -import type { Txn } from "@/lib/home-math"; +import { parseUtc, type Txn } from "@/lib/home-math"; import { cn } from "@/lib/utils"; type Kind = "receipt" | "document" | "statement"; @@ -31,7 +31,7 @@ const CHIPS: { label: string; kind: Kind | null }[] = [ ]; const ICON = { receipt: Receipt, document: FileText, statement: FileSpreadsheet }; const DOC_TYPE: Record = { purchase_order: "Purchase order", contract: "Contract", policy: "Policy", invoice: "Invoice", other: "Document" }; -const dayMonth = (s: string | null) => (s ? new Date(s.length === 10 ? `${s}T00:00` : s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : "—"); +const dayMonth = (s: string | null) => (s ? parseUtc(s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : "—"); const range = (a: string | null, b: string | null) => (a && b ? `${dayMonth(a)} – ${dayMonth(b)}` : ""); const PAGE = 10; diff --git a/frontend/src/app/(app)/receipts/_components/review-cards.tsx b/frontend/src/app/(app)/receipts/_components/review-cards.tsx index 76e63cc..9b52a1b 100644 --- a/frontend/src/app/(app)/receipts/_components/review-cards.tsx +++ b/frontend/src/app/(app)/receipts/_components/review-cards.tsx @@ -7,6 +7,7 @@ import { filesApi, reviewApi, type ReviewItem } from "@/lib/api/client"; import { eventBus, EVENTS } from "@/lib/events"; import { useMoney } from "@/lib/format"; import { toast } from "@/lib/toast"; +import { parseUtc } from "@/lib/home-math"; import { cn } from "@/lib/utils"; type Field = { key: "vendor_name" | "date" | "invoice_number" | "total_amount"; label: string }; @@ -28,7 +29,7 @@ const REASON: Record = { po_mismatch: "Doesn't match its purchase order.", possible_injection: "Unusual text on the bill.", }; -const dayMonth = (s: string | null) => (s ? new Date(s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : ""); +const dayMonth = (s: string | null) => (s ? parseUtc(s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : ""); // the checks' details are terse ("line items 80 + tax 20 != total 110"); the arithmetic one gets a sentence instead const plain = (rule: string, detail: string) => rule === "total_mismatch" ? "The items and tax don't add up to the printed total." : `${detail.charAt(0).toUpperCase()}${detail.slice(1)}.`; diff --git a/frontend/src/app/(app)/receipts/_components/side-cards.tsx b/frontend/src/app/(app)/receipts/_components/side-cards.tsx index db7feef..35ac221 100644 --- a/frontend/src/app/(app)/receipts/_components/side-cards.tsx +++ b/frontend/src/app/(app)/receipts/_components/side-cards.tsx @@ -7,11 +7,12 @@ import { VendorChip } from "@/components/page/vendor-chip"; import { GmailOAuthButton } from "@/components/gmailOAuthButton"; import { emailConfigApi, type StatementImport } from "@/lib/api/client"; import { toast } from "@/lib/toast"; +import { parseUtc } from "@/lib/home-math"; import type { EmailStatus } from "../use-receipts-data"; -const dayMonth = (s: string | null) => (s ? new Date(s.length === 10 ? `${s}T00:00` : s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : ""); +const dayMonth = (s: string | null) => (s ? parseUtc(s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : ""); const ago = (s: string) => { - const m = Math.round((Date.now() - new Date(s).getTime()) / 60_000); + const m = Math.round((Date.now() - parseUtc(s).getTime()) / 60_000); return m < 1 ? "just now" : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`; }; const PARTS = [ diff --git a/frontend/src/app/(app)/receipts/receipts-content.tsx b/frontend/src/app/(app)/receipts/receipts-content.tsx index 6a6a0c0..6c4a0ca 100644 --- a/frontend/src/app/(app)/receipts/receipts-content.tsx +++ b/frontend/src/app/(app)/receipts/receipts-content.tsx @@ -30,7 +30,9 @@ export default function ReceiptsContent() { } const n = data?.review.length ?? 0; - const brief = !data ? null : n ? `${n} ${n === 1 ? "receipt needs" : "receipts need"} your OK before ${n === 1 ? "it counts" : "they count"}.` : "Everything you've given Lumen is filed."; + const rows = data ? toRows(data, (id) => void remove(id)) : []; + // with the review queue unknown, say only what is known + const brief = !data ? null : data.reviewFailed ? `${rows.length} ${rows.length === 1 ? "file" : "files"} with Lumen.` : n ? `${n} ${n === 1 ? "receipt needs" : "receipts need"} your OK before ${n === 1 ? "it counts" : "they count"}.` : "Everything you've given Lumen is filed."; return (
    @@ -67,7 +69,7 @@ export default function ReceiptsContent() {
    )}
    - void remove(id))} /> +
    void reload()} /> diff --git a/frontend/src/app/(app)/receipts/use-receipts-data.ts b/frontend/src/app/(app)/receipts/use-receipts-data.ts index a66af8f..e9da088 100644 --- a/frontend/src/app/(app)/receipts/use-receipts-data.ts +++ b/frontend/src/app/(app)/receipts/use-receipts-data.ts @@ -15,7 +15,7 @@ export type EmailStatus = { last_error?: string | null; }; -type Data = { review: ReviewItem[]; documents: LumenDocument[]; receipts: Txn[]; imports: StatementImport[]; email: EmailStatus | null }; +type Data = { review: ReviewItem[]; reviewFailed: boolean; documents: LumenDocument[]; receipts: Txn[]; imports: StatementImport[]; email: EmailStatus | null }; const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fulfilled" ? r.value : fallback); // Everything the Receipts & statements page shows, loaded together so the page appears in one step (no layout shift @@ -36,6 +36,7 @@ export function useReceiptsData() { const rows = (value(txns, { data: [] }) as { data?: Txn[] }).data ?? []; setData({ review: value(review, []) ?? [], + reviewFailed: review.status === "rejected", documents: value(documents, []) ?? [], receipts: rows, imports: value(imports, []) ?? [], diff --git a/frontend/src/app/(app)/spending/_components/by-category.tsx b/frontend/src/app/(app)/spending/_components/by-category.tsx index 2dcbd0a..591fc4b 100644 --- a/frontend/src/app/(app)/spending/_components/by-category.tsx +++ b/frontend/src/app/(app)/spending/_components/by-category.tsx @@ -52,8 +52,8 @@ export function ByCategory({ key={r.category} type="button" data-testid="category-row" - aria-pressed={selected === r.category} - onClick={() => onSelect(selected === r.category ? null : r.category)} + aria-pressed={selected?.toLowerCase() === r.category.toLowerCase()} + onClick={() => onSelect(selected?.toLowerCase() === r.category.toLowerCase() ? null : r.category)} className={cn( "-mx-2 grid grid-cols-[minmax(0,140px)_1fr_auto] items-center gap-3 rounded-lg px-2 py-2 text-left text-[13px] transition-colors sm:grid-cols-[minmax(0,160px)_1fr_90px_48px_64px]", selected === r.category ? "bg-primary/8" : "hover:bg-muted/60", @@ -64,7 +64,7 @@ export function ByCategory({ {r.category} - + {money(Math.round(r.amount))} {Math.round(r.share * 100)}% diff --git a/frontend/src/app/(app)/spending/_components/transactions.tsx b/frontend/src/app/(app)/spending/_components/transactions.tsx index f37ed76..cab82ec 100644 --- a/frontend/src/app/(app)/spending/_components/transactions.tsx +++ b/frontend/src/app/(app)/spending/_components/transactions.tsx @@ -8,13 +8,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { transactionApi } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; import { parseDay, type Txn } from "@/lib/home-math"; -import { sourceOf, toCsv } from "@/lib/spending-math"; +import { inCategory, sourceOf, toCsv } from "@/lib/spending-math"; import { toast } from "@/lib/toast"; const PAGE = 25; -const UNSORTED = new Set(["", "Other", "Uncategorised"]); const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" }); -const inCategory = (t: Txn, c: string) => (c === "Uncategorised" ? !t.category || UNSORTED.has(t.category) : t.category === c); // Spending's transactions (spec §4.2): follows the category picked above, plus a search and a source filter. // The category pill edits in place: a menu of known categories, saved with PUT /transactions/. diff --git a/frontend/src/app/(app)/spending/spending-content.tsx b/frontend/src/app/(app)/spending/spending-content.tsx index c242c1a..1397c44 100644 --- a/frontend/src/app/(app)/spending/spending-content.tsx +++ b/frontend/src/app/(app)/spending/spending-content.tsx @@ -68,7 +68,11 @@ export default function SpendingContent() { const cats = byCategory(data.txns, data.prevTxns); const top = [...cats].sort((a, b) => b.change - a.change)[0]; if (delta && delta.abs > 0 && top && top.change > delta.abs * 0.4) { - const biggest = data.txns.filter((x) => (x.category || "Uncategorised") === top.category).sort((a, b) => (b.total_amount ?? 0) - (a.total_amount ?? 0))[0]; + // name a payment only if it explains the rise: inside it ("one ₹9,999 payment" can't be most of a ₹2,000 + // rise) and at least half of it (a ₹1 payment explains nothing) + const biggest = data.txns + .filter((x) => (x.category || "Uncategorised") === top.category && (x.total_amount ?? 0) <= top.change && (x.total_amount ?? 0) >= top.change / 2) + .sort((a, b) => (b.total_amount ?? 0) - (a.total_amount ?? 0))[0]; return `${top.category} is most of the jump${biggest?.vendor_name ? `, and one ${biggest.vendor_name} payment is ${money(biggest.total_amount ?? 0)} of it` : ""}.`; } return `You've spent ${money(Math.round(total))} in ${p.label}${preset === "this-month" ? " so far" : ""}.`; diff --git a/frontend/src/lib/bills-math.ts b/frontend/src/lib/bills-math.ts index 5f5b667..f0f5266 100644 --- a/frontend/src/lib/bills-math.ts +++ b/frontend/src/lib/bills-math.ts @@ -76,3 +76,20 @@ export function tiles(items: RecurringItem[], today: Date) { stopped: { count: stopped.length, perMonth: Math.round(stopped.reduce((s, i) => s + monthlyEquivalent(i), 0)) }, }; } + +// The calendar's bills for a month: an item shows from its first known charge on (estimated back from the last one by +// its cadence and count), and one that's cancelled or looks stopped only up to the month of its last charge. +export function billsOnCalendar(items: RecurringItem[], year: number, month: number): Map { + const out = new Map(); + const shown = year * 12 + month; + for (const i of items) { + const last = parseDay(i.last_charge_date); + const back = i.charges_count - 1; + const first = + i.cadence === "weekly" ? addDays(last, -7 * back) : i.cadence === "yearly" ? new Date(last.getFullYear() - back, last.getMonth(), 1) : new Date(last.getFullYear(), last.getMonth() - back, 1); + const ended = i.state === "cancelled" || i.possibly_stopped; + if (shown < first.getFullYear() * 12 + first.getMonth() || (ended && shown > last.getFullYear() * 12 + last.getMonth())) continue; + for (const d of dueDays(i, year, month)) out.set(d, [...(out.get(d) ?? []), i]); + } + return out; +} diff --git a/frontend/src/lib/home-math.ts b/frontend/src/lib/home-math.ts index c4d674f..2a0c051 100644 --- a/frontend/src/lib/home-math.ts +++ b/frontend/src/lib/home-math.ts @@ -152,3 +152,10 @@ export function vendorChip(name: string): { initials: string; hue: number } { for (const ch of name.toLowerCase()) hash = (hash * 31 + ch.charCodeAt(0)) % 360; return { initials, hue: hash }; } + +// The backend sends naive UTC timestamps ("2026-10-22T10:00:00"): read them as UTC, not local time. Plain dates stay +// local calendar days; a timestamp that names its zone is read as it says. +export function parseUtc(s: string): Date { + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return parseDay(s); + return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : `${s}Z`); +} diff --git a/frontend/src/lib/spending-math.ts b/frontend/src/lib/spending-math.ts index be95ef2..9e15efc 100644 --- a/frontend/src/lib/spending-math.ts +++ b/frontend/src/lib/spending-math.ts @@ -87,3 +87,12 @@ const cell = (v: string | number) => { // CSV of exactly the rows shown (spec 2026-10-22 §4.2): date, merchant, category, source, amount. export const toCsv = (rows: Txn[]) => ["date,merchant,category,source,amount", ...rows.map((t) => [t.date, t.vendor_name ?? "", t.category ?? "", sourceOf(t), t.total_amount ?? 0].map(cell).join(","))].join("\n"); + +const SORT_LATER = new Set(["", "other", "uncategorised"]); +// Whether a row belongs to a category, ignoring case and spaces: links (Home's spike, ?category=) carry the most common +// spelling, rows may use another. "Uncategorised" also covers blank and "Other". +export const inCategory = (t: Txn, c: string) => { + const mine = (t.category ?? "").trim().toLowerCase(); + const want = c.trim().toLowerCase(); + return want === "uncategorised" ? SORT_LATER.has(mine) : mine === want; +}; From 80dc90c4c0f442a0baefc329e2799f3fd8c84867 Mon Sep 17 00:00:00 2001 From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:57:56 +0530 Subject: [PATCH 47/47] docs: tick the Phase 5 review minors in the backlog --- .../specs/2026-10-20-ui-revamp-design.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 9491a80..f6cf1f5 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -247,14 +247,14 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **Later.** Draft lines could show a small month-by-month glyph once `basis` carries per-month amounts. *(5d)* - [ ] **Later.** Receipts: tell receipts from invoices in the data so the library can have both chips; "Compare" on a duplicate needs the review item to carry the record it duplicates. *(5e)* - [ ] **Later.** Receipts library lists the latest 100 receipts; page through `/transactions` if people need older ones there. *(5e)* -- [ ] **Phase 6.** Phase 5 review minors: refunds (negative spend) draw a full bar instead of an empty one (budget hero/categories, Home budget card, Spending by category) — clamp at 0. *(5 review)* -- [ ] **Phase 6.** Bills calendar projects a monthly bill into months before its first charge, and hides cancelled bills in months they really were charged. Project from `last_charge_date` on; show real charges for past months. *(5 review)* -- [ ] **Phase 6.** Naive UTC timestamps (`created_at`, `last_successful_poll`) are parsed as local time: "Checked 6 h ago" right after a poll in IST, late uploads dated a day off. Send them with `Z`. *(5 review)* -- [ ] **Phase 6.** Spending brief can name a payment bigger than the category's change ("one ₹9,999 payment is most of a ₹2,000 jump"); check the size first. *(5 review)* -- [ ] **Phase 6.** Receipts brief says "Everything … is filed" when the review list failed to load; say nothing instead. *(5 review)* -- [ ] **Phase 6.** Small layout shifts: "Connect email" joins the header after load (wraps on phones); Budget hero/History taller than their skeletons on phones; a pending draft narrows By category one frame after load on lg. *(5 review)* -- [ ] **Phase 6.** Home's spike link `?category=` is matched case-sensitively on Spending ("food" rows missed under "Food"). *(5 review)* -- [ ] **Phase 6.** Import page currency starts as USD until the profile loads; an INR user who doesn't notice imports in USD. *(5 review; predates 5e)* -- [ ] **Phase 6.** If the budget-versions migration is skipped on Postgres (only a warning), edits to an existing category in a new month hit the old unique constraint and 500. Make that failure loud. *(5 review; migration smoke-tested on Postgres 16 locally)* +- [x] **Phase 6.** Phase 5 review minors: refunds (negative spend) draw a full bar instead of an empty one (budget hero/categories, Home budget card, Spending by category) — clamp at 0. *(5 review)* Done: bars clamp at 0. +- [x] **Phase 6.** Bills calendar projects a monthly bill into months before its first charge, and hides cancelled bills in months they really were charged. Project from `last_charge_date` on; show real charges for past months. *(5 review)* Done: shown from the first known charge, and a cancelled/stopped one up to its last; real past charges still not drawn. +- [x] **Phase 6.** Naive UTC timestamps (`created_at`, `last_successful_poll`) are parsed as local time: "Checked 6 h ago" right after a poll in IST, late uploads dated a day off. Send them with `Z`. *(5 review)* Done client-side (`parseUtc`). +- [x] **Phase 6.** Spending brief can name a payment bigger than the category's change ("one ₹9,999 payment is most of a ₹2,000 jump"); check the size first. *(5 review)* Done: named only when it is between half and all of the rise. +- [x] **Phase 6.** Receipts brief says "Everything … is filed" when the review list failed to load; say nothing instead. *(5 review)* Done: says how many files instead. +- [ ] **Phase 6.** Small layout shifts: "Connect email" joins the header after load (wraps on phones); Budget hero/History taller than their skeletons on phones; a pending draft narrows By category one frame after load on lg. *(5 review)* Not reproduced: phone CLS tests for Budget and Receipts pass (< 0.01); kept as regression tests. +- [x] **Phase 6.** Home's spike link `?category=` is matched case-sensitively on Spending ("food" rows missed under "Food"). *(5 review)* Done: matched ignoring case. +- [ ] **Phase 6.** Import page currency starts as USD until the profile loads; an INR user who doesn't notice imports in USD. *(5 review; predates 5e)* Not reproduced: a delayed-profile test starts in INR; kept as a regression test. +- [x] **Phase 6.** If the budget-versions migration is skipped on Postgres (only a warning), edits to an existing category in a new month hit the old unique constraint and 500. Make that failure loud. *(5 review; migration smoke-tested on Postgres 16 locally)* Done: logged as an error naming the consequence. - [ ] **Later.** Spending: the category edit (pencil) is hidden below `sm`, so phones can't recategorise; and "Lumen noticed" wraps beside its pill at 375px. *(5b)* - [ ] **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)*