diff --git a/backend/ai/home_signals.py b/backend/ai/home_signals.py
index cc5e49b..32f0857 100644
--- a/backend/ai/home_signals.py
+++ b/backend/ai/home_signals.py
@@ -9,6 +9,7 @@
import json
from collections import defaultdict
from datetime import date, datetime, timedelta
+from urllib.parse import quote
from ai.budget import _display_names, _key
from ai.recurring import _norm
@@ -90,7 +91,8 @@ def _spikes(txns: list[dict], today: date) -> list[dict]:
pct = round((n - p) / p * 100)
out.append((pct, {"kind": "spike", "tag": "SPENDING", "subject": names[k], "headline": f"{names[k]} is up {pct}%",
"detail": "{0} so far vs {1} by this point last month.",
- "amounts": [round(n), round(p)], "worth": None, "href": "/spending"}))
+ "amounts": [round(n), round(p)], "worth": None,
+ "href": f"/spending?category={quote(names[k])}"}))
return [s for _, s in sorted(out, key=lambda x: -x[0])]
diff --git a/backend/routes/database_query.py b/backend/routes/database_query.py
index 115b353..1c9170a 100644
--- a/backend/routes/database_query.py
+++ b/backend/routes/database_query.py
@@ -92,6 +92,7 @@ def get_transactions(user_id=None):
'currency': txn.currency,
'po_number': txn.po_number,
'file_key': txn.file_key,
+ 'source': txn.source, # "statement" for imported rows, None for receipts
'created_at': txn.created_at.isoformat() if txn.created_at else None,
'items': [
{
diff --git a/backend/tests/test_home_signals.py b/backend/tests/test_home_signals.py
index ba31449..0124935 100644
--- a/backend/tests/test_home_signals.py
+++ b/backend/tests/test_home_signals.py
@@ -37,7 +37,7 @@ def test_spike_needs_30pct_and_floor():
[s] = find_signals(big, TODAY, 0, [])
assert s == {"kind": "spike", "tag": "SPENDING", "subject": "Food", "headline": "Food is up 35%",
"detail": "{0} so far vs {1} by this point last month.", "amounts": [6750, 5000], "worth": None,
- "href": "/spending"}
+ "href": "/spending?category=Food"}
def test_spike_only_counts_days_up_to_today():
@@ -128,3 +128,10 @@ def test_found_items_carry_their_subject():
t("2026-09-05", 5000, "S", "food"), t("2026-10-05", 6750, "S", "food")]
assert [(i["kind"], i["subject"]) for i in find_signals(txns, TODAY, 2, rec)] == [
("duplicate", "Croma"), ("review", None), ("spike", "food"), ("price_rise", "Spotify")]
+
+
+def test_spike_links_to_spending_filtered_to_its_category():
+ # Spending opens filtered to the category (UI revamp Phase 5b); names are URL-encoded
+ big = [t("2026-09-05", 5000, "X", "Food & Dining"), t("2026-10-05", 6750, "X", "Food & Dining")]
+ [s] = find_signals(big, TODAY, 0, [])
+ assert s["href"] == "/spending?category=Food%20%26%20Dining"
diff --git a/backend/tests/test_invoice_upload.py b/backend/tests/test_invoice_upload.py
index 3baf5f3..3690199 100644
--- a/backend/tests/test_invoice_upload.py
+++ b/backend/tests/test_invoice_upload.py
@@ -605,3 +605,19 @@ def test_the_original_upload_is_kept_and_linked_from_the_transaction(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["file_key"] == key
+
+
+def test_the_transaction_list_says_where_each_row_came_from(client, ocr):
+ # Spending's table shows whether a row came from a receipt or a statement import (UI revamp Phase 5b)
+ from models import Transaction, db
+
+ body = _upload(client, _png_bytes()).get_json()
+ listed = client.get("/transactions", headers=AUTH).get_json()
+ [tx] = [t for t in listed["data"] if t["id"] == body["transaction_id"]]
+ assert tx["source"] is None
+ with client.application.app_context():
+ db.session.get(Transaction, body["transaction_id"]).source = "statement"
+ db.session.commit()
+ 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"
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/plans/2026-10-22-ui-revamp-phase5.md b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md
index 6143227..b8d91ab 100644
--- a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md
+++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md
@@ -190,8 +190,53 @@ Branch: `ui-revamp/phase5-pages` (contains the spec commit; stacked on `ui-revam
---
-## 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 5b — Spending (detailed)
+
+Branch: `ui-revamp/phase5b-spending`, stacked on `ui-revamp/phase5-pages` (#97). Mockup: `docs/superpowers/specs/2026-10-22-phase5/spending.html`; spec §4.2.
+
+**Decided here:** everything is computed client-side from `GET /transactions` (date-filtered, paged like Home's `fetchTxns`) plus `GET /api/home/summary` findings. The only backend change is `source` in the transactions list. The "first time at a merchant" card is dropped (spec §4.2). "Always file X here" is omitted (no rule store). The old `/analytics` and `/ai-analytics` pages and their components go once nothing imports them (both URLs already redirect to `/spending`).
+
+### Task B1: transactions list says where each row came from
+- Modify `backend/routes/database_query.py` (list response adds `'source': txn.source`); test in the existing transactions route tests (`grep -l "/transactions" backend/tests`): a statement-imported row lists `source == "statement"`, a receipt row `None`.
+- Commit `feat: transactions list includes source`.
+
+### Task B2: spending maths (pure)
+- Create `frontend/src/lib/spending-math.ts`, tests `frontend/e2e/spending-math.spec.ts`.
+- Produces:
+ - `type Choice = "this-month" | "last-month" | "3M" | "YTD" | { from: Date; to: Date }`
+ - `spendPeriods(choice: Choice, today: Date): { current: Period; previous: Period; label: string; prevLabel: string }`: previous is the same length immediately before; last-month = the whole previous calendar month vs the one before; custom = inclusive dates.
+ - `byCategory(cur: Txn[], prev: Txn[]): { category: string; amount: number; share: number; change: number }[]`: sorted by amount desc; null/"" → "Uncategorised"; share of the period total; change = amount − prev amount.
+ - `topMerchants(txns: Txn[], n: number): { vendor: string; amount: number; count: number; category: string | null }[]`
+ - `monthlyTotals(txns: Txn[], today: Date, months: number): { month: Date; total: number }[]`: oldest first, includes the current month.
+ - `needsCategory(txns: Txn[]): number`: category null, "", "Other" or "Uncategorised".
+- Tests: one per function with exact numbers (e.g. 3 categories incl. a null one → shares summing to 1, change vs prev; last-month in January → December vs November; YTD previous = same span last year).
+
+### Task B3: Spending page shell, data and period control
+- Replace `frontend/src/app/(app)/spending/page.tsx` (single `` via `PageHeader`); create `spending/spending-content.tsx`, `spending/use-spending-data.ts` (period + previous txns, last 6 months for the bars, summary findings; skeletons only on first load, like Home).
+- Period control: segmented This month / Last month / 3M / YTD / Custom (Custom shows two native ` `), a "Compare with " switch (hides the previous line and change columns when off), and the date range text.
+- Brief: " is most of the change, and is of it." only when the change is up and the top category explains > 40% of it; otherwise "You've spent so far in ." Real numbers only.
+- Tests (`frontend/e2e/spending.spec.ts`, fixture `home.txns`): one `` "Spending"; switching period chips keeps the hero card's box (bounding-box test); Custom shows two date inputs; old content gone ("Spending Analytics", "AI Insights" not present).
+
+### Task B4: hero card
+- Extract Home's chart body into `frontend/src/components/page/cumulative-chart.tsx` (`CumulativeChart({ period, previous, cur, prev, forecast?, pins?, today, showPrevious })`), used by both `SpendCard` and the Spending hero, so Home's tests stay green unchanged.
+- Spending hero: "Spent in ", total, change vs previous (same format as Home), cumulative chart; right inset "Month by month" bars for the last 6 months with the average; for 3M/YTD the bars replace the line as the main chart.
+- Tests: total and delta text; 6 bars with the current month highlighted; 3M shows bars as the main chart.
+
+### Task B5: By category + Lumen noticed
+- By category card: horizontal bars (amount, share, change vs previous when comparing); clicking a row sets `?category=` and the filter pill "Filtering transactions: X" with Clear; footer "N transactions still need a category → Sort them now" (scrolls to the table filtered to Uncategorised) only when N > 0.
+- Lumen noticed: `LumenFound` with a `title` prop ("Lumen noticed"), same rows and actions.
+- Tests: clicking Shopping filters the table to Shopping rows and the URL has `category=Shopping`; Clear restores; footer count matches uncategorised rows.
+
+### Task B6: Transactions table, Top merchants, Export
+- Table: search box (vendor substring, client-side), category pill filter, source filter (All / Statement / Receipt), columns date, merchant (`VendorChip`), category pill, source, amount; 25 rows then "Show more". Hover/focus shows a pencil; the menu lists known categories and saves via `transactionApi.updateTransaction(id, { category })`, updating the row in place (no row height change).
+- Top merchants: top 7 with count and category.
+- Export: the quiet icon button downloads a CSV of the current filtered rows (client-side `Blob`), filename `lumen-spending--.csv`.
+- Tests: search narrows rows; editing a category sends the PUT and the pill changes without the row's box changing; CSV download has a header row and the filtered rows.
+
+### Task B7: remove the old pages, docs, checks, PR
+- Delete `app/(app)/analytics`, `app/(app)/ai-analytics`, and `components/analytics/*`, `components/ai-analytics/*` that nothing else imports (grep first).
+- 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.
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)*
diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts
index 226b215..b1a2a23 100644
--- a/frontend/e2e/fixtures.ts
+++ b/frontend/e2e/fixtures.ts
@@ -38,7 +38,8 @@ export async function signIn(
page: Page,
opts: { reviewCount?: number; home?: HomeFixture; chat?: ChatFixture } = {},
): Promise {
- const home = opts.home ?? {};
+ // a copy: routes like the transactions PUT change it, and tests share their fixture objects
+ const home: HomeFixture = structuredClone(opts.home ?? {});
// 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;
@@ -72,6 +73,14 @@ 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));
+ // category edits from Spending's table (stateful, like the real PUT)
+ const edit = path.match(/^\/transactions\/([^/]+)$/);
+ if (edit && route.request().method() === "PUT") {
+ const row = (home.txns ?? []).find((t) => String(t.id) === edit[1]);
+ if (!row) return route.fulfill({ status: 404, json: { success: false } });
+ Object.assign(row, route.request().postDataJSON());
+ return route.fulfill({ json: { success: true, data: row } });
+ }
if (path === "/transactions") {
const from = url.searchParams.get("date_from") ?? "0000";
const to = url.searchParams.get("date_to") ?? "9999";
diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts
index f0c2c16..75656f9 100644
--- a/frontend/e2e/shell.spec.ts
+++ b/frontend/e2e/shell.spec.ts
@@ -268,7 +268,7 @@ test("spending and bills show no cards that can never load", async ({ 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();
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText("Spending");
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");
diff --git a/frontend/e2e/spending-math.spec.ts b/frontend/e2e/spending-math.spec.ts
new file mode 100644
index 0000000..cf6ff68
--- /dev/null
+++ b/frontend/e2e/spending-math.spec.ts
@@ -0,0 +1,60 @@
+// 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";
+
+const tx = (id: string, vendor_name: string | null, date: string, total_amount: number, category: string | null = "Food") => ({
+ id,
+ vendor_name,
+ date,
+ total_amount,
+ category,
+ created_at: null,
+});
+const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+
+test("spendPeriods: this month, last month, 3M, YTD and custom", () => {
+ const today = new Date(2026, 9, 22); // 22 Oct 2026
+ const m = spendPeriods("this-month", today);
+ expect([ymd(m.current.start), ymd(m.current.end), ymd(m.previous.start), ymd(m.previous.end)]).toEqual(["2026-10-01", "2026-10-31", "2026-09-01", "2026-09-30"]);
+ expect([m.label, m.prevLabel]).toEqual(["October", "September"]);
+ const last = spendPeriods("last-month", today);
+ expect([ymd(last.current.start), ymd(last.current.end), ymd(last.previous.start)]).toEqual(["2026-09-01", "2026-09-30", "2026-08-01"]);
+ // January's last month is December, compared with November
+ const jan = spendPeriods("last-month", new Date(2027, 0, 10));
+ expect([ymd(jan.current.start), ymd(jan.previous.start), jan.label, jan.prevLabel]).toEqual(["2026-12-01", "2026-11-01", "December", "November"]);
+ const ytd = spendPeriods("YTD", today);
+ expect([ymd(ytd.current.start), ymd(ytd.previous.start), ytd.label, ytd.prevLabel]).toEqual(["2026-01-01", "2025-01-01", "2026", "2025"]);
+ expect(spendPeriods("3M", today).label).toBe("Aug – Oct");
+ // custom: inclusive, previous is the same number of days just before
+ const c = spendPeriods({ from: new Date(2026, 9, 5), to: new Date(2026, 9, 11) }, today);
+ expect([c.current.days, ymd(c.previous.start), ymd(c.previous.end)]).toEqual([7, "2026-09-28", "2026-10-04"]);
+});
+
+test("byCategory: amount, share and change, with uncategorised rows named", () => {
+ const cur = [tx("1", "Swiggy", "2026-10-02", 600, "Food"), tx("2", "Croma", "2026-10-03", 300, "Shopping"), tx("3", "X", "2026-10-04", 100, null)];
+ const prev = [tx("4", "Swiggy", "2026-09-02", 400, "Food")];
+ expect(byCategory(cur, prev)).toEqual([
+ { category: "Food", amount: 600, share: 0.6, change: 200 },
+ { category: "Shopping", amount: 300, share: 0.3, change: 300 },
+ { category: "Uncategorised", amount: 100, share: 0.1, change: 100 },
+ ]);
+ expect(byCategory([], [])).toEqual([]);
+});
+
+test("topMerchants: by amount, with visit count", () => {
+ const t = [tx("1", "Swiggy", "2026-10-02", 300), tx("2", "Swiggy", "2026-10-05", 200), tx("3", "Croma", "2026-10-03", 9999, "Shopping"), tx("4", null, "2026-10-04", 50)];
+ expect(topMerchants(t, 2)).toEqual([
+ { vendor: "Croma", amount: 9999, count: 1, category: "Shopping" },
+ { vendor: "Swiggy", amount: 500, count: 2, category: "Food" },
+ ]);
+});
+
+test("monthlyTotals: oldest first, current month included, empty months are zero", () => {
+ const t = [tx("1", "a", "2026-10-02", 100), tx("2", "b", "2026-08-15", 50), tx("3", "c", "2026-08-01", 25), tx("4", "d", "2026-04-01", 999)];
+ const out = monthlyTotals(t, new Date(2026, 9, 22), 3);
+ expect(out.map((m) => [ymd(m.month), m.total])).toEqual([["2026-08-01", 75], ["2026-09-01", 0], ["2026-10-01", 100]]);
+});
+
+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);
+});
diff --git a/frontend/e2e/spending.spec.ts b/frontend/e2e/spending.spec.ts
new file mode 100644
index 0000000..b1d4345
--- /dev/null
+++ b/frontend/e2e/spending.spec.ts
@@ -0,0 +1,193 @@
+import { test, expect, type Page } from "@playwright/test";
+import { signIn, type HomeFixture } from "./fixtures";
+
+// Dates relative to the real "today" so the period maths sees them in this / last month.
+const now = new Date();
+const Y = now.getFullYear();
+const M = now.getMonth();
+const ymd = (y: number, m: number, d: number) => {
+ const x = new Date(y, m, d);
+ return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}`;
+};
+const thisMonth = (d: number) => ymd(Y, M, Math.min(d, now.getDate()));
+const lastMonth = (d: number) => ymd(Y, M - 1, d);
+const tx = (id: string, vendor_name: string, date: string, total_amount: number, category: string | null = "Food", source: string | null = null) => ({
+ id, vendor_name, date, total_amount, category, created_at: null, source,
+});
+
+export const sample: HomeFixture = {
+ txns: [
+ tx("a", "Swiggy", thisMonth(1), 600, "Food"),
+ tx("b", "Croma", thisMonth(1), 9999, "Shopping", "statement"),
+ tx("c", "Amazon", thisMonth(1), 1499, "Shopping"),
+ tx("d", "Mystery", thisMonth(1), 250, null),
+ tx("e", "Swiggy", lastMonth(3), 400, "Food"),
+ tx("f", "Rent", lastMonth(2), 25000, "Rent"),
+ ],
+};
+
+export async function openSpending(page: Page, home: HomeFixture = sample, url = "/spending") {
+ await signIn(page, { home });
+ await page.goto(url);
+}
+const hero = (page: Page) => page.getByTestId("spending-hero");
+
+test("spending is one page with one heading", async ({ page }) => {
+ await openSpending(page);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText("Spending");
+ await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1);
+ for (const old of ["Spending Analytics", "AI Analytics", "Spending Comparison"]) await expect(page.getByText(old, { exact: true })).toHaveCount(0);
+});
+
+test("period choices keep the hero's box", async ({ page }) => {
+ await openSpending(page);
+ await expect(hero(page)).toContainText("₹12,348");
+ const before = (await hero(page).boundingBox())!;
+ for (const name of ["Last month", "3M", "YTD", "This month"]) {
+ await page.getByRole("button", { name, exact: true }).click();
+ await expect(page.getByRole("button", { name, exact: true })).toHaveAttribute("aria-pressed", "true");
+ const box = (await hero(page).boundingBox())!;
+ expect(Math.abs(box.height - before.height), name).toBeLessThanOrEqual(1);
+ expect(Math.abs(box.y - before.y), name).toBeLessThanOrEqual(1);
+ }
+ await page.getByRole("button", { name: "Last month", exact: true }).click();
+ await expect(hero(page)).toContainText("₹25,400");
+});
+
+test("custom period shows two date inputs", async ({ page }) => {
+ await openSpending(page);
+ await page.getByRole("button", { name: "Custom", exact: true }).click();
+ await expect(page.getByLabel("From", { exact: true })).toHaveAttribute("type", "date");
+ await expect(page.getByLabel("To", { exact: true })).toHaveAttribute("type", "date");
+});
+
+test("the brief uses real numbers", async ({ page }) => {
+ await openSpending(page);
+ await expect(page.getByTestId("brief")).toContainText("₹12,348");
+});
+
+test("hero: total, change, the running line and six months of bars", async ({ page }) => {
+ await openSpending(page);
+ await expect(hero(page)).toContainText("₹12,348");
+ await expect(hero(page).locator('[data-series="current"]')).toHaveCount(1);
+ await expect(hero(page).locator('[data-series="previous"]')).toHaveCount(1);
+ const bars = hero(page).getByTestId("month-bar");
+ await expect(bars).toHaveCount(6);
+ await expect(bars.last()).toHaveAttribute("data-current", "true");
+ // turning comparison off hides the previous line and the change
+ await page.getByRole("switch").click();
+ await expect(hero(page).locator('[data-series="previous"]')).toHaveCount(0);
+ await expect(page.getByTestId("spending-delta")).toHaveCount(0);
+});
+
+test("3M and YTD show the months as the main chart", async ({ page }) => {
+ await openSpending(page);
+ await page.getByRole("button", { name: "3M", exact: true }).click();
+ await expect(hero(page).getByTestId("month-bars-main")).toBeVisible();
+ await expect(hero(page).locator('[data-series="current"]')).toHaveCount(0);
+});
+
+test("by category: bars, filter by click, clear", async ({ page }) => {
+ await openSpending(page);
+ const card = page.getByTestId("by-category");
+ const rows = card.getByTestId("category-row");
+ await expect(rows).toHaveCount(3);
+ await expect(rows.first()).toContainText("Shopping");
+ await expect(rows.first()).toContainText("₹11,498");
+ await rows.first().click();
+ await expect(page).toHaveURL(/category=Shopping/);
+ await expect(card).toContainText("Filtering transactions: Shopping");
+ await expect(rows.first()).toHaveAttribute("aria-pressed", "true");
+ await card.getByRole("button", { name: "Clear" }).click();
+ await expect(page).not.toHaveURL(/category=/);
+ await expect(card).toContainText("1 transaction still needs a category");
+});
+
+test("lumen noticed uses the same findings as Home", async ({ page }) => {
+ const spike = { kind: "spike", tag: "SPENDING", subject: "Shopping", headline: "Shopping is up 35%", detail: "{0} so far.", amounts: [11498], worth: null, href: "/spending" };
+ await openSpending(page, { ...sample, summary: { found: [spike] } });
+ const card = page.getByTestId("lumen-found");
+ await expect(card).toContainText("Lumen noticed");
+ await expect(card.getByTestId("found-row")).toContainText("Shopping is up 35%");
+ await expect(card.getByRole("link", { name: "Ask Lumen" })).toHaveAttribute("href", /^\/ask\?draft=/);
+});
+
+const table = (page: Page) => page.getByTestId("transactions");
+const rowsOf = (page: Page) => table(page).getByTestId("txn-row");
+
+test("the table follows the category filter, search and source", async ({ page }) => {
+ await openSpending(page);
+ await expect(rowsOf(page)).toHaveCount(4);
+ await page.getByTestId("category-row").first().click(); // Shopping
+ await expect(rowsOf(page)).toHaveCount(2);
+ await page.getByTestId("by-category").getByRole("button", { name: "Clear" }).click();
+ await page.getByRole("button", { name: "Sort them now" }).click();
+ await expect(rowsOf(page)).toHaveCount(1);
+ await expect(rowsOf(page)).toContainText("Mystery");
+ await page.getByTestId("by-category").getByRole("button", { name: "Clear" }).click();
+ await table(page).getByRole("searchbox").fill("swi");
+ await expect(rowsOf(page)).toHaveCount(1);
+ await expect(rowsOf(page)).toContainText("Swiggy");
+ await table(page).getByRole("searchbox").fill("");
+ await table(page).getByRole("combobox", { name: "Source" }).selectOption("statement");
+ await expect(rowsOf(page)).toHaveCount(1);
+ await expect(rowsOf(page)).toContainText("Croma");
+ await expect(rowsOf(page)).toContainText("Statement");
+});
+
+test("a category can be changed in place", async ({ page }) => {
+ const puts: unknown[] = [];
+ page.on("request", (r) => r.method() === "PUT" && r.url().includes("/transactions/") && puts.push(r.postDataJSON()));
+ await openSpending(page);
+ const row = rowsOf(page).filter({ hasText: "Mystery" });
+ const before = (await row.boundingBox())!;
+ await row.getByRole("button", { name: "Change category for Mystery" }).click();
+ await page.getByRole("menuitem", { name: "Food" }).click();
+ await expect(row.getByTestId("category-pill")).toHaveText("Food");
+ expect(puts).toEqual([{ category: "Food" }]);
+ const after = (await row.boundingBox())!;
+ expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1);
+ await expect(page.getByTestId("by-category")).not.toContainText("still need");
+});
+
+test("top merchants and CSV export of what's shown", async ({ page }) => {
+ await openSpending(page);
+ await expect(page.getByTestId("top-merchants").getByTestId("merchant-row").first()).toContainText("Croma");
+ await page.getByTestId("category-row").first().click(); // Shopping: Croma + Amazon
+ await expect(rowsOf(page)).toHaveCount(2);
+ const download = page.waitForEvent("download");
+ await page.getByRole("button", { name: "Export CSV" }).click();
+ const file = await download;
+ expect(file.suggestedFilename()).toMatch(/^lumen-spending-\d{4}-\d{2}-\d{2}-\d{4}-\d{2}-\d{2}\.csv$/);
+ const text = await (await import("node:fs/promises")).readFile((await file.path())!, "utf8");
+ const lines = text.trim().split(/\r?\n/);
+ expect(lines[0]).toBe("date,merchant,category,source,amount");
+ expect(lines).toHaveLength(3);
+ 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);
+});
+
+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)/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) => (
- handleTimeRangeChange(range)}
- className="rounded-full px-4"
- >
- {range[0].toUpperCase()}
- {range.slice(1)}
-
- ))}
-
-
-
- navigatePeriod("prev")}
- >
-
-
-
- {timeRange === "yearly" && (
-
- handlePeriodChange("year", value)
- }
- >
-
-
-
-
- {generateYears().map((year) => (
-
- {year}
-
- ))}
-
-
- )}
-
- {timeRange === "monthly" && (
- <>
-
- handlePeriodChange("month", value)
- }
- >
-
-
-
-
- {months.map((month, idx) => (
-
- {month}
-
- ))}
-
-
-
- handlePeriodChange("year", value)
- }
- >
-
-
-
-
- {generateYears().map((year) => (
-
- {year}
-
- ))}
-
-
- >
- )}
-
- {timeRange === "weekly" && (
- <>
-
- handlePeriodChange("week", value)
- }
- >
-
-
-
-
- {generateWeeks().map((week) => (
-
- Week {week}
-
- ))}
-
-
-
- handlePeriodChange("year", value)
- }
- >
-
-
-
-
- {generateYears().map((year) => (
-
- {year}
-
- ))}
-
-
- >
- )}
-
- navigatePeriod("next")}
- >
-
-
-
-
- );
-
- return (
-
-
- {getPeriodLabel()}
-
- }
- >
-
-
-
- );
-}
diff --git a/frontend/src/app/(app)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx
index 5feae32..d8ab839 100644
--- a/frontend/src/app/(app)/home/_components/lumen-found.tsx
+++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx
@@ -19,13 +19,13 @@ export const askAbout = (item: FoundItem, money: (n: number) => string) =>
// "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 }) {
+export function LumenFound({ summary, loading, title = "Lumen found" }: { summary: HomeSummary | null; loading: boolean; title?: string }) {
const money = useMoney();
const found = summary?.found ?? [];
const total = found.length + (summary?.more ?? 0);
return (
0 && (
diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx
index fda1882..2743b8e 100644
--- a/frontend/src/app/(app)/home/_components/spend-card.tsx
+++ b/frontend/src/app/(app)/home/_components/spend-card.tsx
@@ -1,20 +1,17 @@
"use client";
import { useEffect, useState } from "react";
+import { CumulativeChart } from "@/components/page/cumulative-chart";
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, ordinal, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math";
import { cn } from "@/lib/utils";
import type { Forecast, Reminder } from "../use-home-data";
const RANGES: Range[] = ["1W", "1M", "3M", "YTD"];
-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)}`;
@@ -45,20 +42,6 @@ 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({
@@ -87,7 +70,6 @@ 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);
@@ -108,46 +90,12 @@ export function SpendCard({
if (!matched) fc.length = 0;
}
const empty = total === 0 && !prev.some((v) => v > 0);
- const max = Math.max(total, prev[prev.length - 1] ?? 0, fc[fc.length - 1] ?? 0, 1);
- const x = (i: number) => (period.days > 1 ? (i / (period.days - 1)) * W : 0);
- const y = (v: number) => H - (v / max) * (H - 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;
const i = dayIndex(period, parseDay(r.date));
return i > t && i < period.days;
});
- // Today always shows; the two ends unless they'd touch it; the dates in between only where they fit. 1W/1M label
- // those by day number, 3M by day and month, YTD by the 1st of each month. Widths are estimated (12.5px UI font).
- const px = (i: number) => (x(i) / W) * width;
- const at = (i: number) => new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i);
- const last = period.days - 1;
- const middle = (i: number) =>
- range === "YTD" ? at(i).getDate() === 1 : i % 7 === 0;
- const ticks = pickTicks(
- Array.from({ length: period.days }, (_, i) => i)
- .filter((i) => i === 0 || i === t || i === last || middle(i))
- .map((i) => {
- const label =
- i === t ? "Today"
- : i === 0 || i === last || range === "3M" ? dayMonth(at(i))
- : range === "YTD" ? at(i).toLocaleString("en-GB", { month: "short" })
- : String(at(i).getDate());
- return {
- i,
- label,
- x: px(i),
- w: label.length * 7 + 2,
- anchor: (i === 0 ? "start" : i === last ? "end" : "middle") as "start" | "end" | "middle",
- rank: i === t ? 0 : i === 0 || i === last ? 1 : 2,
- };
- }),
- 8,
- );
-
const legend = (
@@ -216,63 +164,15 @@ export function SpendCard({
) : (
- <>
-
-
-
-
-
-
-
-
- {[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}
-
- ))}
-
- >
+
)}
{RANGES.map((r) => (
diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts
index ef52d75..6dbf240 100644
--- a/frontend/src/app/(app)/home/use-home-data.ts
+++ b/frontend/src/app/(app)/home/use-home-data.ts
@@ -47,7 +47,7 @@ const ymd = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
// The API returns at most 100 rows a page, so follow has_next; a period's totals need every row.
// ponytail: client-side paging, capped at 50 pages (5,000 rows); Phase 3's summary endpoint sums on the server.
-async function fetchTxns(from: Date, to: Date): Promise
{
+export async function fetchTxns(from: Date, to: Date): Promise {
const rows: Txn[] = [];
for (let page = 1; page <= 50; page++) {
const r: { data?: Txn[]; pagination?: { has_next?: boolean } } = await transactionApi.getTransactions({
diff --git a/frontend/src/app/(app)/spending/_components/by-category.tsx b/frontend/src/app/(app)/spending/_components/by-category.tsx
new file mode 100644
index 0000000..2dcbd0a
--- /dev/null
+++ b/frontend/src/app/(app)/spending/_components/by-category.tsx
@@ -0,0 +1,88 @@
+import { PageCard } from "@/components/page/page-card";
+import { useMoney } from "@/lib/format";
+import { cn } from "@/lib/utils";
+
+// Tints for the bars, in order of spend; the rest share the last one.
+const TINTS = ["#2f6fe0", "#c4572f", "#2e8b57", "#b7791f", "#7fa8ee", "#8b6fd6", "#a8a69f"];
+
+// Where the money went (spec 2026-10-22 §4.2): each category's spend, share and change; a click filters the
+// transactions below to it. The footer only appears when some rows still have no real category.
+export function ByCategory({
+ rows,
+ compare,
+ selected,
+ onSelect,
+ needsCategory,
+ loading,
+}: {
+ rows: { category: string; amount: number; share: number; change: number }[];
+ compare: boolean;
+ selected: string | null;
+ onSelect: (category: string | null) => void;
+ needsCategory: number;
+ loading: boolean;
+}) {
+ const money = useMoney();
+ const max = Math.max(...rows.map((r) => r.amount), 1);
+ return (
+ Filtering transactions: {selected}
+ )
+ }
+ action={
+ selected && (
+ onSelect(null)} className="label-ui text-muted-foreground hover:text-foreground">
+ Clear
+
+ )
+ }
+ loading={loading}
+ skeletonHeight={260}
+ data-testid="by-category"
+ >
+ {rows.length === 0 ? (
+ Nothing spent in this period.
+ ) : (
+
+ {rows.map((r, i) => (
+ onSelect(selected === r.category ? 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",
+ )}
+ >
+
+
+ {r.category}
+
+
+
+
+ {money(Math.round(r.amount))}
+ {Math.round(r.share * 100)}%
+ 0 ? "text-bad" : r.change < 0 ? "text-good" : "text-muted-foreground")}>
+ {Math.round(r.change) === 0 ? "same" : `${r.change > 0 ? "+" : "−"}${money(Math.abs(Math.round(r.change)))}`}
+
+
+ ))}
+
+ )}
+ {needsCategory > 0 && (
+
+ {needsCategory} {needsCategory === 1 ? "transaction still needs" : "transactions still need"} a category
+ onSelect("Uncategorised")} className="ml-auto font-medium text-primary hover:underline">
+ Sort them now
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/app/(app)/spending/_components/hero.tsx b/frontend/src/app/(app)/spending/_components/hero.tsx
new file mode 100644
index 0000000..182f34b
--- /dev/null
+++ b/frontend/src/app/(app)/spending/_components/hero.tsx
@@ -0,0 +1,104 @@
+import { CumulativeChart } from "@/components/page/cumulative-chart";
+import { PageCard } from "@/components/page/page-card";
+import { useMoney } from "@/lib/format";
+import type { Period } from "@/lib/home-math";
+import { cn } from "@/lib/utils";
+
+type Month = { month: Date; total: number };
+const short = (d: Date) => d.toLocaleString("en-US", { month: "short" });
+
+// Monthly totals as bars, the current month in the accent. `tall` is the main chart for 3M/YTD (the same 232px as the
+// line it replaces, so switching never moves anything); otherwise the small inset.
+function MonthBars({ months, today, tall, testId }: { months: Month[]; today: Date; tall?: boolean; testId?: string }) {
+ const money = useMoney();
+ const max = Math.max(...months.map((m) => m.total), 1);
+ return (
+
+ {months.map((m) => {
+ const current = m.month.getFullYear() === today.getFullYear() && m.month.getMonth() === today.getMonth();
+ return (
+
+ {tall &&
{money(Math.round(m.total))} }
+
+
{short(m.month)}
+
+ );
+ })}
+
+ );
+}
+
+// Spending's hero (spec 2026-10-22 §4.2): the period's total and how it compares, the running total as a line (or, for
+// 3M/YTD, month bars), and the last six months beside it.
+export function SpendingHero({
+ label,
+ prevLabel,
+ total,
+ delta,
+ period,
+ cur,
+ prev,
+ t,
+ months,
+ mainMonths,
+ today,
+ loading,
+}: {
+ label: string;
+ prevLabel: string;
+ total: number;
+ delta: { abs: number; pct: number } | null;
+ period: Period;
+ cur: number[];
+ /** null when comparison is off */
+ prev: number[] | null;
+ t: number;
+ /** the last six calendar months */
+ months: Month[];
+ /** set for 3M/YTD: the period's months, shown as the main chart */
+ mainMonths: Month[] | null;
+ today: Date;
+ loading: boolean;
+}) {
+ const money = useMoney();
+ const average = months.length ? months.slice(0, -1).reduce((s, m) => s + m.total, 0) / Math.max(1, months.length - 1) : 0;
+ return (
+
+
+
+
{money(Math.round(total))}
+
+ {delta && Math.round(delta.abs) !== 0 && (
+
+
+ {delta.abs < 0 ? "−" : "+"}
+ {money(Math.abs(Math.round(delta.abs)))} ({Math.abs(Math.round(delta.pct))}%)
+ {" "}
+ vs {prevLabel}
+
+ )}
+
+ {mainMonths ? (
+
+ ) : (
+
62 ? "dayMonth" : "day"} />
+ )}
+
+
+
Month by month
+
+ {money(Math.round(average))} avg
+
+
The five months before this one
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/spending/_components/top-merchants.tsx b/frontend/src/app/(app)/spending/_components/top-merchants.tsx
new file mode 100644
index 0000000..dec6eaa
--- /dev/null
+++ b/frontend/src/app/(app)/spending/_components/top-merchants.tsx
@@ -0,0 +1,29 @@
+import { PageCard } from "@/components/page/page-card";
+import { VendorChip } from "@/components/page/vendor-chip";
+import { useMoney } from "@/lib/format";
+
+// Who the money went to this period, biggest first.
+export function TopMerchants({ rows, loading }: { rows: { vendor: string; amount: number; count: number; category: string | null }[]; loading: boolean }) {
+ const money = useMoney();
+ return (
+
+ {rows.length === 0 ? (
+ No merchants in this period.
+ ) : (
+ rows.map((r) => (
+
+
+
+ {r.vendor}
+
+ {r.count} {r.count === 1 ? "payment" : "payments"}
+ {r.category ? ` · ${r.category}` : ""}
+
+
+ {money(Math.round(r.amount))}
+
+ ))
+ )}
+
+ );
+}
diff --git a/frontend/src/app/(app)/spending/_components/transactions.tsx b/frontend/src/app/(app)/spending/_components/transactions.tsx
new file mode 100644
index 0000000..9bad21e
--- /dev/null
+++ b/frontend/src/app/(app)/spending/_components/transactions.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import { useState } from "react";
+import { Download, Pencil, Search } from "lucide-react";
+import { PageCard } from "@/components/page/page-card";
+import { VendorChip } from "@/components/page/vendor-chip";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { transactionApi } from "@/lib/api/client";
+import { useMoney } from "@/lib/format";
+import { parseDay, type Txn } from "@/lib/home-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 sourceOf = (t: Txn) => (t.source === "statement" ? "Statement" : "Receipt");
+const inCategory = (t: Txn, c: string) => (c === "Uncategorised" ? !t.category || UNSORTED.has(t.category) : t.category === c);
+
+// CSV of exactly the rows shown (spec 2026-10-22 §4.2): date, merchant, category, source, amount.
+const cell = (v: string | number) => (/[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v));
+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");
+
+// 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/.
+export function Transactions({
+ txns,
+ category,
+ categories,
+ onChanged,
+ file,
+ loading,
+}: {
+ txns: Txn[];
+ category: string | null;
+ /** what the edit menu offers */
+ categories: string[];
+ onChanged: (id: Txn["id"], category: string) => void;
+ /** the CSV's name, without the extension */
+ file: string;
+ loading: boolean;
+}) {
+ const money = useMoney();
+ const [search, setSearch] = useState("");
+ const [source, setSource] = useState<"all" | "statement" | "receipt">("all");
+ const [shown, setShown] = useState(PAGE);
+ const q = search.trim().toLowerCase();
+ const rows = [...txns]
+ .filter((t) => !category || inCategory(t, category))
+ .filter((t) => !q || (t.vendor_name ?? "").toLowerCase().includes(q))
+ .filter((t) => source === "all" || (source === "statement") === (t.source === "statement"))
+ .sort((a, b) => b.date.localeCompare(a.date));
+
+ async function change(t: Txn, next: string) {
+ try {
+ await transactionApi.updateTransaction(String(t.id), { category: next });
+ onChanged(t.id, next);
+ } catch {
+ toast.error("Couldn't change that category.");
+ }
+ }
+
+ function exportCsv() {
+ const url = URL.createObjectURL(new Blob([toCsv(rows)], { type: "text/csv" }));
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${file}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ return (
+ {rows.length} {category ? `in ${category}` : "in this period"}}
+ action={
+
+
+
+ }
+ loading={loading}
+ skeletonHeight={420}
+ data-testid="transactions"
+ >
+
+
+
+ setSearch(e.target.value)} placeholder="Search merchant" className="min-w-0 flex-1 bg-transparent outline-none" />
+
+ setSource(e.target.value as typeof source)} className="rounded-lg border border-border bg-card px-2.5 py-1.5 text-[13px]">
+ All sources
+ Statements
+ Receipts
+
+
+ {rows.length === 0 ? (
+ No transactions match.
+ ) : (
+
+ {rows.slice(0, shown).map((t) => (
+
+
{dayMonth(t.date)}
+
+
+ {t.vendor_name ?? "Unknown"}
+
+
+
+ {t.category || "Uncategorised"}
+
+
+
+
+
+
+ {categories.map((c) => (
+ void change(t, c)}>
+ {c}
+
+ ))}
+
+
+
+
{sourceOf(t)}
+
{money(t.total_amount ?? 0)}
+
+ ))}
+ {rows.length > shown && (
+
setShown((n) => n + PAGE)} className="label-ui mt-3 text-primary hover:underline">
+ Show more ({rows.length - shown})
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/app/(app)/spending/page.tsx b/frontend/src/app/(app)/spending/page.tsx
index 8e0b98a..80dec86 100644
--- a/frontend/src/app/(app)/spending/page.tsx
+++ b/frontend/src/app/(app)/spending/page.tsx
@@ -1,15 +1,8 @@
import type { Metadata } from "next";
-import AnalyticsContent from "../analytics/analyticsContent";
-import AIAnalyticsContent from "../ai-analytics/aiAnalyticsContent";
+import SpendingContent from "./spending-content";
export const metadata: Metadata = { title: "Spending" };
-// Interim (UI revamp Phase 1): the old Analytics and AI Insights pages stacked until Phase 5 redesigns Spending.
export default function Page() {
- return (
- <>
-
-
- >
- );
+ return ;
}
diff --git a/frontend/src/app/(app)/spending/spending-content.tsx b/frontend/src/app/(app)/spending/spending-content.tsx
new file mode 100644
index 0000000..c242c1a
--- /dev/null
+++ b/frontend/src/app/(app)/spending/spending-content.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { PageHeader } from "@/components/page/page-header";
+import { useMoney } from "@/lib/format";
+import { cumulative, dayIndex, sameDayDelta } from "@/lib/home-math";
+import { byCategory, monthlyTotals, needsCategory, spendPeriods, topMerchants, type Choice } from "@/lib/spending-math";
+import { cn } from "@/lib/utils";
+import { Brief } from "../home/_components/brief";
+import { LumenFound } from "../home/_components/lumen-found";
+import { ByCategory } from "./_components/by-category";
+import { TopMerchants } from "./_components/top-merchants";
+import { Transactions } from "./_components/transactions";
+import { SpendingHero } from "./_components/hero";
+import { useSpendingData } from "./use-spending-data";
+
+const CHOICES: { label: string; value: Exclude }[] = [
+ { label: "This month", value: "this-month" },
+ { label: "Last month", value: "last-month" },
+ { label: "3M", value: "3M" },
+ { label: "YTD", value: "YTD" },
+];
+const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+const fromInput = (s: string) => {
+ const [y, m, d] = s.split("-").map(Number);
+ return new Date(y, m - 1, d);
+};
+const dayMonth = (d: Date) => d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric" });
+
+// Spending (UI revamp Phase 5b, spec 2026-10-22 §4.2): where did my money go, and is anything off?
+export default function SpendingContent() {
+ const money = useMoney();
+ const today = useMemo(() => {
+ const n = new Date();
+ return new Date(n.getFullYear(), n.getMonth(), n.getDate());
+ }, []);
+ const [preset, setPreset] = useState | "custom">("this-month");
+ const [custom, setCustom] = useState({ from: ymd(new Date(today.getFullYear(), today.getMonth(), 1)), to: ymd(today) });
+ const [compare, setCompare] = useState(true);
+ const choice: Choice = preset === "custom" ? { from: fromInput(custom.from), to: fromInput(custom.to) } : preset;
+ const p = spendPeriods(choice, today);
+ const data = useSpendingData(choice, today);
+ // the category filter lives in the URL (?category=), so it survives a reload and can be linked to
+ const router = useRouter();
+ const pathname = usePathname();
+ const params = useSearchParams();
+ const category = params.get("category");
+ const setCategory = (c: string | null) => {
+ const next = new URLSearchParams(params.toString());
+ if (c) next.set("category", c);
+ else next.delete("category");
+ const q = next.toString();
+ router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
+ };
+
+ const t = Math.min(Math.max(dayIndex(p.current, today), 0), p.current.days - 1);
+ // the edit menu offers every category the user already has, plus a few common ones
+ const knownCategories = [...new Set([...data.recent, ...data.txns].map((x) => x.category).filter((c): c is string => !!c && c !== "Other" && c !== "Uncategorised").concat(["Food", "Groceries", "Shopping", "Travel", "Bills & utilities", "Rent", "Health", "Entertainment"]))].sort();
+ const cur = cumulative(data.txns, p.current);
+ const prev = cumulative(data.prevTxns, p.previous);
+ const total = cur[t] ?? 0;
+ const delta = sameDayDelta(cur, prev, t);
+
+ // "Shopping is most of the jump, and one Croma order is ₹9,999 of it." only when it's true; otherwise the total.
+ const brief = (() => {
+ if (data.status === "loading") return null;
+ 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];
+ 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" : ""}.`;
+ })();
+
+ return (
+
+
+
+
}>
+
+
+ {[...CHOICES, { label: "Custom", value: "custom" as const }].map((c) => (
+ setPreset(c.value)}
+ className={cn(
+ "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",
+ )}
+ >
+ {c.label}
+
+ ))}
+
+ {preset === "custom" && (
+
+
+ From
+ e.target.value && setCustom((c) => ({ ...c, from: e.target.value }))} className="rounded-md border border-border bg-card px-2 py-1" />
+
+
+ To
+ e.target.value && setCustom((c) => ({ ...c, to: e.target.value }))} className="rounded-md border border-border bg-card px-2 py-1" />
+
+
+ )}
+
setCompare((v) => !v)}
+ className="label-ui flex items-center gap-2 text-foreground/80"
+ >
+
+
+
+ Compare with {p.prevLabel}
+
+
+ {dayMonth(p.current.start)} – {dayMonth(p.current.end < today ? p.current.end : today)}
+
+
+
+
+
+
+
+
+
+ data.patch(id, { category: c })}
+ file={`lumen-spending-${ymd(p.current.start)}-${ymd(p.current.end < today ? p.current.end : today)}`}
+ loading={data.status === "loading"}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/spending/use-spending-data.ts b/frontend/src/app/(app)/spending/use-spending-data.ts
new file mode 100644
index 0000000..cfadd4f
--- /dev/null
+++ b/frontend/src/app/(app)/spending/use-spending-data.ts
@@ -0,0 +1,75 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { homeApi, type HomeSummary } from "@/lib/api/client";
+import type { Txn } from "@/lib/home-math";
+import { spendPeriods, type Choice } from "@/lib/spending-math";
+import { fetchTxns } from "../home/use-home-data";
+
+export type SpendingData = {
+ status: "loading" | "ready";
+ txns: Txn[];
+ prevTxns: Txn[];
+ /** the last six calendar months, for the month-by-month bars */
+ recent: Txn[];
+ summary: HomeSummary | null;
+ /** swap one row in place after its category is edited */
+ patch: (id: Txn["id"], change: Partial) => void;
+};
+
+const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fulfilled" ? r.value : fallback);
+
+// Spending's data, from endpoints that exist today (spec 2026-10-22 §4.2). Like Home: the period's rows reload when the
+// choice changes but the old numbers stay until the new ones arrive, so a click never blanks the page; the six-month
+// history and the findings load once. A failed call empties only its card.
+export function useSpendingData(choice: Choice, today: Date): SpendingData {
+ const [rows, setRows] = useState<{ txns: Txn[]; prevTxns: Txn[] } | null>(null);
+ const [recent, setRecent] = useState(null);
+ const [summary, setSummary] = useState(null);
+ const key = typeof choice === "object" ? `${choice.from.toDateString()}-${choice.to.toDateString()}` : choice;
+ const day = today.toDateString();
+
+ useEffect(() => {
+ let live = true;
+ const { current, previous } = spendPeriods(choice, today);
+ Promise.allSettled([fetchTxns(current.start, current.end), fetchTxns(previous.start, previous.end)]).then(([cur, prev]) => {
+ if (live) setRows({ txns: value(cur, []), prevTxns: value(prev, []) });
+ });
+ return () => {
+ live = false;
+ };
+ // choice is a fresh object for custom ranges; its key is what matters
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, day]);
+
+ useEffect(() => {
+ let live = true;
+ Promise.allSettled([
+ fetchTxns(new Date(today.getFullYear(), today.getMonth() - 5, 1), new Date(today.getFullYear(), today.getMonth() + 1, 0)),
+ homeApi.summary(),
+ ]).then(([six, found]) => {
+ if (!live) return;
+ setRecent(value(six, []));
+ setSummary(value(found, null));
+ });
+ return () => {
+ live = false;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [day]);
+
+ const patch = useCallback((id: Txn["id"], change: Partial) => {
+ const swap = (list: Txn[]) => list.map((t) => (t.id === id ? { ...t, ...change } : t));
+ setRows((r) => (r ? { txns: swap(r.txns), prevTxns: r.prevTxns } : r));
+ setRecent((r) => (r ? swap(r) : r));
+ }, []);
+
+ return {
+ status: rows && recent ? "ready" : "loading",
+ txns: rows?.txns ?? [],
+ prevTxns: rows?.prevTxns ?? [],
+ recent: recent ?? [],
+ summary,
+ patch,
+ };
+}
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 (
-
-
-
-
-
-
- {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/components/page/cumulative-chart.tsx b/frontend/src/components/page/cumulative-chart.tsx
new file mode 100644
index 0000000..2bcda8e
--- /dev/null
+++ b/frontend/src/components/page/cumulative-chart.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { pickTicks, smoothPath } from "@/lib/chart";
+import { dayIndex, parseDay, type Period } from "@/lib/home-math";
+import { cn } from "@/lib/utils";
+
+const W = 1000;
+const H = 200;
+const dayMonth = (d: Date) => d.toLocaleString("en-GB", { day: "numeric", month: "short" });
+
+// 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 chart 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;
+}
+
+// A period's running total as a smooth line (spec 2026-10-22 §3), with the previous period dashed behind it, an
+// optional forecast dotted on from today, and bills pinned on the axis. Used by Home's hero and Spending's.
+// `ticks`: "day" labels the dates in between by day number (a week or a month), "dayMonth" by day and month (a few
+// months), "month" by each month's 1st (a year). Always 232px tall (chart and axis) whatever the data.
+export function CumulativeChart({
+ period,
+ cur,
+ prev,
+ forecast = [],
+ t,
+ pins = [],
+ ticks: style,
+}: {
+ period: Period;
+ cur: number[];
+ /** the previous period's running total, or null to leave it out */
+ prev: number[] | null;
+ /** running totals for the days after today */
+ forecast?: number[];
+ /** today's index in the period (already clamped to it) */
+ t: number;
+ pins?: { id: string | number; vendor: string; date: string }[];
+ ticks: "day" | "dayMonth" | "month";
+}) {
+ const [axis, width] = useWidth();
+ const fc = forecast;
+ const total = cur[t] ?? 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 - 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));
+
+ // Today always shows; the two ends unless they'd touch it; the dates in between only where they fit. Widths are
+ // estimated (12.5px UI font).
+ const px = (i: number) => (x(i) / W) * width;
+ const at = (i: number) => new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i);
+ const last = period.days - 1;
+ const middle = (i: number) => (style === "month" ? at(i).getDate() === 1 : i % 7 === 0);
+ const ticks = pickTicks(
+ Array.from({ length: period.days }, (_, i) => i)
+ .filter((i) => i === 0 || i === t || i === last || middle(i))
+ .map((i) => {
+ const label =
+ i === t ? "Today"
+ : i === 0 || i === last || style === "dayMonth" ? dayMonth(at(i))
+ : style === "month" ? at(i).toLocaleString("en-GB", { month: "short" })
+ : String(at(i).getDate());
+ return {
+ i,
+ label,
+ x: px(i),
+ w: label.length * 7 + 2,
+ anchor: (i === 0 ? "start" : i === last ? "end" : "middle") as "start" | "end" | "middle",
+ rank: i === t ? 0 : i === 0 || i === last ? 1 : 2,
+ };
+ }),
+ 8,
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {[0.25, 0.5, 0.75].map((f) => (
+
+ ))}
+ {prev && 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}
+
+ ))}
+
+ >
+ );
+}
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 => {
diff --git a/frontend/src/lib/home-math.ts b/frontend/src/lib/home-math.ts
index 313f42b..c4d674f 100644
--- a/frontend/src/lib/home-math.ts
+++ b/frontend/src/lib/home-math.ts
@@ -8,6 +8,8 @@ export type Txn = {
total_amount: number | null;
category: string | null;
created_at: string | null;
+ /** "statement" for imported rows; null/absent for receipts */
+ source?: string | null;
};
export type Range = "1W" | "1M" | "3M" | "YTD";
export type Period = { start: Date; end: Date; days: number }; // inclusive, local midnight
@@ -21,7 +23,7 @@ export const parseDay = (s: string) => {
// round() absorbs the 23h/25h days around daylight-saving changes
export const dayIndex = (p: Period, day: Date) => Math.round((day.getTime() - p.start.getTime()) / DAY);
-const period = (start: Date, end: Date): Period => ({ start, end, days: Math.round((end.getTime() - start.getTime()) / DAY) + 1 });
+export const period = (start: Date, end: Date): Period => ({ start, end, days: Math.round((end.getTime() - start.getTime()) / DAY) + 1 });
const months = (y: number, m: number, n: number) => period(new Date(y, m, 1), new Date(y, m + n, 0));
export function periods(range: Range, today: Date): { current: Period; previous: Period } {
diff --git a/frontend/src/lib/spending-math.ts b/frontend/src/lib/spending-math.ts
new file mode 100644
index 0000000..52cdf7b
--- /dev/null
+++ b/frontend/src/lib/spending-math.ts
@@ -0,0 +1,76 @@
+// Pure calculations behind the Spending page (UI revamp Phase 5b). No React, no fetching; unit tests in
+// e2e/spending-math.spec.ts. Built on the same Period/Txn shapes as Home (lib/home-math.ts).
+import { period, periods, type Period, type Txn } from "@/lib/home-math";
+
+export type Choice = "this-month" | "last-month" | "3M" | "YTD" | { from: Date; to: Date };
+
+const longMonth = (d: Date) => d.toLocaleString("en-US", { month: "long" });
+const shortMonth = (d: Date) => d.toLocaleString("en-US", { month: "short" });
+const dayMonth = (d: Date) => d.toLocaleString("en-GB", { day: "numeric", month: "short" });
+const UNCATEGORISED = "Uncategorised";
+
+// The period a choice covers and the one it's compared with (the same length, immediately before), with labels.
+export function spendPeriods(choice: Choice, today: Date): { current: Period; previous: Period; label: string; prevLabel: string } {
+ const y = today.getFullYear();
+ const m = today.getMonth();
+ if (typeof choice === "object") {
+ const current = period(choice.from, choice.to);
+ const prevEnd = new Date(choice.from.getFullYear(), choice.from.getMonth(), choice.from.getDate() - 1);
+ const previous = period(new Date(prevEnd.getFullYear(), prevEnd.getMonth(), prevEnd.getDate() - current.days + 1), prevEnd);
+ return { current, previous, label: `${dayMonth(current.start)} – ${dayMonth(current.end)}`, prevLabel: "the days before" };
+ }
+ if (choice === "last-month") {
+ const current = period(new Date(y, m - 1, 1), new Date(y, m, 0));
+ const previous = period(new Date(y, m - 2, 1), new Date(y, m - 1, 0));
+ return { current, previous, label: longMonth(current.start), prevLabel: longMonth(previous.start) };
+ }
+ const p = periods(choice === "this-month" ? "1M" : choice, today);
+ if (choice === "YTD") return { ...p, label: String(y), prevLabel: String(y - 1) };
+ if (choice === "3M")
+ return { ...p, label: `${shortMonth(p.current.start)} – ${shortMonth(p.current.end)}`, prevLabel: `${shortMonth(p.previous.start)} – ${shortMonth(p.previous.end)}` };
+ return { ...p, label: longMonth(p.current.start), prevLabel: longMonth(p.previous.start) };
+}
+
+const sumBy = (txns: Txn[], key: (t: Txn) => string) => {
+ const out = new Map();
+ for (const t of txns) if (t.total_amount != null) out.set(key(t), (out.get(key(t)) ?? 0) + t.total_amount);
+ return out;
+};
+const categoryOf = (t: Txn) => t.category || UNCATEGORISED;
+
+// Each category's spend this period, its share of the total and the change against the previous period.
+export function byCategory(cur: Txn[], prev: Txn[]): { category: string; amount: number; share: number; change: number }[] {
+ const now = sumBy(cur, categoryOf);
+ const before = sumBy(prev, categoryOf);
+ const total = [...now.values()].reduce((a, b) => a + b, 0);
+ return [...now.entries()]
+ .map(([category, amount]) => ({ category, amount, share: total ? amount / total : 0, change: amount - (before.get(category) ?? 0) }))
+ .sort((a, b) => b.amount - a.amount);
+}
+
+export function topMerchants(txns: Txn[], n: number): { vendor: string; amount: number; count: number; category: string | null }[] {
+ const out = new Map();
+ for (const t of txns) {
+ if (!t.vendor_name || t.total_amount == null) continue;
+ const row = out.get(t.vendor_name) ?? { vendor: t.vendor_name, amount: 0, count: 0, category: t.category };
+ row.amount += t.total_amount;
+ row.count += 1;
+ out.set(t.vendor_name, row);
+ }
+ return [...out.values()].sort((a, b) => b.amount - a.amount).slice(0, n);
+}
+
+// The last `months` calendar months, oldest first, ending with the current one.
+export function monthlyTotals(txns: Txn[], today: Date, months: number): { month: Date; total: number }[] {
+ const out = Array.from({ length: months }, (_, i) => ({ month: new Date(today.getFullYear(), today.getMonth() - months + 1 + i, 1), total: 0 }));
+ for (const t of txns) {
+ if (t.total_amount == null || !t.date) continue;
+ const [yy, mm] = t.date.split("-").map(Number);
+ const row = out.find((o) => o.month.getFullYear() === yy && o.month.getMonth() === mm - 1);
+ if (row) row.total += t.total_amount;
+ }
+ return out;
+}
+
+export const needsCategory = (txns: Txn[]) =>
+ txns.filter((t) => !t.category || t.category === "Other" || t.category === UNCATEGORISED).length;