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