Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/ai/home_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])]


Expand Down
1 change: 1 addition & 0 deletions backend/routes/database_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': [
{
Expand Down
9 changes: 8 additions & 1 deletion backend/tests/test_home_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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"
16 changes: 16 additions & 0 deletions backend/tests/test_invoice_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
9 changes: 9 additions & 0 deletions docs/CODEBASE-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`, 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=<name>`). 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 <Month> (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,
Expand Down
17 changes: 17 additions & 0 deletions docs/direction/STORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

49 changes: 47 additions & 2 deletions docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<h1>`, 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 `<h1>` 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 `<input type="date">`), a "Compare with <prev>" switch (hides the previous line and change columns when off), and the date range text.
- Brief: "<Top category> is most of the change, and <largest single txn vendor> is <money> of it." only when the change is up and the top category explains > 40% of it; otherwise "You've spent <total> so far in <period>." Real numbers only.
- Tests (`frontend/e2e/spending.spec.ts`, fixture `home.txns`): one `<h1>` "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 <label>", 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-<from>-<to>.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 `<h1>`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 <vendor>?`). STORY 34.
Expand Down
3 changes: 2 additions & 1 deletion docs/superpowers/specs/2026-10-20-ui-revamp-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<h1>`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 `<h1>`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)*
Expand All @@ -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)*
11 changes: 10 additions & 1 deletion frontend/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export async function signIn(
page: Page,
opts: { reviewCount?: number; home?: HomeFixture; chat?: ChatFixture } = {},
): Promise<void> {
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;
Expand Down Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading