diff --git a/QUICK_START.md b/QUICK_START.md
index 5ae047f..15247cb 100644
--- a/QUICK_START.md
+++ b/QUICK_START.md
@@ -70,7 +70,7 @@ alembic upgrade head
```
```bash
-python app.py
+python -m uvicorn asgi:app --port 5000
```
API at http://localhost:5000
diff --git a/backend/agent/graph.py b/backend/agent/graph.py
index 518c2b5..a4ea8f2 100644
--- a/backend/agent/graph.py
+++ b/backend/agent/graph.py
@@ -53,6 +53,10 @@ class AgentState(TypedDict):
_ASKS_USER = re.compile(r"(would you|do you (want|need|wish)|shall i|want me|would it help|is there anything|"
r"anything else|let me know|how about i|you could|you can|tell me (which|if|whether)|if you('d| would) like|"
r"feel free)\b", re.IGNORECASE)
+# A step the user does themselves ("Connect your Gmail…") isn't a request to Lumen either: as a chip it was sent to the
+# chat (owner report, 2026-10-22). Uploads are the exception: the UI turns an "Upload…" chip into the upload button.
+_USER_STEP = re.compile(r"(connect|go to|log ?in|sign ?in|visit|click|tap|open (the|your)|check your|forward)\b",
+ re.IGNORECASE)
def split_follow_up(text: str) -> tuple[str, str | None]:
@@ -65,7 +69,7 @@ def split_follow_up(text: str) -> tuple[str, str | None]:
answer, follow_up = (text[:m.start()] + text[m.end():]).strip(), m.group(1).strip(" \t*_\"'“”‘’")
if not follow_up: # a bare "Next:"
return answer, None
- if _ASKS_USER.match(follow_up):
+ if _ASKS_USER.match(follow_up) or _USER_STEP.match(follow_up):
return f"{answer}\n\n{follow_up}", None
return answer, follow_up
diff --git a/backend/agent/prompts.py b/backend/agent/prompts.py
index 975fe71..9bef0bb 100644
--- a/backend/agent/prompts.py
+++ b/backend/agent/prompts.py
@@ -51,7 +51,7 @@
- Write naturally, like a knowledgeable friend: lead with the answer, then a sentence or two of useful context.
Use a short list or table only when it helps.
- If you need to ask the user something, ask it in the answer itself. Then end with one short request the user
- might send next, in their own words (e.g. "Show last month by category"), never a question to them, on its own
+ might send next, in their own words (e.g. "Show last month by category"), never a question to them or a step they must do in the app, on its own
last line, starting "Next: "."""
FORCE_ANSWER = ("You have used the maximum number of tool calls. Answer now from the information above, "
diff --git a/backend/ai/budget.py b/backend/ai/budget.py
index 7192861..5ff264f 100644
--- a/backend/ai/budget.py
+++ b/backend/ai/budget.py
@@ -55,8 +55,8 @@ def _round_up(avg: float) -> int:
def draft_budget(txns: list[dict], today: date) -> list[dict]:
- """One line per category: the mean over the 3 complete months before today's (only those with any spend),
- rounded up.
+ """One line per category: the mean over the 3 complete months before today's (only those with any spend, which
+ are the ones basis.months lists), rounded up.
Empty when fewer than 2 of those months have any spend (not enough history to suggest limits)."""
months = []
y, m = today.year, today.month
@@ -65,15 +65,15 @@ def draft_budget(txns: list[dict], today: date) -> list[dict]:
months.append((y, m))
months.reverse()
totals = _totals(txns, set(months))
- active = len({mo for (_, mo), v in totals.items() if v}) # months with any spend (a new user may have only 2)
- if active < 2:
+ spent = sorted({mo for (_, mo), v in totals.items() if v}) # months with any spend (a new user may have only 2)
+ if len(spent) < 2:
return []
names = _display_names(txns)
lines = []
for key in {k for k, _ in totals}:
- avg = sum(totals.get((key, mo), 0.0) for mo in months) / active
+ avg = sum(totals.get((key, mo), 0.0) for mo in months) / len(spent)
if avg >= MIN_MONTHLY:
lines.append({"category": names[key], "limit": _round_up(avg),
- "basis": {"months": [f"{yy}-{mm:02d}" for yy, mm in months], "average": round(avg, 2)}})
+ "basis": {"months": [f"{yy}-{mm:02d}" for yy, mm in spent], "average": round(avg, 2)}})
lines.sort(key=lambda l: l["limit"], reverse=True)
return lines[:MAX_LINES]
diff --git a/backend/ai/home_signals.py b/backend/ai/home_signals.py
index cc5e49b..b5d8f03 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])]
@@ -113,7 +115,7 @@ def find_signals(txns: list[dict], today: date, review_count: int, recurring: li
review = [{"kind": "review", "tag": "RECEIPTS", "subject": None,
"headline": f"{review_count} {'receipt needs' if review_count == 1 else 'receipts need'} a glance",
"detail": "Lumen wasn't sure about these. Nothing is saved until you approve.",
- "amounts": [], "worth": None, "href": "/receipts?tab=review"}]
+ "amounts": [], "worth": None, "href": "/receipts"}]
return _duplicates(txns, today) + review + _spikes(txns, today) + _price_rises(recurring, today)
diff --git a/backend/api/agent.py b/backend/api/agent.py
index 45b8e3d..184fd93 100644
--- a/backend/api/agent.py
+++ b/backend/api/agent.py
@@ -120,7 +120,7 @@ def _save_invoice(proposal_id: str, user_id: str, deps: AgentDeps) -> dict | Non
if not session.execute(update(Proposal).where(
Proposal.id == proposal_id, Proposal.user_id == user_id, Proposal.status == "pending")
.values(status="saving")).rowcount:
- raise HTTPException(409, f"Proposal is already {p.status}")
+ raise HTTPException(409, "This proposal was already decided.")
payload = json.loads(p.payload or "{}")
try:
item = submit_invoice(deps.engine, user_id, payload, warnings_seen=True)
@@ -148,25 +148,28 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d
if p is None:
raise HTTPException(404, "Proposal not found")
# claim it first (UPDATE ... WHERE status = 'pending'): of two concurrent decisions only one can apply;
- # anything raised below rolls the claim back with the rest of the transaction
+ # anything raised below rolls the claim back with the rest of the transaction. The 409 doesn't name p.status:
+ # it was read before the claim, so the loser of a race would still see "pending"
now = datetime.utcnow()
if not session.execute(update(Proposal).where(
Proposal.id == proposal_id, Proposal.user_id == user_id, Proposal.status == "pending")
.values(status=decision, decided_at=now).execution_options(synchronize_session=False)).rowcount:
- raise HTTPException(409, f"Proposal is already {p.status}")
+ raise HTTPException(409, "This proposal was already decided.")
p.status, p.decided_at = decision, now
applied = None
if decision == "approved" and p.type == "set_budget":
- from api.budget import upsert_budget
+ from api.budget import this_month, upsert_budget, valid_month
# the agent can file set_budget with any payload: only a real positive limit is ever applied
try:
- limit = float(json.loads(p.payload or "{}")["limit"])
+ payload = json.loads(p.payload or "{}")
+ limit = float(payload["limit"])
except (KeyError, TypeError, ValueError):
- limit = math.nan
+ payload, limit = {}, math.nan
if not math.isfinite(limit) or limit <= 0 or not p.target.strip():
raise HTTPException(422, "This budget suggestion has no valid limit; reject it instead.")
- upsert_budget(session, user_id, p.target.strip(), limit)
+ # from the month the draft was for, but never earlier than this one: past months stay as they were
+ upsert_budget(session, user_id, p.target.strip(), limit, max(valid_month(payload.get("month")) or "", this_month()))
applied = {"category": p.target.strip(), "limit": limit}
if decision == "approved" and p.type == "update_category":
category = json.loads(p.payload or "{}").get("category")
diff --git a/backend/api/bills.py b/backend/api/bills.py
new file mode 100644
index 0000000..1add729
--- /dev/null
+++ b/backend/api/bills.py
@@ -0,0 +1,72 @@
+"""UI revamp Phase 5c: recurring charges for the Bills page and Home's "Coming up".
+
+Detection is ai/recurring.find_recurring over the user's transactions (pure, no LLM). The user's choices live in
+recurring_states: "not_a_bill" moves a vendor to `hidden`, "cancelled" keeps it listed as cancelled unless it was
+charged again after the cancel ("charged_again"). Posting "active" forgets the choice.
+"""
+from __future__ import annotations
+
+from datetime import date, datetime
+from typing import Literal
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, Field
+from sqlalchemy import select, text
+from sqlalchemy.engine import Engine
+from sqlalchemy.orm import Session
+
+from ai.recurring import _norm, find_recurring
+from api.deps import current_user, rate_limit
+from api.profile import get_engine
+
+router = APIRouter(prefix="/api/bills", tags=["bills"])
+
+
+class StateChange(BaseModel):
+ vendor: str = Field(min_length=1, max_length=200)
+ state: Literal["cancelled", "not_a_bill", "active"]
+
+
+def _recurring(engine: Engine, user_id: str, today: date) -> dict:
+ from models import RecurringState
+
+ with engine.connect() as conn:
+ rows = [dict(r) for r in conn.execute(text(
+ "SELECT vendor_name, date, total_amount, category FROM transactions WHERE user_id = :u"), {"u": user_id}).mappings()]
+ with Session(engine) as session:
+ states = {s.vendor_key: s for s in session.scalars(select(RecurringState).where(RecurringState.user_id == user_id))}
+ items, hidden = [], []
+ for item in find_recurring(rows, today):
+ key = _norm(item["vendor"])
+ saved = states.get(key)
+ if saved and saved.state == "not_a_bill":
+ hidden.append({"vendor": item["vendor"], "vendor_key": key})
+ continue
+ state = "active"
+ if saved and saved.state == "cancelled":
+ state = "charged_again" if item["last_charge_date"] > saved.changed_at.date().isoformat() else "cancelled"
+ items.append({**item, "state": state})
+ return {"success": True, "items": items, "hidden": hidden}
+
+
+@router.get("/recurring", dependencies=[Depends(rate_limit())])
+def recurring(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
+ return _recurring(engine, claims["sub"], date.today())
+
+
+@router.post("/recurring/state", dependencies=[Depends(rate_limit())])
+def set_state(body: StateChange, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
+ from models import RecurringState
+
+ user_id, key = claims["sub"], _norm(body.vendor)
+ with Session(engine) as session, session.begin():
+ row = session.scalars(select(RecurringState).where(RecurringState.user_id == user_id,
+ RecurringState.vendor_key == key)).first()
+ if body.state == "active":
+ if row:
+ session.delete(row)
+ elif row:
+ row.state, row.changed_at = body.state, datetime.utcnow()
+ else:
+ session.add(RecurringState(user_id=user_id, vendor_key=key, state=body.state))
+ return _recurring(engine, user_id, date.today())
diff --git a/backend/api/budget.py b/backend/api/budget.py
index 675a763..8fea860 100644
--- a/backend/api/budget.py
+++ b/backend/api/budget.py
@@ -1,12 +1,14 @@
"""UI revamp Phase 3: budgets. The overall monthly budget is User.monthly_budget (set in onboarding); per-category
limits live in the budgets table. A draft (ai/budget.py) never writes budgets: it files set_budget proposals that the
-user approves one by one through /api/agent/proposals/{id}/approve (api/agent.py applies them)."""
+user approves one by one through /api/agent/proposals/{id}/approve (api/agent.py applies them).
+Phase 5d: limits are versioned by month (models.Budget); every read is for one month, every write is from one month on."""
from __future__ import annotations
import json
+import re
from datetime import date, datetime
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy import select, text
from sqlalchemy.engine import Engine
@@ -18,6 +20,42 @@
router = APIRouter(prefix="/api/budget", tags=["budget"])
_MONTH = "%b"
+MONTH_RE = r"^\d{4}-(0[1-9]|1[0-2])$"
+
+
+def this_month(today: date | None = None) -> str:
+ return (today or date.today()).strftime("%Y-%m")
+
+
+def valid_month(value) -> str | None:
+ return value if isinstance(value, str) and re.match(MONTH_RE, value) else None
+
+
+def _shift(month: str, n: int) -> str:
+ y, m = divmod(int(month[:4]) * 12 + int(month[5:]) - 1 + n, 12)
+ return f"{y}-{m + 1:02d}"
+
+
+def near_month(month: str | None) -> str:
+ """`month` if it's the server's month or next to it, else 422; None means the server's month."""
+ from fastapi import HTTPException
+
+ now = this_month()
+ if month is None or month == now:
+ return now
+ if month in (_shift(now, -1), _shift(now, 1)):
+ return month
+ raise HTTPException(422, "Budgets can only be changed for the current month.")
+
+
+def limits_in_force(rows, month: str) -> dict[str, tuple[str, float]]:
+ """Case-folded category -> (name, limit) for `month`: each category's latest row from that month or before.
+ A limit of 0 is a removal, so the category has none."""
+ latest: dict[str, object] = {}
+ for b in sorted(rows, key=lambda b: b.effective_from or ""):
+ if (b.effective_from or "") <= month:
+ latest[b.category.casefold()] = b
+ return {k: (b.category, b.monthly_limit) for k, b in latest.items() if b.monthly_limit > 0}
class CategoryLimit(BaseModel):
@@ -27,6 +65,8 @@ class CategoryLimit(BaseModel):
class BudgetUpdate(BaseModel):
monthly_budget: float | None = Field(default=None, ge=0)
+ # the browser's month: around midnight on the 1st it can be a month off the server's (UTC) date
+ month: str | None = Field(default=None, pattern=MONTH_RE)
categories: list[CategoryLimit] = Field(default_factory=list, max_length=50)
@@ -37,31 +77,45 @@ def _txns(conn, user_id: str) -> list[dict]:
{"u": user_id}).mappings()]
-def _state(engine: Engine, user_id: str, today: date) -> dict:
+def _spend(engine: Engine, user_id: str, month: str) -> dict[str, float]:
from ai.budget import month_spend_by_category
- from models import Budget, User
with engine.connect() as conn:
- spend = month_spend_by_category(_txns(conn, user_id), today.year, today.month)
+ return month_spend_by_category(_txns(conn, user_id), int(month[:4]), int(month[5:]))
+
+
+def _rows(session: Session, user_id: str) -> list:
+ from models import Budget
+
+ return session.scalars(select(Budget).where(Budget.user_id == user_id)).all()
+
+
+def _state(engine: Engine, user_id: str, month: str) -> dict:
+ from models import User
+
+ spend = _spend(engine, user_id, month)
by_key = {k.casefold(): v for k, v in spend.items()}
with Session(engine) as session:
user = session.get(User, user_id)
- rows = session.scalars(select(Budget).where(Budget.user_id == user_id)).all()
- cats = [{"category": b.category, "limit": b.monthly_limit, "spent": round(by_key.get(b.category.casefold(), 0), 2)}
- for b in rows]
+ limits = limits_in_force(_rows(session, user_id), month)
+ cats = [{"category": name, "limit": limit, "spent": round(by_key.get(key, 0), 2)}
+ for key, (name, limit) in limits.items()]
cats.sort(key=lambda c: c["spent"] / c["limit"], reverse=True)
- return {"success": True, "month": today.strftime("%Y-%m"), "monthly_budget": user.monthly_budget if user else None,
+ # the overall budget isn't versioned, so a past month only has it once category limits were in force
+ overall = user.monthly_budget if user and (limits or month >= this_month()) else None
+ return {"success": True, "month": month, "monthly_budget": overall,
"spent": round(sum(spend.values()), 2), "categories": cats}
-def upsert_budget(session: Session, user_id: str, category: str, limit: float) -> None:
- """One row per (user, category), matched case-insensitively so 'food' and 'Food' don't become two budgets."""
+def upsert_budget(session: Session, user_id: str, category: str, limit: float, month: str) -> None:
+ """Sets `category`'s limit from `month` on (0 removes it). One row per (user, category, month), matched
+ case-insensitively so 'food' and 'Food' don't become two budgets."""
from models import Budget
- rows = session.scalars(select(Budget).where(Budget.user_id == user_id)).all()
- row = next((b for b in rows if b.category.casefold() == category.casefold()), None)
+ row = next((b for b in _rows(session, user_id)
+ if b.category.casefold() == category.casefold() and b.effective_from == month), None)
if row is None:
- session.add(Budget(user_id=user_id, category=category, monthly_limit=limit))
+ session.add(Budget(user_id=user_id, category=category, monthly_limit=limit, effective_from=month))
else:
row.monthly_limit, row.updated_at = limit, datetime.utcnow()
@@ -85,7 +139,7 @@ def create_draft(engine: Engine, user_id: str, today: date) -> list[dict]:
months = line["basis"]["months"]
span = f"{date.fromisoformat(months[0] + '-01'):{_MONTH}}–{date.fromisoformat(months[-1] + '-01'):{_MONTH}}"
p = Proposal(user_id=user_id, type="set_budget", target=line["category"], risk="low",
- payload=json.dumps({"limit": line["limit"], "basis": line["basis"]}),
+ payload=json.dumps({"limit": line["limit"], "basis": line["basis"], "month": this_month(today)}),
reason=f"Averaged {line['basis']['average']:,.0f} a month over {span}, rounded up.",
evidence="[]", status="pending")
session.add(p)
@@ -97,27 +151,46 @@ def create_draft(engine: Engine, user_id: str, today: date) -> list[dict]:
@router.get("", dependencies=[Depends(rate_limit())])
-def get_budget(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
- return _state(engine, claims["sub"], date.today())
+def get_budget(month: str | None = Query(default=None, pattern=MONTH_RE), claims: dict = Depends(current_user),
+ engine: Engine = Depends(get_engine)):
+ return _state(engine, claims["sub"], month or this_month())
+
+
+@router.get("/history", dependencies=[Depends(rate_limit())])
+def get_history(months: int = Query(default=6, ge=1, le=12), to: str | None = Query(default=None, pattern=MONTH_RE),
+ claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
+ """Budget vs spent for the last `months` months, oldest first, ending with this one. A past month with no
+ category limit in force has no budget (null): spend only."""
+ from models import User
+
+ user_id, now = claims["sub"], near_month(to)
+ with Session(engine) as session:
+ user = session.get(User, user_id)
+ overall = user.monthly_budget if user else None
+ rows = _rows(session, user_id)
+ from ai.budget import month_spend_by_category
+
+ with engine.connect() as conn:
+ txns = _txns(conn, user_id)
+ out = []
+ for month in (_shift(now, -n) for n in range(months - 1, -1, -1)):
+ limits = limits_in_force(rows, month)
+ budget = (overall or round(sum(v for _, v in limits.values()), 2) or None) if limits or month >= now else None
+ spent = sum(month_spend_by_category(txns, int(month[:4]), int(month[5:])).values())
+ out.append({"month": month, "budget": budget, "spent": round(spent, 2)})
+ return {"success": True, "months": out}
@router.put("", dependencies=[Depends(rate_limit())])
def put_budget(body: BudgetUpdate, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
- from models import Budget
-
- user_id = claims["sub"]
+ """Changes apply from the current month on; past months keep the limits they had."""
+ user_id, month = claims["sub"], near_month(body.month)
with Session(engine) as session, session.begin():
if "monthly_budget" in body.model_fields_set:
ensure_user(session, user_id, claims.get("email")).monthly_budget = body.monthly_budget
for c in body.categories:
- name = c.category.strip()
- if c.limit is None:
- for b in session.scalars(select(Budget).where(Budget.user_id == user_id)).all():
- if b.category.casefold() == name.casefold():
- session.delete(b)
- else:
- upsert_budget(session, user_id, name, c.limit)
- return _state(engine, user_id, date.today())
+ upsert_budget(session, user_id, c.category.strip(), c.limit or 0, month)
+ return _state(engine, user_id, month)
@router.post("/draft", dependencies=[Depends(rate_limit("10 per minute"))])
diff --git a/backend/api/review.py b/backend/api/review.py
index 123b842..ada9943 100644
--- a/backend/api/review.py
+++ b/backend/api/review.py
@@ -7,12 +7,13 @@
from __future__ import annotations
import json
+import math
import uuid
from datetime import date, datetime
from functools import lru_cache
from fastapi import APIRouter, Depends, HTTPException
-from pydantic import BaseModel
+from pydantic import BaseModel, field_validator
from sqlalchemy import create_engine, func, select
from sqlalchemy.engine import Engine
from sqlalchemy.exc import IntegrityError
@@ -43,6 +44,16 @@ class Decision(BaseModel):
edits: dict = {}
note: str | None = None
+ @field_validator("edits")
+ @classmethod
+ def _total_is_an_amount(cls, edits: dict) -> dict:
+ # an edited total becomes the transaction's amount: it has to be a real, positive number
+ if "total_amount" in edits:
+ v = edits["total_amount"]
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) or v <= 0:
+ raise ValueError("total_amount must be a positive number")
+ return edits
+
def _history(session: Session, user_id: str) -> list[dict]:
"""The user's review decisions, newest first, for SPEC-FEEDBACK loop B."""
diff --git a/backend/api/statements.py b/backend/api/statements.py
index 4bd9445..bec9bd9 100644
--- a/backend/api/statements.py
+++ b/backend/api/statements.py
@@ -70,10 +70,14 @@ def preview(file: UploadFile = File(...), claims: dict = Depends(current_user)):
@router.post("/import", dependencies=[Depends(rate_limit("10 per minute"))])
def import_statement(file: UploadFile = File(...), mapping: str = Form(...), currency: str | None = Form(None),
- header_line: int | None = Form(None), claims: dict = Depends(current_user),
- engine: Engine = Depends(get_engine)):
+ header_line: int | None = Form(None), label: str | None = Form(None),
+ claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
from config import Config
- from models import Transaction, User
+ from models import StatementImport, Transaction, User
+
+ label = (label or "").strip() or None
+ if label and len(label) > 60:
+ raise HTTPException(400, "The account name is too long (60 characters at most).")
try:
parsed, header = _load(file)
@@ -127,6 +131,26 @@ def import_statement(file: UploadFile = File(...), mapping: str = Form(...), cur
category=vendor.category(name), currency=code, source="statement",
import_key=key, invoice_number=None))
imported += 1
+ days = sorted(ln.date for ln in ok)
+ session.add(StatementImport(user_id=user_id, file_name=file.filename, label=label,
+ period_from=days[0] if days else None, period_to=days[-1] if days else None,
+ lines=len(lines), imported=imported, matched=matched, duplicates=duplicates,
+ credits=credits, unreadable=len(unreadable)))
session.commit()
return {"success": True, "imported": imported, "duplicates": duplicates, "matched": matched,
"credits": credits, "unreadable": unreadable}
+
+
+@router.get("/imports", dependencies=[Depends(rate_limit())])
+def imports(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)):
+ """The user's statement imports, newest first (Receipts & statements page)."""
+ from models import StatementImport
+
+ with Session(engine) as session:
+ rows = session.scalars(select(StatementImport).where(StatementImport.user_id == claims["sub"])
+ .order_by(StatementImport.created_at.desc())).all()
+ return {"success": True, "imports": [
+ {"id": r.id, "file_name": r.file_name, "label": r.label, "period_from": r.period_from,
+ "period_to": r.period_to, "lines": r.lines, "imported": r.imported, "matched": r.matched,
+ "duplicates": r.duplicates, "credits": r.credits, "unreadable": r.unreadable,
+ "created_at": r.created_at.isoformat()} for r in rows]}
diff --git a/backend/asgi.py b/backend/asgi.py
index 5baaec3..b552a38 100644
--- a/backend/asgi.py
+++ b/backend/asgi.py
@@ -20,7 +20,7 @@
from fastapi.middleware.cors import CORSMiddleware
import app as flask_module
-from api import agent, budget, demo, documents, errors, files, home, keys, profile, review, statements
+from api import agent, bills, budget, demo, documents, errors, files, home, keys, profile, review, statements
from api.deps import llm_deadline
from config import Config
@@ -90,5 +90,5 @@ def create_app(*routers: APIRouter) -> FastAPI:
app = create_app(documents.router, agent.router, demo.router, review.router, files.router, profile.router, statements.router,
- budget.router, home.router,
+ budget.router, bills.router, home.router,
keys.router)
diff --git a/backend/models/__init__.py b/backend/models/__init__.py
index f220616..8a938c6 100644
--- a/backend/models/__init__.py
+++ b/backend/models/__init__.py
@@ -302,7 +302,9 @@ class Proposal(db.Model):
class Budget(db.Model):
"""A monthly spending limit for one category (UI revamp Phase 3). The overall budget is User.monthly_budget.
- Rows are written only by approving a set_budget proposal or by PUT /api/budget."""
+ Rows are written only by approving a set_budget proposal or by PUT /api/budget.
+ Versioned by month (Phase 5d): a month's limit is the latest row with effective_from <= that month, so changing
+ a budget never rewrites past months. A monthly_limit of 0 removes the category from that month on."""
__tablename__ = "budgets"
@@ -310,9 +312,25 @@ class Budget(db.Model):
user_id = db.Column(String(36), nullable=False, index=True)
category = db.Column(db.String, nullable=False)
monthly_limit = db.Column(db.Float, nullable=False)
+ effective_from = db.Column(db.String(7)) # "YYYY-MM"; always set by the writers (older rows: migrated)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
- __table_args__ = (db.UniqueConstraint("user_id", "category", name="u_budget_user_category"),)
+ __table_args__ = (db.UniqueConstraint("user_id", "category", "effective_from", name="u_budget_user_category_month"),)
+
+class RecurringState(db.Model):
+ """What the user said about a recurring charge on the Bills page (UI revamp Phase 5c): "cancelled" keeps it listed
+ (and flags a charge after the cancel), "not_a_bill" hides it. Keyed by ai.recurring._norm(vendor), the same key
+ the detection groups charges by. No row = active."""
+
+ __tablename__ = "recurring_states"
+
+ id = db.Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ user_id = db.Column(String(36), nullable=False, index=True)
+ vendor_key = db.Column(db.String, nullable=False)
+ state = db.Column(db.String(20), nullable=False) # cancelled | not_a_bill
+ changed_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+
+ __table_args__ = (db.UniqueConstraint("user_id", "vendor_key", name="u_recurring_state_user_vendor"),)
class AuditEvent(db.Model):
@@ -390,3 +408,24 @@ class UserKey(db.Model):
last_error = db.Column(Text)
__table_args__ = (db.UniqueConstraint("user_id", "provider", name="u_user_key"),)
+
+
+class StatementImport(db.Model):
+ """One bank-statement import (UI revamp Phase 5e), for the Receipts page's history. Counts are as the import
+ returned them ("matched" is at import time; a re-import is a new row). The CSV itself isn't kept."""
+
+ __tablename__ = "statement_imports"
+
+ id = db.Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ user_id = db.Column(String(36), nullable=False, index=True)
+ file_name = db.Column(db.String)
+ label = db.Column(db.String(60))
+ period_from = db.Column(db.String(10))
+ period_to = db.Column(db.String(10))
+ lines = db.Column(db.Integer, nullable=False, default=0)
+ imported = db.Column(db.Integer, nullable=False, default=0)
+ matched = db.Column(db.Integer, nullable=False, default=0)
+ duplicates = db.Column(db.Integer, nullable=False, default=0)
+ credits = db.Column(db.Integer, nullable=False, default=0)
+ unreadable = db.Column(db.Integer, nullable=False, default=0)
+ created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
diff --git a/backend/models/database.py b/backend/models/database.py
index 2848a79..356a32f 100644
--- a/backend/models/database.py
+++ b/backend/models/database.py
@@ -1,4 +1,5 @@
import logging
+from datetime import datetime
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
@@ -34,6 +35,7 @@ def _migrate_transaction_unique_index(app: Flask) -> None:
# Columns added after their table first shipped (create_all doesn't alter existing tables). All nullable.
_ADDED_COLUMNS = {
+ "budgets": [("effective_from", "VARCHAR(7)")], # UI revamp Phase 5d: budgets per month
"chat_messages": [("thread_id", "VARCHAR(36)")], # UI revamp Phase 4: chat threads
"review_items": [("extracted", "TEXT")], # SPEC-FEEDBACK
"transactions": [("currency", "VARCHAR(3)"), ("po_number", "VARCHAR"), ("file_key", "VARCHAR"),
@@ -58,6 +60,44 @@ def _add_missing_columns() -> None:
logger.warning("Column migration for %s skipped: %s", table, e)
+def _migrate_budget_versions(engine) -> None:
+ """Phase 5d: budgets went from one row per (user, category) to one per month. Dates older rows by their last
+ update and swaps the unique constraint. SQLite can't drop a table constraint, so there the table is rebuilt.
+ Idempotent."""
+ insp = inspect(engine)
+ if "budgets" not in insp.get_table_names():
+ return
+ old = any(set(u["column_names"]) == {"user_id", "category"} for u in insp.get_unique_constraints("budgets"))
+ with engine.begin() as conn:
+ if old and engine.dialect.name == "sqlite":
+ conn.execute(text("ALTER TABLE budgets RENAME TO budgets_pre_5d"))
+ conn.execute(text("CREATE TABLE budgets (id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, "
+ "category VARCHAR NOT NULL, monthly_limit FLOAT NOT NULL, effective_from VARCHAR(7), "
+ "updated_at DATETIME)"))
+ have = {c["name"] for c in insp.get_columns("budgets_pre_5d")}
+ eff = "effective_from" if "effective_from" in have else "NULL"
+ conn.execute(text(f"INSERT INTO budgets (id, user_id, category, monthly_limit, effective_from, updated_at) "
+ f"SELECT id, user_id, category, monthly_limit, {eff}, updated_at FROM budgets_pre_5d"))
+ conn.execute(text("DROP TABLE budgets_pre_5d"))
+ conn.execute(text("CREATE INDEX IF NOT EXISTS ix_budgets_user_id ON budgets (user_id)"))
+ elif old:
+ conn.execute(text("ALTER TABLE budgets DROP CONSTRAINT IF EXISTS u_budget_user_category"))
+ for row_id, updated in conn.execute(text("SELECT id, updated_at FROM budgets WHERE effective_from IS NULL")).all():
+ conn.execute(text("UPDATE budgets SET effective_from = :m WHERE id = :i"),
+ {"m": str(updated or datetime.utcnow())[:7], "i": row_id})
+ conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS u_budget_user_category_month "
+ "ON budgets (user_id, category, effective_from)"))
+
+
+def run_budget_migration(engine) -> None:
+ """Runs _migrate_budget_versions; a failure is an error, not a warning: with the old (user, category) constraint
+ still in place, saving an existing category's budget in a new month fails."""
+ try:
+ _migrate_budget_versions(engine)
+ except Exception as e:
+ logger.error("Budget versions migration FAILED (budget edits in a new month will error until it runs): %s", e)
+
+
RAG_TABLES = {"documents", "document_chunks"}
@@ -90,6 +130,7 @@ def init_db(app: Flask):
db.metadata.create_all(db.engine, tables=tables)
_migrate_transaction_unique_index(app)
_add_missing_columns()
+ run_budget_migration(db.engine)
logger.info("Database connected and tables initialized")
except Exception as e:
logger.warning("Could not connect to database: %s", e)
diff --git a/backend/routes/chat.py b/backend/routes/chat.py
index 7ef7e04..c8ceb85 100644
--- a/backend/routes/chat.py
+++ b/backend/routes/chat.py
@@ -98,11 +98,12 @@ def _own_thread(user_id: str, thread_id) -> ChatThread:
def _thread_for(user_id: str, thread_id: str | None, question: str) -> ChatThread:
+ """The thread to save into. A new one is only added to the session: _save_exchange commits it together with its
+ messages, so a failed save leaves no empty titled thread behind."""
if thread_id:
return _own_thread(user_id, thread_id)
thread = ChatThread(id=str(uuid.uuid4()), user_id=user_id, title=_title(question) or "New chat")
db.session.add(thread)
- db.session.commit()
return thread
@@ -193,8 +194,10 @@ def chat():
_save_exchange(user_id, thread.id, f"{query} [attached: {file.filename}]" if file else query,
result["response"])
except Exception as e:
- # Losing history shouldn't cost the user their answer.
+ # Losing history shouldn't cost the user their answer. A new thread was rolled back with it: don't hand out its id.
db.session.rollback()
+ if not thread_id:
+ result.pop("thread_id", None)
logger.exception("Failed to save chat history for user=%s: %s", user_id, e)
return jsonify({"success": True, "data": result}), 200
diff --git a/backend/routes/database_query.py b/backend/routes/database_query.py
index 115b353..768a889 100644
--- a/backend/routes/database_query.py
+++ b/backend/routes/database_query.py
@@ -39,6 +39,7 @@ def get_transactions(user_id=None):
vendor = request.args.get('vendor')
min_amount = request.args.get('min_amount', type=float)
max_amount = request.args.get('max_amount', type=float)
+ exclude_source = request.args.get('exclude_source') # e.g. "statement": Receipts lists files, not bank lines
# Get sorting parameters
sort_by = request.args.get('sort_by', 'created_at')
@@ -60,6 +61,9 @@ def get_transactions(user_id=None):
if vendor:
query = query.filter(Transaction.vendor_name.like(f'%{vendor}%'))
+ if exclude_source:
+ query = query.filter(or_(Transaction.source.is_(None), Transaction.source != exclude_source))
+
if min_amount is not None:
query = query.filter(Transaction.total_amount >= min_amount)
@@ -92,6 +96,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/start.ps1 b/backend/start.ps1
index e6ad7b8..1d37e6b 100644
--- a/backend/start.ps1
+++ b/backend/start.ps1
@@ -14,5 +14,6 @@ if (-not (Test-Path .env)) {
}
# Start Flask application
-Write-Host "Starting Flask server on http://localhost:5000" -ForegroundColor Cyan
-python app.py
+Write-Host "Starting the API (uvicorn) on http://localhost:5000" -ForegroundColor Cyan
+# uvicorn runs the whole API; `python app.py` would start only the old Flask half (no /api/demo, agent or documents)
+python -m uvicorn asgi:app --port 5000
diff --git a/backend/tests/test_agent_tools.py b/backend/tests/test_agent_tools.py
index 69a2cd3..f294256 100644
--- a/backend/tests/test_agent_tools.py
+++ b/backend/tests/test_agent_tools.py
@@ -247,6 +247,13 @@ def test_split_follow_up():
"If you'd like a breakdown, ask", "You can ask for last month"):
assert split_follow_up(f"Done.\nNext: {lumen}")[1] is None
assert split_follow_up("Done.\nNext: Tell me my top vendors")[1] == "Tell me my top vendors"
+ # owner report 2026-10-22: a step for the user is not a question for Lumen; clicking it sent it to the chat.
+ # Uploads stay a chip (the UI turns it into an "upload" button); other do-it-yourself steps stay in the answer.
+ assert split_follow_up("Empty.\nNext: Upload an invoice or receipt to get started.")[1] == \
+ "Upload an invoice or receipt to get started."
+ for step in ("Connect your Gmail in Settings", "Go to Receipts and import a statement", "Log in to your bank app",
+ "Visit your bank's website", "Click Draft budget on Home"):
+ assert split_follow_up(f"Done.\nNext: {step}") == (f"Done.\n\n{step}", None)
assert split_follow_up("Here it is.\nNext: Would you like last month or the last three?") == (
"Here it is.\n\nWould you like last month or the last three?", None)
assert split_follow_up("Done.\nNext: Can I afford a trip in December?") == (
diff --git a/backend/tests/test_api_agent.py b/backend/tests/test_api_agent.py
index a1dff19..7f2932a 100644
--- a/backend/tests/test_api_agent.py
+++ b/backend/tests/test_api_agent.py
@@ -86,7 +86,8 @@ def category():
approved = client.post(f"/api/agent/proposals/{pid}/approve", headers=bearer(u1)).json()
assert approved["proposal"]["status"] == "approved" and approved["applied"]["transactions_updated"] == 1
assert category() == "Shopping"
- assert client.post(f"/api/agent/proposals/{pid}/reject", headers=bearer(u1)).status_code == 409
+ late = client.post(f"/api/agent/proposals/{pid}/reject", headers=bearer(u1))
+ assert late.status_code == 409 and late.json()["error"] == "This proposal was already decided."
with engine.connect() as conn:
actions = [r[0] for r in conn.execute(text(
"SELECT action FROM audit_events WHERE proposal_id = :p ORDER BY id"), {"p": pid})]
diff --git a/backend/tests/test_api_bills.py b/backend/tests/test_api_bills.py
new file mode 100644
index 0000000..acf4a08
--- /dev/null
+++ b/backend/tests/test_api_bills.py
@@ -0,0 +1,107 @@
+"""UI revamp Phase 5c: recurring charges for the Bills page, with the user's saved choices (cancelled / not a bill)."""
+from datetime import date, timedelta
+
+import jwt
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+
+A = "7a1c0000-0000-4000-8000-0000000000aa"
+B = "7a1c0000-0000-4000-8000-0000000000bb"
+
+
+def bearer(sub):
+ return {"Authorization": "Bearer " + jwt.encode({"sub": sub, "email": f"{sub}@x.com"}, "k" * 32, algorithm="HS256")}
+
+
+def ago(days):
+ return (date.today() - timedelta(days=days)).isoformat()
+
+
+@pytest.fixture
+def setup(tmp_path, monkeypatch):
+ import models # noqa: F401
+ import utils.auth
+ from api import bills
+ from asgi import create_app
+ from models.database import db as flask_db
+ from utils.limiter import limiter
+
+ monkeypatch.setattr(utils.auth, "verify_token", lambda t: jwt.decode(t, options={"verify_signature": False}))
+ monkeypatch.setattr(limiter, "enabled", False)
+ engine = create_engine(f"sqlite:///{tmp_path / 'bills.db'}")
+ flask_db.metadata.create_all(engine)
+ app = create_app(bills.router)
+ app.dependency_overrides[bills.get_engine] = lambda: engine
+ return TestClient(app), engine
+
+
+def charge(engine, user, vendor, when, amount=649.0, category="Entertainment"):
+ from models import Transaction
+
+ with Session(engine) as s, s.begin():
+ s.add(Transaction(user_id=user, vendor_name=vendor, invoice_number=f"{user}-{vendor}-{when}", date=when,
+ total_amount=amount, category=category))
+
+
+def monthly(engine, user, vendor, amount=649.0, category="Entertainment", latest=10):
+ for n in (latest + 60, latest + 30, latest):
+ charge(engine, user, vendor, ago(n), amount, category)
+
+
+def items(c, user=A):
+ r = c.get("/api/bills/recurring", headers=bearer(user))
+ assert r.status_code == 200, r.text
+ return r.json()
+
+
+def test_recurring_lists_active_charges_per_user(setup):
+ c, engine = setup
+ monthly(engine, A, "Netflix")
+ monthly(engine, B, "Spotify", 119)
+ [item] = items(c)["items"]
+ assert item["vendor"] == "Netflix" and item["cadence"] == "monthly" and item["state"] == "active"
+ assert items(c)["hidden"] == []
+ assert [i["vendor"] for i in items(c, B)["items"]] == ["Spotify"]
+
+
+def test_cancel_then_charged_again(setup):
+ c, engine = setup
+ monthly(engine, A, "Netflix")
+ r = c.post("/api/bills/recurring/state", json={"vendor": "Netflix", "state": "cancelled"}, headers=bearer(A))
+ assert r.status_code == 200 and r.json()["items"][0]["state"] == "cancelled"
+ # cancelled yesterday, charged again today: the user should know the cancel didn't stick
+ from models import RecurringState
+
+ charge(engine, A, "Netflix", date.today().isoformat())
+ with Session(engine) as s, s.begin():
+ row = s.query(RecurringState).one()
+ row.changed_at = row.changed_at - timedelta(days=1)
+ assert items(c)["items"][0]["state"] == "charged_again"
+
+
+def test_not_a_bill_hides_and_active_restores(setup):
+ c, engine = setup
+ monthly(engine, A, "Netflix")
+ r = c.post("/api/bills/recurring/state", json={"vendor": "NETFLIX", "state": "not_a_bill"}, headers=bearer(A))
+ body = r.json()
+ assert body["items"] == [] and [h["vendor"] for h in body["hidden"]] == ["Netflix"]
+ r = c.post("/api/bills/recurring/state", json={"vendor": "Netflix", "state": "active"}, headers=bearer(A))
+ assert [i["state"] for i in r.json()["items"]] == ["active"] and r.json()["hidden"] == []
+
+
+def test_states_are_per_user_and_match_vendor_spelling(setup):
+ c, engine = setup
+ monthly(engine, A, "Netflix.com")
+ monthly(engine, B, "Netflix.com")
+ c.post("/api/bills/recurring/state", json={"vendor": "netflix com", "state": "cancelled"}, headers=bearer(A))
+ assert items(c)["items"][0]["state"] == "cancelled"
+ assert items(c, B)["items"][0]["state"] == "active"
+
+
+def test_state_input_is_validated(setup):
+ c, _ = setup
+ assert c.post("/api/bills/recurring/state", json={"vendor": "X", "state": "deleted"}, headers=bearer(A)).status_code == 422
+ assert c.post("/api/bills/recurring/state", json={"vendor": "", "state": "cancelled"}, headers=bearer(A)).status_code == 422
+ assert c.get("/api/bills/recurring").status_code == 401
diff --git a/backend/tests/test_api_budget.py b/backend/tests/test_api_budget.py
index 7f99a21..35460df 100644
--- a/backend/tests/test_api_budget.py
+++ b/backend/tests/test_api_budget.py
@@ -195,3 +195,110 @@ def test_decide_claims_with_a_conditional_update(setup):
claim = next(i for i, s in enumerate(statements) if s.startswith("update proposals") and "status = ?" in s.split("where", 1)[1])
writes = [i for i, s in enumerate(statements) if s.startswith("insert into budgets") or s.startswith("insert into audit_events")]
assert writes and claim < min(writes)
+
+
+def ym(n):
+ return months_back(n)[:7]
+
+
+def put_row(engine, user, category, limit, month):
+ from models import Budget
+
+ with Session(engine) as s, s.begin():
+ s.add(Budget(user_id=user, category=category, monthly_limit=limit, effective_from=month))
+
+
+def test_editing_this_month_keeps_last_months_limits(setup):
+ c, engine = setup
+ put_row(engine, A, "Food", 3000, ym(1))
+ seed(engine, A, [(months_back(1), 2500, "Food")])
+ c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": 5000}]})
+ last = c.get(f"/api/budget?month={ym(1)}", headers=bearer(A)).json()
+ assert last["month"] == ym(1) and last["spent"] == 2500
+ assert last["categories"] == [{"category": "Food", "limit": 3000, "spent": 2500}]
+ assert c.get("/api/budget", headers=bearer(A)).json()["categories"][0]["limit"] == 5000
+
+
+def test_removing_a_category_only_applies_from_this_month(setup):
+ c, engine = setup
+ put_row(engine, A, "Food", 3000, ym(1))
+ body = c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "food", "limit": None}]}).json()
+ assert body["categories"] == []
+ assert c.get(f"/api/budget?month={ym(1)}", headers=bearer(A)).json()["categories"][0]["limit"] == 3000
+
+
+def test_a_bad_month_is_rejected(setup):
+ c, _ = setup
+ for bad in ("2026-13", "26-01", "2026-1", "x"):
+ assert c.get(f"/api/budget?month={bad}", headers=bearer(A)).status_code == 422
+ assert c.get("/api/budget/history?months=0", headers=bearer(A)).status_code == 422
+
+
+def test_history_compares_each_month_with_the_limits_then(setup):
+ c, engine = setup
+ put_row(engine, A, "Food", 3000, ym(2))
+ put_row(engine, A, "Food", 4000, ym(1))
+ seed(engine, A, [(months_back(3), 900, "Food"), (months_back(2), 3500, "Food"), (months_back(1), 1000, "Food")])
+ months = c.get("/api/budget/history?months=4", headers=bearer(A)).json()["months"]
+ assert months == [
+ {"month": ym(3), "budget": None, "spent": 900}, # before any budget: spend only
+ {"month": ym(2), "budget": 3000, "spent": 3500},
+ {"month": ym(1), "budget": 4000, "spent": 1000},
+ {"month": ym(0), "budget": 4000, "spent": 0},
+ ]
+ c.put("/api/budget", headers=bearer(A), json={"monthly_budget": 50000})
+ assert c.get("/api/budget/history?months=1", headers=bearer(A)).json()["months"][0]["budget"] == 50000
+
+
+def test_approving_a_draft_writes_it_from_the_drafts_month(setup):
+ from models import Budget
+
+ c, engine = setup
+ history(engine, A)
+ food = next(p for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] if p["target"] == "Food")
+ assert food["payload"]["month"] == ym(0)
+ c.post(f"/api/agent/proposals/{food['id']}/approve", headers=bearer(A))
+ with Session(engine) as s:
+ assert [(b.category, b.effective_from) for b in s.scalars(select(Budget))] == [("Food", ym(0))]
+
+
+def test_a_past_month_without_limits_has_no_overall_budget(setup):
+ """The overall budget isn't versioned: a month before any category limit shows spend only, like history."""
+ c, engine = setup
+ c.put("/api/budget", headers=bearer(A), json={"monthly_budget": 50000})
+ assert c.get(f"/api/budget?month={ym(3)}", headers=bearer(A)).json()["monthly_budget"] is None
+ put_row(engine, A, "Food", 3000, ym(2))
+ assert c.get(f"/api/budget?month={ym(2)}", headers=bearer(A)).json()["monthly_budget"] == 50000
+ assert c.get("/api/budget", headers=bearer(A)).json()["monthly_budget"] == 50000
+
+
+def test_approving_last_months_draft_applies_from_this_month(setup):
+ import json
+
+ from models import Budget, Proposal
+
+ c, engine = setup
+ history(engine, A)
+ food = next(p for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] if p["target"] == "Food")
+ with Session(engine) as s, s.begin(): # a draft left pending since last month
+ p = s.get(Proposal, food["id"])
+ p.payload = json.dumps({**json.loads(p.payload), "month": ym(1)})
+ c.post(f"/api/agent/proposals/{food['id']}/approve", headers=bearer(A))
+ with Session(engine) as s:
+ assert [b.effective_from for b in s.scalars(select(Budget))] == [ym(0)]
+
+
+def test_writes_can_name_the_clients_month_within_one_of_the_servers(setup):
+ """Around midnight on the 1st the browser and server can be a month apart: the client's month wins if adjacent."""
+ from models import Budget
+
+ c, engine = setup
+ nxt = date.today().replace(day=1) + __import__("datetime").timedelta(days=32)
+ nxt = nxt.strftime("%Y-%m")
+ body = c.put("/api/budget", headers=bearer(A), json={"month": nxt, "categories": [{"category": "Food", "limit": 900}]}).json()
+ assert body["month"] == nxt
+ with Session(engine) as s:
+ assert [b.effective_from for b in s.scalars(select(Budget))] == [nxt]
+ assert c.put("/api/budget", headers=bearer(A), json={"month": ym(2), "categories": []}).status_code == 422
+ assert c.get(f"/api/budget/history?months=2&to={nxt}", headers=bearer(A)).json()["months"][-1]["month"] == nxt
+ assert c.get(f"/api/budget/history?months=2&to={ym(3)}", headers=bearer(A)).status_code == 422
diff --git a/backend/tests/test_api_review.py b/backend/tests/test_api_review.py
index 338952b..b7c43af 100644
--- a/backend/tests/test_api_review.py
+++ b/backend/tests/test_api_review.py
@@ -192,3 +192,10 @@ def test_list_count_only(client):
client.post("/api/review", headers=bearer(ALICE), json={"invoice": invoice(total_amount=total, invoice_number=f"N{total}")})
assert client.get("/api/review?status=flagged&count=1", headers=bearer(ALICE)).json() == {"success": True, "count": 3}
assert client.get("/api/review?count=1", headers=bearer(BOB)).json() == {"success": True, "count": 0}
+
+
+@pytest.mark.parametrize("total", [0, -5, None, "lots", True])
+def test_an_edit_cannot_approve_a_bad_total(client, total):
+ item = client.post("/api/review", headers=bearer(ALICE), json={"invoice": invoice(total_amount=999.0)}).json()["item"]
+ r = client.post(f"/api/review/{item['id']}/approve", headers=bearer(ALICE), json={"edits": {"total_amount": total}})
+ assert r.status_code == 422 and _txn_count(client.engine, ALICE) == 1
diff --git a/backend/tests/test_api_statements.py b/backend/tests/test_api_statements.py
index 92b8603..80b35e6 100644
--- a/backend/tests/test_api_statements.py
+++ b/backend/tests/test_api_statements.py
@@ -195,3 +195,31 @@ def test_bad_currency_is_400(client):
c, _ = client
m = json.dumps({"date": 0, "description": 1, "amount": 2})
assert post(c, "import", mapping=m, currency="RUPEES").status_code == 400
+
+
+def history(c, who=USER):
+ r = c.get("/api/statements/imports", headers=bearer(who))
+ assert r.status_code == 200, r.text
+ return r.json()["imports"]
+
+
+def test_each_import_is_kept_in_history(client):
+ """UI revamp Phase 5e: one history row per import, with the period and the counts it returned."""
+ c, _ = client
+ m = json.dumps(post(c, "preview").json()["mapping"])
+ post(c, "import", mapping=m, label=" HDFC Savings ··4417 ")
+ post(c, "import", mapping=m)
+ second, first = history(c)
+ assert first["label"] == "HDFC Savings ··4417" and first["file_name"] == "s.csv"
+ assert (first["period_from"], first["period_to"]) == ("2026-10-03", "2026-10-06")
+ assert (first["lines"], first["imported"], first["duplicates"], first["matched"], first["credits"], first["unreadable"]) \
+ == (3, 2, 0, 0, 1, 0)
+ assert second["label"] is None and (second["imported"], second["duplicates"]) == (0, 2)
+ assert first["created_at"] and history(c, OTHER) == []
+
+
+def test_history_needs_auth_and_label_is_capped(client):
+ c, _ = client
+ assert c.get("/api/statements/imports").status_code == 401
+ m = json.dumps(post(c, "preview").json()["mapping"])
+ assert post(c, "import", mapping=m, label="x" * 61).status_code == 400
diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py
index be19c35..30e07c4 100644
--- a/backend/tests/test_budget.py
+++ b/backend/tests/test_budget.py
@@ -63,17 +63,66 @@ def test_month_spend_nets_refunds():
assert month_spend_by_category(txns, 2026, 10) == {"Food": 600}
-def test_budget_unique_per_category(tmp_path):
+def test_budget_unique_per_category_and_month(tmp_path):
import models # noqa: F401
from models import Budget
from models.database import db as flask_db
engine = create_engine(f"sqlite:///{tmp_path / 'b.db'}")
flask_db.metadata.create_all(engine)
+ with Session(engine) as s, s.begin(): # one row per month is how a budget changes over time
+ s.add_all([Budget(user_id="u", category="Food", monthly_limit=1, effective_from="2026-09"),
+ Budget(user_id="u", category="Food", monthly_limit=2, effective_from="2026-10")])
with pytest.raises(IntegrityError), Session(engine) as s, s.begin():
- s.add_all([Budget(user_id="u", category="Food", monthly_limit=1), Budget(user_id="u", category="Food", monthly_limit=2)])
+ s.add(Budget(user_id="u", category="Food", monthly_limit=3, effective_from="2026-10"))
+
+
+def test_migration_versions_an_old_budgets_table(tmp_path):
+ """A database from before Phase 5d: one row per (user, category), no effective_from. The migration keeps the rows,
+ dates them by their last update, and lets a category have one row per month. Running it twice is harmless."""
+ from sqlalchemy import text
+
+ from models.database import _migrate_budget_versions
+
+ engine = create_engine(f"sqlite:///{tmp_path / 'old.db'}")
+ with engine.begin() as conn:
+ conn.execute(text("CREATE TABLE budgets (id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, "
+ "category VARCHAR NOT NULL, monthly_limit FLOAT NOT NULL, updated_at DATETIME, "
+ "CONSTRAINT u_budget_user_category UNIQUE (user_id, category))"))
+ conn.execute(text("INSERT INTO budgets VALUES ('1', 'u', 'Food', 4000, '2026-08-14 10:00:00')"))
+ _migrate_budget_versions(engine)
+ _migrate_budget_versions(engine)
+ with engine.begin() as conn:
+ assert conn.execute(text("SELECT category, monthly_limit, effective_from FROM budgets")).all() == [
+ ("Food", 4000, "2026-08")]
+ conn.execute(text("INSERT INTO budgets (id, user_id, category, monthly_limit, effective_from) "
+ "VALUES ('2', 'u', 'Food', 5000, '2026-10')"))
+ with pytest.raises(IntegrityError), engine.begin() as conn:
+ conn.execute(text("INSERT INTO budgets (id, user_id, category, monthly_limit, effective_from) "
+ "VALUES ('3', 'u', 'Food', 6000, '2026-10')"))
+
+
+def test_basis_lists_only_months_with_spend():
+ txns = [t("2026-08-10", 6000), t("2026-09-10", 6000)] # nothing in July
+ assert draft_budget(txns, TODAY)[0]["basis"]["months"] == ["2026-08", "2026-09"]
def test_draft_with_two_months_averages_over_two():
txns = [t("2026-08-10", 6000), t("2026-09-10", 6000)] # no July data
assert draft_budget(txns, TODAY)[0]["limit"] == 6000
+
+
+def test_a_failed_budget_migration_is_logged_as_an_error(monkeypatch, caplog):
+ """If it's skipped, edits to an existing category in a new month hit the old constraint: that must not be quiet."""
+ import logging
+
+ from models import database
+
+ def boom(engine):
+ raise RuntimeError("no permission")
+
+ monkeypatch.setattr(database, "_migrate_budget_versions", boom)
+ with caplog.at_level(logging.WARNING, logger="models.database"):
+ database.run_budget_migration(object())
+ [record] = caplog.records
+ assert record.levelno == logging.ERROR and "budget" in record.getMessage().lower()
diff --git a/backend/tests/test_chat_attach.py b/backend/tests/test_chat_attach.py
index dd8f117..80b99f4 100644
--- a/backend/tests/test_chat_attach.py
+++ b/backend/tests/test_chat_attach.py
@@ -84,7 +84,7 @@ def test_approve_save_invoice_records_once(env):
assert out["proposal"]["status"] == "approved"
with pytest.raises(HTTPException) as e:
_decide(pid, "user-1", "approved", deps)
- assert e.value.status_code == 409
+ assert (e.value.status_code, e.value.detail) == (409, "This proposal was already decided.")
assert _count(engine, ReviewItem) == 1 # one submission (auto-approved: its transaction, else queued for review)
assert _count(engine, Transaction) == 1
diff --git a/backend/tests/test_chat_threads.py b/backend/tests/test_chat_threads.py
index 842d9fe..30e336d 100644
--- a/backend/tests/test_chat_threads.py
+++ b/backend/tests/test_chat_threads.py
@@ -51,6 +51,21 @@ def test_post_without_thread_creates_one_titled_from_question(chat):
assert not thread["title"].rstrip("…").endswith(" ")
+def test_chat_save_failure_leaves_no_empty_thread(chat, monkeypatch):
+ import routes.chat as chat_routes
+
+ client, post, _, _ = chat
+
+ def boom(*a):
+ raise RuntimeError("db down")
+
+ monkeypatch.setattr(chat_routes, "_save_exchange", boom)
+ res = post(A, "Lost to a failed save")
+ assert res.status_code == 200 # the answer still comes back
+ assert "thread_id" not in res.get_json()["data"] # no id for a thread that was never saved
+ assert client.get("/chat/threads", headers=bearer(A)).get_json()["threads"] == []
+
+
def test_post_with_thread_appends(chat):
client, post, _, _ = chat
tid = post(A, "First").get_json()["data"]["thread_id"]
diff --git a/backend/tests/test_home_signals.py b/backend/tests/test_home_signals.py
index ba31449..3e0c246 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():
@@ -71,7 +71,7 @@ def test_order_and_review_item():
assert kinds(items) == ["duplicate", "review", "spike", "price_rise"]
assert items[1] == {"kind": "review", "tag": "RECEIPTS", "subject": None, "headline": "3 receipts need a glance",
"detail": "Lumen wasn't sure about these. Nothing is saved until you approve.",
- "amounts": [], "worth": None, "href": "/receipts?tab=review"}
+ "amounts": [], "worth": None, "href": "/receipts"}
assert find_signals([], TODAY, 1, [])[0]["headline"] == "1 receipt needs a glance"
@@ -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..4c252d6 100644
--- a/backend/tests/test_invoice_upload.py
+++ b/backend/tests/test_invoice_upload.py
@@ -605,3 +605,34 @@ 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"
+
+
+def test_the_list_can_leave_out_statement_lines(client, ocr):
+ # Receipts & statements lists files, not bank lines: one big import must not push the receipts off the page
+ from models import Transaction, db
+
+ body = _upload(client, _png_bytes()).get_json()
+ with client.application.app_context():
+ for i in range(3):
+ db.session.add(Transaction(user_id=db.session.get(Transaction, body["transaction_id"]).user_id,
+ vendor_name=f"Line {i}", date="2026-10-01", total_amount=1, source="statement",
+ import_key=f"k{i}"))
+ db.session.commit()
+ listed = client.get("/transactions?exclude_source=statement&page_size=2", headers=AUTH).get_json()
+ assert [t["id"] for t in listed["data"]] == [body["transaction_id"]]
diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md
index cdc170c..636823d 100644
--- a/docs/CODEBASE-GUIDE.md
+++ b/docs/CODEBASE-GUIDE.md
@@ -253,17 +253,19 @@ embeds the demo documents once per server and uploads the files in the backgroun
the landing or sign-in pages, which don't need the API; they still ping it so it's warm by the time the app opens).
## 7b2. App frame and navigation (frontend)
-- The signed-in pages live in the route group `app/(app)/`: `/home`, `/spending`, `/bills`, `/receipts`
- (`?tab=documents|import|review`), `/ask`, `/settings`. The old URLs (`/dashboard`, `/analytics`, `/ai-analytics`,
+- The signed-in pages live in the route group `app/(app)/`: `/home`, `/spending`, `/bills`, `/budget`, `/receipts`
+ (and `/receipts/import`), `/ask`, `/settings`. The old URLs (`/dashboard`, `/analytics`, `/ai-analytics`,
`/chatbot`, `/documents`, `/import`, `/review`, `/upload`) redirect to them in `next.config.js` (temporary 307s, query
- strings carried). Until UI revamp Phase 5, Spending, Bills and Receipts reuse the old pages' `*Content.tsx`.
-- **Sidebar** (`components/app-sidebar.tsx`): Home · Track (Spending, Bills & subscriptions) · Receipts & statements
+ strings carried).
+- **Sidebar** (`components/app-sidebar.tsx`): Home · Track (Spending, Bills & subscriptions) · Plan (Budget) · Receipts & statements
(badge = flagged review items) · Ask Lumen (Ctrl/⌘+K). It collapses to a 56px icon rail with Ctrl/⌘+B, remembered
in the `sidebar_state` cookie. The layout reads that cookie on the sidebar's first render, which is always
client-side because the auth guard shows a loader on the server, so there's no jump and no server read. Shortcuts
skip key presses in text fields (`isTypingTarget` in `lib/utils.ts`). **Account** (Settings, API keys, Log out) is the
- top-right `components/avatar-menu.tsx`.
- `app/(app)/layout.tsx` holds the auth guard, sidebar and top bar; they mount once and stay while you move between
+ top-right `components/avatar-menu.tsx`, floating over the page (there is no top bar on desktop; phones keep a slim
+ row with the sheet button). The collapse button lives in the sidebar header; on the 56px rail the logo turns into it
+ on hover. Every sidebar group is labelled (Overview, Track, Records, Assistant).
+ `app/(app)/layout.tsx` holds the auth guard and sidebar; they mount once and stay while you move between
pages, so only the content area swaps. `app/(app)/template.tsx` fades each page's content in (`.app-page-in` in
`globals.css`). `components/dashboard-shell.tsx` is just a page's content wrapper and optional header card.
- There is no root `loading.tsx`: the pages are static and prefetched, and a full-screen spinner made every click
@@ -273,17 +275,66 @@ embeds the demo documents once per server and uploads the files in the backgroun
swaps every shadcn colour token to the light Sky palette, so portaled menus and dialogs match too. The
`dark:` variant is also switched off while the shell is mounted. Public pages keep the forced dark theme.
Extra tokens: `good`, `bad`, `accent-soft`, `--band`. Type roles: `font-serif` (Newsreader), `font-label` /
- `.label-mono` (IBM Plex Mono labels).
+ `.label-ui` (card titles, tags, ticks: Funnel Sans, sentence case; the owner ruled out all-caps mono labels).
- **No layout shift from overlays.** `globals.css` sets `scrollbar-gutter: stable` and cancels Radix's scroll-lock
margin (`body[data-scroll-locked]`), so opening a menu or dialog never moves the page. Guarded by
`npm run test:e2e` (Playwright, local only), which drives the dev-only harness `app/dev/layout-shift` (404 in
- production). Firefox is opt-in with `E2E_FIREFOX=1`. `html, body` use `overflow-x: clip`, never `hidden`:
+ production). Firefox is opt-in with `E2E_FIREFOX=1`. The e2e dev server builds into `.next-e2e` (`NEXT_DIST_DIR`),
+ so running tests never breaks a `npm run dev` in the same checkout. `html, body` use `overflow-x: clip`, never `hidden`:
`hidden` makes `
` a scroll container whose scrollbar disappears when a menu opens.
-- **Home** (`app/(app)/home/`, UI revamp Phase 2): greeting and a serif brief over a sky band, then the cards.
- The main column has Spent this period (a hand-drawn SVG: cumulative now vs the previous period, a forecast to month-end, bill pins,
- 1W/1M/3M/YTD), Lumen found, and Spending calendar + Recent transactions. The side column has Budget, Upcoming bills, the draft-budget promo and
- Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, reminders, forecast, review
- queue). A failed call empties only its card. Skeletons show only on the first load, so changing the chart range
+- **Page skeleton (UI revamp Phase 5).** Every redesigned page is built from `components/page/`: `PageHeader`
+ (26px title, quiet actions, serif brief, optional control row; `md:pr-12` leaves room for the floating account
+ menu), `PageCard` (title row with optional `›` link, `extra` and `action`; a skeleton at the card's final height,
+ so nothing moves when data arrives) and `VendorChip` (initials on a stable per-vendor hue, `vendorChip` in
+ `lib/home-math.ts`). Buttons on these pages use the `quiet` and `ink` variants, never the default blue. Charts use
+ `lib/chart.ts`: `smoothPath` (monotone cubic, so a running total never dips between days) and `pickTicks`
+ (axis labels that never overlap at any width). Approved mockups: `docs/superpowers/specs/2026-10-22-phase5/`.
+- **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.
+- **Bills & subscriptions** (`app/(app)/bills/`, Phase 5c): "what's due, what recurs, what can I cut?" Everything
+ comes from `GET /api/bills/recurring` (`api/bills.py`): `ai/recurring.find_recurring` over the user's transactions
+ plus their saved choices in `recurring_states` (keyed by the detection's normalised vendor). `not_a_bill` moves a
+ vendor to `hidden`; `cancelled` keeps it listed as cancelled, or `charged_again` if a charge lands after the
+ cancel day; posting `active` forgets the choice (`POST /api/bills/recurring/state`). The page: three tiles (due in
+ 30 days, recurring per month with weekly/yearly spread, subscriptions that look stopped), a six-row month calendar
+ with each bill on its projected days (‹ month › switcher), Upcoming grouped This week / Next week / Later, and the
+ table with tabs, price-rise / looks-stopped / charged-again pills, Mark as cancelled, Not a bill, and How to cancel
+ (`/ask?draft=How do I cancel X?`). Maths in `lib/bills-math.ts` (tests: `e2e/bills-math.spec.ts`).
+- **Budget** (`app/(app)/budget/`, Phase 5d): "am I on track, and what should my plan be?" A ‹ month › switcher
+ (past months read-only, as they were), Edit budget (the overall total, in place) and Let Lumen draft. Hero: spent of
+ total, a bar with the even-pace tick, left / days left / safe per day (left ÷ days left), and the running total
+ against a straight line to the budget (`CumulativeChart` with that line as its dashed series). Last 6 months: dashed
+ budget outline vs actual (green under, red over), this month with a run-rate projection, and the track record. By
+ category: budget / spent / left / used, the amount editable in place (Enter saves, Esc cancels, same row height),
+ Add category, and the unassigned footer. The draft panel shows pending `set_budget` lines (Skip / Approve named for
+ the category, "Approve remaining N"). History sits above By category so the data-height cards come last.
+ Maths in `lib/budget-math.ts` (tests: `e2e/budget-math.spec.ts`).
+- **Receipts & statements** (`app/(app)/receipts/`, Phase 5e): "what have I given Lumen, and what needs my OK?" It
+ replaced the Documents / Import / Review tabs. Everything loads together and appears in one step. Needs your review
+ (only when the queue isn't empty): a card per flagged item with the doubtful field highlighted from its validation
+ flag (`total_mismatch`/`no_currency` → Total, `duplicate` → Bill no., `bad_date` → Date, `unknown_vendor` → Vendor),
+ the reason in plain words, Approve / Edit (sends only changed fields) / Reject; a duplicate offers Keep both. The
+ library merges receipts (non-statement transactions, latest 100), review items, RAG documents (deletable) and
+ statement imports, with chips, search and Show more. Right column: Statement imports (counts bar) and From your
+ inbox (`/api/v1/email-config/status`: Check now, Pause/Resume, or Connect with Google). The upload dialog
+ (`components/uploadDialog.tsx`) chooses Receipts & invoices (`/extract`) or Documents (PDF, `/api/documents`).
+- **Home** (`app/(app)/home/`, Home v2 from Phase 5a, modelled on Origin's app home): greeting, quiet Upload and
+ Export, and a serif brief over a sky band. Then a full-width hero: Spent in (total, change vs the previous
+ period by the same day, "heading for" from the forecast, smooth now-vs-before lines, bill markers on the axis,
+ 1W/1M/3M/YTD, `+` opens upload). Below it, the main column has ", day by day" (amount per day, today
+ outlined, upcoming bill names on future days, always as tall as a six-week month) beside Recent transactions, then
+ Lumen found as a list whose links act ("Ask Lumen" opens `/ask?draft=` with the question typed but not sent). The
+ side column has Budget in (spent of total, % bar, top four categories by spend), Coming up (next four
+ bills), the draft-budget promo and Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, recurring
+ charges, forecast, review queue). Bills come from `GET /api/bills/recurring`: `toBills` keeps active ones that
+ don't look stopped and are due in the next 31 days. A failed call empties only its card. Skeletons show only on the first load, so changing the chart range
never blanks other cards. All the maths is pure, in `lib/home-math.ts` (unit tests: `e2e/home-math.spec.ts`).
**Phase 3 backend:** `GET /api/home/summary` (`api/home.py` over the pure `ai/home_signals.py`, no LLM) supplies
Lumen found and Activity:
@@ -294,12 +345,18 @@ embeds the demo documents once per server and uploads the files in the backgroun
Text carries `{0}` placeholders plus `amounts`, which the client fills in the user's currency (`fill` in
`lib/home-math.ts`). The brief adds "The one thing worth a look" from the top item.
- Budgets: the overall budget is `User.monthly_budget` (onboarding); per-category limits are the `budgets` table.
- - `GET/PUT /api/budget` (`api/budget.py`).
- - `POST /api/budget/draft` runs the deterministic `ai/budget.py` (3-month mean, rounded up) and files `set_budget`
- proposals. Approving one (`/api/agent/proposals/{id}/approve`) upserts the row. A new draft retires pending ones.
+ Budgets: the overall budget is `User.monthly_budget` (onboarding); per-category limits are the `budgets` table,
+ one row per (user, category, `effective_from` month) since Phase 5d. A month's limit is the latest row from that
+ month or before (`limits_in_force`), so edits never rewrite past months; a 0 limit removes a category from that
+ month on. `models/database._migrate_budget_versions` dates older rows by `updated_at` and swaps the unique
+ constraint (SQLite rebuilds the table).
+ - `GET /api/budget?month=YYYY-MM` (default this month), `PUT /api/budget` (writes from the current month),
+ `GET /api/budget/history?months=6` (budget vs spent per month; `budget` null for a past month with no limits).
+ - `POST /api/budget/draft` runs the deterministic `ai/budget.py` (3-month mean over the months that had spend,
+ rounded up; `basis.months` lists those) and files `set_budget` proposals with `payload.month` = this month.
+ Approving one (`/api/agent/proposals/{id}/approve`) writes the row from that month. A new draft retires pending ones.
- Ask Lumen's `draft_budget` tool files the same proposals.
- - Home's draft dialog approves lines one by one or all at once.
+ - The Budget page's draft panel approves lines; Home's promo links to `/budget?draft=1`.
The sidebar badge uses `GET /api/review?count=1`. `(app)/layout.tsx` is a
tiny server file exporting the light `theme-color`; the client shell is `components/app-shell.tsx`.
@@ -322,7 +379,8 @@ embeds the demo documents once per server and uploads the files in the backgroun
comparable.
- **The page.** Opening Ask starts a fresh chat with suggestions (from Home's "Lumen found" via
`suggestedQuestions`, then `/chat/suggestions`). Past chats sit in `thread-rail.tsx` (a column on desktop, a Sheet
- on small screens). `/ask?q=…` starts a new chat with that question once, then cleans the URL.
+ on small screens). `/ask?q=…` starts a new chat with that question once, then cleans the URL. `/ask?draft=…`
+ starts a new chat with the question typed into the composer but not sent (Lumen found, Bills "How to cancel").
- **Answers.** `POST /chat` returns the markdown answer plus its evidence: `sources` (passage `text` and the original's
`file_key`), `steps` (tool, summary, `latency_ms`) and `proposals`. The SQL the agent ran stays server-side. Saved
messages keep only the text, so older answers show citation numbers without passages.
@@ -465,8 +523,11 @@ embeds the demo documents once per server and uploads the files in the backgroun
into a vendor name and a category.
- `api/statements.py`: `POST /api/statements/preview` (columns, guessed mapping, 5 sample lines, counts; a file with
no header is a 400 with `code: "no_header"` and the first raw rows) and `POST /api/statements/import` (file +
- mapping JSON + currency + header_line). Only debits become transactions (`source="statement"`).
+ mapping JSON + currency + header_line + optional account `label`). Only debits become transactions
+ (`source="statement"`). Each import also writes a `statement_imports` row (file, label, period, counts as returned;
+ the CSV isn't kept), listed by `GET /api/statements/imports` (UI revamp Phase 5e).
- Dedupe: each line has an `import_key` (sha1 of user, date, amount, description), so re-importing is safe. Invoice
matching: a debit with the same amount within 3 days and a shared word in the vendor name is "already recorded",
not added. Credits are counted and skipped; unreadable lines are returned by line number.
-- `/import` (frontend, minimal): choose a CSV, adjust the mapping and currency, preview, import, see the result line.
+- `/receipts/import` (frontend): choose a CSV, adjust the mapping, currency and account name, preview, import, see the
+ result line.
diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md
index a80df55..1b98fd6 100644
--- a/docs/direction/STORY.md
+++ b/docs/direction/STORY.md
@@ -385,3 +385,104 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in
never on reload, a rail that doesn't move the conversation, and Caps-Lock-proof ⌘K. Screenshots caught two bugs the
tests then pinned: an unreadable blue selected thread and a 24 px sidebar overflow left over from Phase 1.
+## 32. The owner runs the revamp and Home becomes Origin-like (UI revamp, review round + Phase 5a)
+- **S:** Running Phases 0–4 locally, the owner found the app still felt like the old one: Home didn't resemble the
+ inspiration (Origin's app home), labels used a generic all-caps mono font, only one sidebar group was labelled, the
+ collapse button sat in an empty top bar, and an "Upload an invoice…" chip sent that sentence to the AI as a question.
+ `/spending` had cards that never loaded, and Home then crashed on real data.
+- **T:** Fix what the owner flagged, agree a look for Home and the four inner pages from mockups first, then rebuild
+ Home on one shared page skeleton without breaking the zero layout-shift rule.
+- **A:** Labelled every sidebar group, moved the collapse control into the sidebar header (no desktop top bar), and
+ switched labels to Funnel Sans in sentence case. Upload follow-ups became an upload button; other "do it yourself"
+ steps stay in the answer text. Mocked Home plus Spending, Bills, Budget and Receipts in the Origin style (four
+ mockups built in parallel by subagents) and wrote the Phase 5 spec and plan. Built `PageHeader`/`PageCard`, a
+ monotone curve and collision-free axis ticks, then the new Home: hero chart with "heading for", a day-by-day calendar
+ with amounts and bill names, Lumen found as a list whose links act (`/ask?draft=` types a question without sending
+ it), budget categories and Coming up. Upload and Export went quiet and their dialogs light. Ask fixes ran in
+ parallel in a separate worktree. Root causes found while testing on the owner's data: Home expected a bills shape the
+ API never sent (so bills never showed, and the new calendar crashed), three Spending cards read fields no backend
+ code produces (removed until 5b/5c rebuild them), and the e2e server shared the dev server's `.next` folder.
+- **R:** Backend 979 passed (chat thread and its messages saved in one commit; a clear 409 on a lost approval race).
+ Frontend 128 e2e checks (up from 102): every Home card measured to the same height loading and loaded, ticks that
+ never overlap at 375 px, light dialogs, a draft that never sends itself, and the real reminders shape in the
+ fixtures. Screenshots caught three bugs the tests then pinned: a width observer that never attached, cut-off
+ calendar amounts on phones, and a doubled close button.
+
+## 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.
+
+## 34. Bills & subscriptions you can act on (UI revamp, Phase 5c)
+- **S:** Bills had only the payment calendar and reminders cards, which read fields the backend never sent, and Home's
+ "Coming up" only had rows after a heavy analysis had run. Lumen already detected recurring charges well, but only the
+ agent and Home's signals used it.
+- **T:** One page answering "what's due, what recurs, what can I cut?" from that detection, where telling Lumen
+ "cancelled" or "that's not a bill" is remembered, and noticing when a cancel didn't stick.
+- **A:** A small `/api/bills` (TDD): recurring charges plus a `recurring_states` table keyed by the detection's own
+ normalised vendor, so "NETFLIX.com" and "Netflix com" are one choice; a charge after the cancel day flips the row to
+ "charged again". Home's bills switched to it. Pure maths (`lib/bills-math.ts`: monthly equivalents, projected due days
+ per cadence, a 30-day agenda, the tiles) under unit tests, then the page: tiles, a six-row month calendar that never
+ changes height, Upcoming, and a table whose actions save in place, with Undo for cancelled and hidden vendors and
+ "How to cancel" opening Ask with the question typed. Removed the interim list and the last caller of the old reminders.
+- **R:** Backend 986 passed (5 new: per-user states, cancel → charged again, hide/restore, vendor spelling,
+ validation). Frontend: 10 Bills e2e checks (tiles, the calendar across months without moving, groups, tabs, cancel and
+ undo, hide and undo, the Ask link, CLS under 0.01, tabs on one line at 375 px) and 4 maths tests. A screenshot against
+ the mockup caught the wrapping tabs on phones, now pinned by a test.
+
+## 35. A budget you can plan with (UI revamp, Phase 5d)
+- **S:** Budgets were one limit per category with no history: changing one rewrote every past month, there was no
+ way to look back, and the only place to approve Lumen's draft was a dialog on Home with buttons named just
+ "Approve" / "Skip".
+- **T:** A Budget page that answers "am I on track, and what should my plan be?", where past months stay as they were
+ and the draft lives on the page.
+- **A:** Budgets became versioned by month (TDD): an `effective_from` column, reads that take each category's latest
+ row from that month or before, writes from the current month on, and a migration that dates old rows and swaps the
+ unique constraint (rebuilding the table on SQLite, where a constraint can't be dropped). New `?month=` and
+ `/history` endpoints; approved drafts write from the month they were for; `basis.months` lists only months with
+ spend. Pure maths (`lib/budget-math.ts`), then the page: a pace hero with the even-pace tick and safe per day, six
+ months of budget-vs-actual, a category table edited in place, and the draft panel with category-named buttons. A
+ Plan group joined the sidebar; Home's promo now links to the page.
+- **R:** Backend 993 passed (8 new: last month's limits survive an edit, removal only from this month, history with
+ and without a budget, draft month, bad input, the old-schema migration, basis months). Frontend: 17 Budget e2e
+ checks (hero numbers, month switch read-only, inline edit without the row moving, Esc, add category, edit total,
+ draft skip/approve/approve-remaining/double click/409/`?draft=1`/no history, history bars, CLS under 0.01, phone fit)
+ and 4 maths tests. A phone screenshot showed buttons and bars clipped past the screen edge, which the old
+ "no sideways scroll" check missed; the test now checks every header button, bar and table cell ends on screen.
+
+## 36. One place for everything you've given Lumen (UI revamp, Phase 5e)
+- **S:** Receipts & statements was three old pages as tabs (Documents, Import, Review). The review queue showed raw
+ rule text and a "correct total" box; statement imports left no trace once the result line was gone; and connecting
+ Gmail had no entry point in the app at all since the old dashboard was removed.
+- **T:** One page that answers "what have I given Lumen, and what needs my OK?", with the review as cards that point at
+ the doubtful field, and a history of statement imports.
+- **A:** A `statement_imports` table written by every import (file, optional account name, period, counts as returned)
+ and `GET /api/statements/imports` (TDD). The page: review cards that map each validation flag to the field it doubts
+ and a plain reason, Edit that sends only changed fields, Keep both for duplicates; one library of receipts, review
+ items, documents and imports with chips and search; Statement imports and From your inbox cards (Check now, Pause,
+ Connect with Google). The upload dialog now files PDFs as documents too. The import flow moved to
+ `/receipts/import`; the old routes redirect, and the three old pages were deleted.
+- **R:** Backend 995 passed (2 new: history per import with period and counts, re-import as its own row, per-user,
+ label limit). Frontend: 15 Receipts e2e checks (brief, highlights and reasons, edit-and-approve body, reject and the
+ sidebar badge, empty queue, library mix, chips, search, delete, imports counts, inbox both ways, document upload,
+ import link, redirects, CLS under 0.01, nothing past the edge at 375 px). A screenshot pass fixed raw ISO dates on the
+ cards.
+- **Phase 5 review:** one fresh reviewer read all 43 commits of Phase 5. No Critical issues; 8 Important ones, plus a
+ CSV-formula risk upgraded to Important, all fixed test-first: receipts lost behind a big statement import (now
+ filtered server-side), a past month showing today's overall budget, a stale draft writing into last month, the
+ first night of a month (browser and UTC server a month apart; every budget call now names the browser's month,
+ writes only within one month of the server's), late bills vanishing from "what's due" (now "Not seen yet"), no way
+ to open the original receipt, the wrong error text, an edit that could approve a ₹0 total (now refused by the API
+ too), and spreadsheet formulas in the CSV export. Backend 1004 passed, e2e 207. Nine minors went to the backlog.
+
diff --git a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md
new file mode 100644
index 0000000..4fdb82b
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md
@@ -0,0 +1,313 @@
+# UI revamp Phase 5 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build Home v2 and the Spending, Bills & subscriptions, Budget and Receipts & statements pages to the approved mockups, on one shared page skeleton, with the cheap backend each page needs.
+
+**Architecture:** A small set of shared page pieces (`components/page/*`, `lib/chart.ts`) is built first and used by every page so they can't drift. Each sub-phase (5a–5e) is one stacked PR into `refactor`. Pages keep fetching through `lib/api/client.ts`; new backend routes are FastAPI routers in `backend/api/` like `budget.py`.
+
+**Tech Stack:** Next.js (app router), Tailwind v4 + shadcn tokens (Sky theme), motion, Playwright e2e; FastAPI + SQLAlchemy (Flask-SQLAlchemy models), pytest.
+
+**Spec:** `docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md` (mockups in `docs/superpowers/specs/2026-10-22-phase5/`), parent `docs/superpowers/specs/2026-10-20-ui-revamp-design.md`.
+
+## Global Constraints
+
+- Labels: `.label-ui` (Funnel Sans, sentence case). No all-caps mono, no shadcn default blue `Button` on these pages, no dark dialog in the app.
+- Nothing the data can't back is shown: deferred elements are hidden, never faked (spec §2, §6).
+- A control whose label is an action does that action; it never sends its text to the chat.
+- Zero layout shift: every range/filter/month change, hover-edit and dialog keeps its box; each gets a bounding-box test.
+- Pages work at 375px wide with no sideways scroll; leave `md:pr-12` room for the floating account menu in header rows.
+- Inside `.bui` (Ask) use bui tokens (`bg-hover`, `bg-surface`, `border-line`, `text-ink-2`), never shadcn `accent`.
+- Git: branch names without "claude", no attribution lines in commits or PRs; PRs into `refactor` on coderconnoisseur/Lumen.
+- Backend test env: `OPENROUTER_API_KEY=test-key SECRET_KEY=test-secret-key-for-ci-only SUPABASE_URL=https://example.supabase.co python -m pytest -q` from `backend/`.
+- Frontend lint/build env: `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY=e2e-dummy NEXT_PUBLIC_BACKEND_URL=http://127.0.0.1:9`.
+
+## Review Focus
+
+1. **Empty account** (no transactions, no budget, no bills): every Home card shows its empty state at its loading height; no NaN, "₹0 of ₹0", or a chart line at zero. Pinned by Task 3 and Task 6 empty-state tests.
+2. **Phone width (375px)**: chart ticks never overlap (the reported "01 OC09 OCT"), calendar cells keep amounts readable, header actions wrap under the title without covering the account menu. Pinned by Task 2 (`pickTicks`) and Task 3 / Task 1 bounding-box tests.
+3. **Forecast missing or outside the month** (backend forecasts from the last transaction date): no "heading for" text and no dotted line. Pinned in Task 3.
+4. **Ask prefill** (`/ask?draft=`) must never send by itself, also after a reload or when Ask is already mounted. Pinned in Task 5.
+5. **Ask busy state**: Keep/Delete in the thread rail are disabled while an answer is in flight; deleting the open thread mid-answer must not orphan the answer. Pinned in Task 8.
+
+---
+
+## Phase 5a — shared skeleton, Home v2, dialogs, Ask/§12 fixes (detailed)
+
+Branch: `ui-revamp/phase5-pages` (contains the spec commit; stacked on `ui-revamp/polish`).
+
+### Task 1: Page header, page card, quiet buttons
+
+**Files:**
+- Create: `frontend/src/components/page/page-header.tsx`, `frontend/src/components/page/page-card.tsx`
+- Modify: `frontend/src/components/ui/button.tsx` (variants), `frontend/src/app/(app)/home/_components/card.tsx` (delete; callers import `PageCard`), all Home cards that import `HomeCard`, `frontend/src/app/(app)/home/homeContent.tsx`
+- Test: `frontend/e2e/home.spec.ts`
+
+**Interfaces:**
+- Produces: `PageHeader({ title: ReactNode; brief?: ReactNode; actions?: ReactNode; children?: ReactNode })` — renders ``, actions right-aligned in the same row with `md:pr-12`, brief below in `font-serif text-[19px] text-ink-2`-equivalent (`text-foreground/80`), `children` = a control row under the brief.
+- Produces: `PageCard({ title: ReactNode; href?: string; action?: ReactNode; extra?: ReactNode; loading?: boolean; skeletonHeight: number; className?; "data-testid"?; children })` — same contract as today's `HomeCard` (skeleton at final height, `minHeight` after), restyled: `rounded-2xl border border-border/70 bg-card px-5 py-[18px] shadow-[0_1px_2px_rgba(28,28,26,.04),0_4px_16px_-8px_rgba(28,28,26,.08)]`; title row = `.label-ui` title, `›` after it when `href`, `extra` (legend, pill) then `action` at the right.
+- Produces: `Button` variants `quiet` (`border border-border bg-card/70 text-foreground/80 hover:bg-card hover:text-foreground backdrop-blur`) and `ink` (`bg-foreground text-background hover:bg-foreground/90`); size `pill-icon` (`size-[34px] rounded-full`).
+
+- [ ] **Step 1: Write the failing test** `home header keeps clear of the account menu` in `e2e/home.spec.ts`: at 1440×900 and 1024×800, `signIn`, goto `/home`; the bounding box of `getByRole("button", { name: "Account menu" })` does not intersect the boxes of `getByRole("button", { name: /upload/i })` and `getByRole("button", { name: "Export" })`; `getByRole("heading", { level: 1 })` has `font-size` `26px`.
+- [ ] **Step 2: Run** `npx playwright test e2e/home.spec.ts -g "account menu"` — Expected: FAIL (font-size 24px / names differ).
+- [ ] **Step 3: Implement** `PageHeader`, `PageCard`, the button variants; switch Home's greeting row to `PageHeader` (title = greeting, brief = ``), replace every `HomeCard` import with `PageCard` and delete `card.tsx`.
+- [ ] **Step 4: Run** the new test and the whole `e2e/home.spec.ts` — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: shared page header and card for the Phase 5 pages`.
+
+### Task 2: Chart helpers
+
+**Files:**
+- Create: `frontend/src/lib/chart.ts`
+- Test: `frontend/e2e/home-math.spec.ts` (pure-function tests live here)
+
+**Interfaces:**
+- Produces: `smoothPath(points: [number, number][]): string` — SVG path through every point; monotone cubic (Fritsch–Carlson) so a running total never dips or overshoots between points; 0 points → `""`, 1 point → `"M x y"`.
+- Produces: `pickTicks(candidates: { i: number; x: number; label: string; keep?: boolean }[], minGap: number): typeof candidates` — keeps `keep` ticks (today, first, last) and drops any other tick closer than `minGap` px to a kept one, left to right.
+
+- [ ] **Step 1: Write failing tests** in `e2e/home-math.spec.ts`:
+ - `smoothPath([[0,10],[10,20],[20,20],[30,40]])` starts with `"M0 10"`, contains `"20 20"` and ends with `"30 40"`.
+ - Sampling the cubic segments of `smoothPath([[0,0],[10,100],[20,100],[30,101]])` at t=0.25/0.5/0.75 never gives y > 101 or a y lower than the previous sample (monotone, no overshoot). Parse the `C` commands to compute the samples in the test.
+ - `pickTicks` with x = 0, 20, 40, 300, 310 (`keep` on 0, 300, 310) and `minGap: 48` returns labels for 0, 300, 310 only.
+- [ ] **Step 2: Run** `npx playwright test e2e/home-math.spec.ts` — Expected: FAIL (module not found).
+- [ ] **Step 3: Implement** both in `lib/chart.ts`.
+- [ ] **Step 4: Run** — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: smooth monotone chart path and tick picking`.
+
+### Task 3: Hero spend card v2
+
+**Files:**
+- Modify: `frontend/src/app/(app)/home/_components/spend-card.tsx`
+- Test: `frontend/e2e/home.spec.ts`
+
+**Interfaces:**
+- Consumes: `PageCard`, `smoothPath`, `pickTicks`; existing props of `SpendCard` plus `onUpload: () => void`.
+- Title (`TITLE` map): `"Spent in "` for 1M (month from `period.start`), `"Spent this week"`, `"Spent this quarter"`, `"Spent in "`; `href="/spending"`.
+
+- [ ] **Step 1: Write failing tests** in `e2e/home.spec.ts` (use the existing home fixture with transactions and forecast):
+ - `hero shows delta, percent and the forecast total`: text matches `/\+₹[\d,]+ \(\d+%\) vs \w+ by the \d+(st|nd|rd|th)/` and `/heading for ₹[\d,]+/`.
+ - `no forecast, no heading-for`: fixture forecast dates all in an earlier month → no `heading for` text and no `[data-series="forecast"]`.
+ - `ticks never overlap at phone width`: viewport 375×812; the bounding boxes of all `[data-testid="tick"]` are pairwise non-overlapping.
+ - `the + button opens upload`: click `getByRole("button", { name: "Upload a receipt" })` inside `[data-testid="spend-card"]` → a dialog is visible.
+ - keep the existing range-change bounding-box test green.
+- [ ] **Step 2: Run** `npx playwright test e2e/home.spec.ts -g "hero|ticks|\\+ button"` — Expected: FAIL.
+- [ ] **Step 3: Implement**: number 36px medium; delta line `+₹X (Y%) vs by the ` (bad colour when up, good when down; for 3M/YTD "vs last period"/"vs "); `· heading for ` only when the forecast is drawn; legend (Month / Previous / Forecast) in `extra`; `+` icon button (`aria-label="Upload a receipt"`) in `action`; paths via `smoothPath`; chart height 200px; bill pins become 22px round markers on the x-axis with the vendor's first letter and `title={vendor}`; ticks via `pickTicks` with `data-testid="tick"`, `minGap` 48; range chips centred below.
+- [ ] **Step 4: Run** `e2e/home.spec.ts` — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: Home hero card in the Origin style`.
+
+### Task 4: "Month, day by day" calendar + recent transactions
+
+**Files:**
+- Modify: `frontend/src/app/(app)/home/_components/calendar-card.tsx`, `frontend/src/lib/home-math.ts` (export `ordinal(n)` if Task 3 didn't)
+- Test: `frontend/e2e/home.spec.ts`
+
+**Interfaces:**
+- Consumes: `PageCard`; new prop `reminders: Reminder[]`, `today: Date`.
+- Produces: `vendorChip(name: string): { initials: string; hue: number }` in `lib/home-math.ts` (2 initials, stable hue from the name) — reused by Spending/Bills.
+
+- [ ] **Step 1: Write failing tests**: `calendar marks today and names future bills` — the cell for today's date has `data-today="true"`; a fixture reminder dated after today puts its vendor text in that day's cell; future cells have `data-future="true"`; the card title links to `/spending`; `See all` in Recent transactions links to `/spending`. `vendorChip("Swiggy Instamart")` → `initials: "SI"` and the same hue on two calls (home-math.spec).
+- [ ] **Step 2: Run** — Expected: FAIL.
+- [ ] **Step 3: Implement** per `home.html`: title `", day by day"`, month total + "across N days" (days with spend), 50px cells with day number top and short amount bottom, heat shade as today, today outlined in primary, future cells dashed with up to one bill name; recent rows with a 28px initials chip from `vendorChip`.
+- [ ] **Step 4: Run** — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: day-by-day calendar with amounts and bill names`.
+
+### Task 5: Lumen found as a list, Ask prefill
+
+**Files:**
+- Modify: `frontend/src/app/(app)/home/_components/lumen-found.tsx`, `frontend/src/app/(app)/ask/chatbotContent.tsx`, `frontend/src/components/ask/prompt-bar.tsx` (accept an initial value if it doesn't)
+- Test: `frontend/e2e/home.spec.ts`, `frontend/e2e/ask.spec.ts`
+
+**Interfaces:**
+- Produces: `/ask?draft=` — opens a fresh chat with the composer filled with ``, focused, **not sent**; the URL is cleaned (`router.replace("/ask")`) so a reload doesn't refill. Used by Lumen found "Ask why", Spending, and Bills "How to cancel".
+- Produces: `askWhy(item: FoundItem, money): string` in `lumen-found.tsx` — `"Why " + headline (filled, lower-cased first letter, no trailing period) + "?"`.
+
+- [ ] **Step 1: Write failing tests**:
+ - home: `Lumen found is a list with doing links` — each item is a row with an icon, headline, detail, a primary link to `item.href` and an "Ask why" link whose href starts with `/ask?draft=`.
+ - ask: `draft prefills without sending` — goto `/ask?draft=Why%20is%20Food%20up%3F`; composer value is `Why is Food up?`; no `/chat` POST happened (count requests in the fixture route); URL becomes `/ask`; reload → composer empty.
+- [ ] **Step 2: Run** — Expected: FAIL.
+- [ ] **Step 3: Implement** list layout per `home.html` (32px tinted icon by `kind`: duplicate=bad, spike=warn, price_rise/review=primary; title row pill "N to look at"); `?draft=` handling next to the existing `?q=` effect, guarded by the same `lastQ`-style ref.
+- [ ] **Step 4: Run** — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: Lumen found list with real actions; /ask?draft= prefills`.
+
+### Task 6: Budget card v2, Coming up, Home layout
+
+**Files:**
+- Modify: `frontend/src/app/(app)/home/_components/budget-card.tsx`, `bills-card.tsx`, `homeContent.tsx`
+- Test: `frontend/e2e/home.spec.ts`
+
+**Interfaces:**
+- Consumes: `BudgetState` from `lib/api/client.ts` (unchanged), `PageCard`, `vendorChip`.
+
+- [ ] **Step 1: Write failing tests**:
+ - `budget card shows spent of total, percent and top categories`: with a fixture budget of 4+ categories → text `/₹[\d,]+ of ₹[\d,]+/`, a `%` label, exactly 4 `[data-testid="budget-cat"]` rows ordered by spent desc; an over-budget category row has `data-over="true"`.
+ - `empty account Home`: fixture with no transactions/budget/reminders → no text matching `/NaN|of ₹0\b/`; every card's box height equals its loading skeleton height ±1px (record during loading via a delayed route, compare after).
+ - `coming up lists the next four bills`: 6 upcoming reminders → 4 rows with date chips (weekday + day).
+ - existing draft-budget tests stay green (promo still opens the draft dialog in 5a; 5d moves it).
+- [ ] **Step 2: Run** — Expected: FAIL.
+- [ ] **Step 3: Implement** per `home.html`: layout = hero full width, then `grid lg:grid-cols-[minmax(0,1fr)_380px]`: left = calendar card, Lumen found; right = Budget in , Coming up, Activity (kept at the bottom for now). Budget: big spent of total, green bar + %, then top 4 category bars (green / amber ≥90% / red over).
+- [ ] **Step 4: Run** full `e2e/home.spec.ts` — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: Home v2 layout, budget and coming-up cards`.
+
+### Task 7: Upload and Export restyle, light upload dialog
+
+**Files:**
+- Modify: `frontend/src/components/uploadDialog.tsx`, `frontend/src/components/exportDialog.tsx`, `homeContent.tsx`
+- Test: `frontend/e2e/home.spec.ts`
+
+**Interfaces:**
+- Produces: `DialogDemo` gains `trigger?: ReactNode` (default: ` Upload `) and `open`/`onOpenChange` passthrough so the hero `+` (Task 3) and `?upload=1` open the same dialog. `ExportDialog` default trigger = `Button variant="quiet" size="pill-icon" aria-label="Export"` with a download icon.
+
+- [ ] **Step 1: Write failing tests**: `upload dialog is light` — open it; computed `background-color` of `[role="dialog"]` has luminance > 0.9 (parse rgb); no element inside has class matching `neutral-9|#0b0612`. `Home buttons are quiet` — the Upload button's computed background is not `rgb(37, 99, 235)` (blue-600).
+- [ ] **Step 2: Run** — Expected: FAIL.
+- [ ] **Step 3: Implement**: dialog on Sky tokens (`bg-card`, `border-border`, `text-foreground/muted-foreground`), drop zone `border-dashed border-border bg-muted/40`, file rows `bg-muted/50`; keep all upload logic untouched.
+- [ ] **Step 4: Run** — Expected: PASS.
+- [ ] **Step 5: Commit** `feat: quiet Upload/Export and a light upload dialog`.
+
+### Task 8: Ask and chat §12 fixes
+
+**Files:**
+- Modify: `frontend/src/app/(app)/ask/thread-rail.tsx`, `frontend/src/components/app-sidebar.tsx` (render `AskPalette` outside ``; lift `paletteOpen` into `app-shell.tsx` or a tiny context), `backend/routes/chat.py` (`_thread_for` + `_save_exchange` in one commit), `backend/api/agent.py` (409 text)
+- Test: `frontend/e2e/ask.spec.ts`, `frontend/e2e/shell.spec.ts`, `backend/tests/test_chat_threads.py` (or the existing chat test module), `backend/tests/test_agent_api.py` (existing proposals tests)
+
+**Interfaces:**
+- Backend: `_thread_for(user_id, thread_id, question)` no longer commits; the `/chat` handler commits once after `_save_exchange`. On a lost claim, both 409s say `"This proposal was already decided."`.
+
+- [ ] **Step 1: Write failing tests**:
+ - e2e ask: `rail actions wait for the answer` — with a delayed `/chat` route, while pending the thread's Delete button is disabled; opening the delete confirm moves focus to "Keep"; at 375px with the history sheet open the trash button is visible without hover.
+ - e2e shell: `ctrl+k works on a tablet with the sheet closed` — viewport 768×1024 (below the md sheet breakpoint if 768 is still sheet; else 767), press Control+k → palette dialog visible.
+ - backend: `test_chat_save_failure_leaves_no_empty_thread` — monkeypatch the message save to raise; after the failed POST, the user has 0 threads.
+ - backend: `test_lost_claim_says_already_decided` — approve twice concurrently (or claim then approve) → 409 with detail `"This proposal was already decided."`.
+- [ ] **Step 2: Run** them — Expected: FAIL.
+- [ ] **Step 3: Implement** the four fixes.
+- [ ] **Step 4: Run** `e2e/ask.spec.ts e2e/shell.spec.ts` and backend `pytest -q` — Expected: PASS.
+- [ ] **Step 5: Commit** `fix: Ask rail busy/focus/touch, palette outside the sheet, one-commit chat save, clear 409`.
+
+### Task 9: 5a docs, full checks, PR
+
+- [ ] **Step 1:** STORY.md entry `## 32.` (owner review round + Home v2: what was flagged, what changed, test counts), CODEBASE-GUIDE: `components/page/*`, `lib/chart.ts`, `/ask?draft=`; spec §12: tick the Phase 5 items done in 5a.
+- [ ] **Step 2:** Run backend `pytest -q`, `pytest -q -m eval`; frontend `npm run lint`, `npm run build` (dummy env), `npm run test:e2e`. Expected: all green.
+- [ ] **Step 3:** Screenshot `/home` at 1440 and 375 with seeded fixture data (throwaway `e2e/tmp/shot.spec.ts`) and compare with `home.html` side by side; fix visible drift.
+- [ ] **Step 4:** Push `ui-revamp/phase5-pages`, open PR into `refactor` (or stacked on `ui-revamp/polish` until #96 merges) with manual checks: Home matches the mockup on your seeded account; hero `+` and Upload open the same light dialog; "Ask why" fills Ask without sending; phone width has no overlapping ticks.
+
+---
+
+## Phase 5b — Spending (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 (detailed)
+
+Branch: `ui-revamp/phase5c-bills`, stacked on `ui-revamp/phase5b-spending` (#98). Mockup `bills.html`; spec §4.3 and the builder-check decision on saved states.
+
+### Task C1: recurring API with saved states (backend, TDD)
+- Model `RecurringState` (`recurring_states`: id, user_id, vendor_key = `ai.recurring._norm(vendor)`, state `cancelled|not_a_bill`, changed_at; unique (user_id, vendor_key)); created by `create_all` like `budgets`.
+- `api/bills.py`, router `/api/bills`, registered in `asgi.py`:
+ - `GET /recurring` → `{success, items: [...find_recurring fields + "state": "active"|"cancelled"|"charged_again"], hidden: [{vendor, vendor_key}]}`. `not_a_bill` vendors leave `items` and go to `hidden`; `cancelled` shows `state: "cancelled"` unless `last_charge_date` is after `changed_at`'s date, then `"charged_again"`.
+ - `POST /recurring/state {vendor, state: "cancelled"|"not_a_bill"|"active"}` → upsert (active deletes the row); returns the new GET payload.
+- Tests (`tests/test_api_bills.py`, setup like `test_api_budget.py`): per-user isolation; three monthly Netflix charges listed active; cancel → cancelled; a charge after cancel → charged_again; not_a_bill → in hidden only; active restores; vendor key ignores case/punctuation ("NETFLIX.com" vs "Netflix com").
+
+### Task C2: Home reads bills from the recurring API
+- `billsApi.recurring()` / `billsApi.setState(vendor, state)` in `lib/api/client.ts`; `use-home-data` maps items with `next_due_date` in the next 31 days, not `possibly_stopped`, state active → `Reminder` (`toBills`), replacing `/api/analytics/reminders` (which only had rows after the old analysis ran; spec §12 5c item).
+- e2e fixture serves `/api/bills/recurring` built from `home.reminders` (vendor→vendor, date→next_due_date, amount→typical_amount, monthly) and records state POSTs; Home's bill tests stay unchanged in intent.
+- Tests: Coming up / bill pins / calendar names come from the recurring route; a cancelled or stopped item is not a bill on Home.
+
+### Task C3: the Bills page
+- `app/(app)/bills/` (replaces the interim list): `PageHeader` "Bills & subscriptions" with a ‹ month › switcher; brief ("Rent of ₹X is due . N subscriptions look stopped, about ₹Y a month." from real items only).
+- Three tiles: Due in the next 30 days (sum + count), Recurring every month (monthly equivalents: weekly×52/12, yearly/12; active items only), Subscriptions that look stopped (`possibly_stopped`, count + ₹/month).
+- Month calendar (Monday first) with each item's projected due days in the shown month (monthly: next_due_date's day clamped; weekly: every 7 days from next_due_date; yearly: if the month matches), today outlined, past-due days muted; beside it Upcoming (next 30 days) grouped This week / Next week / Later with group totals.
+- Table with tabs Subscriptions / Bills / All (kind subscription vs bill+recurring): VendorChip, cadence, last charged, next, amount; pills: price rise (from → to), looks stopped, charged again after you cancelled. Actions per row: Mark as cancelled, Not a bill, How to cancel (→ `/ask?draft=How do I cancel ?`). Cancelled rows dimmed in a group at the bottom with Undo; a "Hidden (N)" footer lists not-a-bill vendors with Undo.
+- Tests: tiles' numbers; calendar shows a bill on its projected day for this and next month; tabs filter; Mark as cancelled POSTs and moves the row to Cancelled without the table jumping (bounding box of the table's first row); Not a bill hides it and Undo brings it back; How to cancel href; month switching keeps the calendar box; CLS < 0.01 while loading.
+
+### Task C4: clean-up, docs, checks, PR
+- Remove the interim `bills/upcoming-bills.tsx` and `aiAnalyticsApi.getReminders` if unused.
+- STORY 34, guide (Bills), spec §12 (tick the 5c item).
+- Full checks green, screenshot vs `bills.html` at 1440 and 375, PR stacked on #98.
+
+## Phase 5d — Budget page (detailed)
+
+Branch: `ui-revamp/phase5d-budget`, stacked on `ui-revamp/phase5c-bills` (#99). Mockup `budget.html`; spec §4.4 and the builder-check decision "budgets are versioned by month".
+
+### Task D1: budgets per month (backend, TDD)
+- `Budget.effective_from` = `"YYYY-MM"` (String(7)); unique (user_id, category, effective_from) as `u_budget_user_category_month`. `models/database.py`: `_migrate_budget_versions(engine)` after `_add_missing_columns` (column in `_ADDED_COLUMNS`): fills NULLs with the row's `updated_at` month, drops the old `u_budget_user_category` (Postgres: `DROP CONSTRAINT IF EXISTS`; SQLite: rebuild the table, since its autoindex can't be dropped), creates the new unique index. Idempotent.
+- `api/budget.py`: `limits_in_force(rows, month) -> {key: (category, limit)}` = latest row per case-folded category with `effective_from <= month`; a `monthly_limit <= 0` row is a removal (PUT with `limit: null` writes one for the current month). `upsert_budget(session, user_id, category, limit, month)`.
+- `GET /api/budget?month=YYYY-MM` (default current; 422 on a bad month) → the existing shape for that month. `GET /api/budget/history?months=6` (1–12) → `{success, months: [{month, budget, spent}]}` oldest first ending this month; `budget` = `monthly_budget` or the sum of limits in force, `null` for a past month with no limit in force.
+- Draft: `payload.month` = current month; approving writes rows effective from `payload.month` (current month if missing/bad). `draft_budget`'s `basis.months` lists only months with spend (§12).
+- Tests: `test_api_budget.py` — editing this month leaves last month's limits; history budget/spent per month and `null` before any budget; removal hides the category only from this month on; approve writes `effective_from`; bad month 422; `test_budget.py` — basis months skip an empty month; unique index allows two months; migration on an old-schema SQLite file keeps rows, fills `effective_from`, and allows a second month.
+
+### Task D2: the Budget page (frontend)
+- `budgetApi.get(month?)`, `budgetApi.history(months)`; type `BudgetMonth {month, budget: number|null, spent}`. `lib/budget-math.ts`: `pace(total, spent, today, month)` → `{pct, evenPct|null, left, daysLeft, perDay}` (evenPct/perDay only for the current month); `usedTone(spent, limit)` → `"good"|"warn"|"bad"` (warn ≥ 90%).
+- `app/(app)/budget/` + sidebar **Plan · Budget** between Track and Records. Header "Budget", ‹ month › switcher (next disabled at the current month), actions on the current month only: "Edit budget" (quiet; edits the overall total in place) and "Let Lumen draft" (dark). Brief from real numbers ("You're on track: ₹X under an even pace." / "₹X over an even pace." / "₹X over budget."). Past months read-only.
+- Hero: spent of total, bar with even-pace tick, Left / Days left / Safe per day (= left ÷ days left), pace chart = `CumulativeChart` with the even line passed as the dashed series.
+- By category table: Budget / Spent / Left / Used bar (green, amber ≥90%, red over). Clicking the budget amount (current month) swaps it for an input of the same height: Enter saves (`PUT`), Esc cancels. Footer "Categories add up to X · Y of your Z is unassigned" (only with an overall total) + Add category (inline name + amount row).
+- Draft panel on the page: shows pending `set_budget` proposals (loaded on open, or filed by "Let Lumen draft"; `?draft=1` drafts on arrival). Each line: category, proposed amount, "was X" / "new", basis sentence, Skip / Approve with aria-labels "Skip " / "Approve at ", decided state in place, "Approve remaining N", 409 → "This draft was replaced. Draft again." No month-bars glyph (basis has no per-month amounts; hidden, not faked).
+- Last 6 months: dashed budget outline + actual bar (green under, red over), the current month "so far" with a pale run-rate projection; "N of M months under" over past months that had a budget.
+- Home: the promo becomes a link to `/budget?draft=1` (dialog removed); the budget card title links to `/budget`.
+- Tests (`budget.spec.ts`, `budget-math.spec.ts`): hero numbers and pace tick; month switch requests `?month=` and hides edit actions; inline edit PUTs and the row's box doesn't change; Esc cancels; draft panel approve/skip names, approve-remaining, 409 message; history bars over/under; CLS < 0.01; phone width no sideways scroll. Home's draft-dialog tests move here.
+
+### Task D3: docs, checks, PR
+- STORY 35, guide (Budget), spec §12 ticks (draft accessible names, `basis.months`).
+- Full checks green, screenshot vs `budget.html` at 1440 and 375, PR stacked on #99.
+
+## Phase 5e — Receipts & statements (detailed)
+
+Branch: `ui-revamp/phase5e-receipts`, stacked on `ui-revamp/phase5d-budget` (#100). Mockup `receipts.html`; spec §4.5 and the builder-check decisions on import history and the Statements filter.
+
+### Task E1: statement import history (backend, TDD)
+- Model `StatementImport` (`statement_imports`: id, user_id, file_name, label nullable, period_from/period_to "YYYY-MM-DD" from the readable rows, lines, imported, matched, duplicates, credits, unreadable, created_at); created by `create_all`.
+- `POST /api/statements/import` takes an optional `label` form field (≤60 chars) and writes one row per import with the counts it returns (`matched` "at import"). `GET /api/statements/imports` → `{success, imports: [...]}` newest first, the user's own only.
+- Tests (`tests/test_statements.py` style): an import writes one history row with period and counts; a re-import is a second row with duplicates counted; per-user; label trimmed and optional.
+
+### Task E2: the Receipts & statements page
+- `statementsApi.imports()`, `emailConfigApi.getStatus()` typed. Header "Receipts & statements" with Upload (dialog: Receipts & invoices vs Documents; documents upload via `documentsApi.upload(file, type)`; a line pointing to Import statement for CSVs), Import statement (→ `/receipts/import`, the existing flow plus an optional account label), Connect email (scrolls to the inbox card). Brief: "N receipts need your OK before they count." / "Everything you've given Lumen is filed."
+- **Needs your review** (only when the queue is non-empty): one card per flagged item: vendor, how it came in, amount; fields Vendor / Date / Total (+ Bill no. for duplicates) with the flagged ones highlighted (`total_mismatch` → Total, `duplicate` → Bill no., `bad_date` → Date, `unknown_vendor` → Vendor, `no_currency` → Total); the reason in plain words from the first flag; Approve / Edit (inline vendor/date/total inputs, then Save & approve) / Reject; a duplicate shows "Keep both" instead of Approve. Deciding removes the card and updates the sidebar badge (`REVIEW_DECIDED`). Compare is left out (no "other record" in the payload; hidden, not faked).
+- **Library**: one list of receipts (non-statement transactions, latest 100), flagged review items (status Needs review), documents (with delete) and statement imports; chips All / Receipts & invoices / Statements / Documents (a chip only when it has rows), search over title/vendor/file name, 10 rows then "Show N more". Columns: file (icon, title, kind · file/sender), came from (Upload / Gmail / Import), date, amount, status.
+- Right column: **Statement imports** (label or file name, period, imported date, lines, a new / matched / duplicates / credits bar with counts) and **From your inbox** (connected: address, last check, receipts added, Check now, Pause/Resume; not connected: Connect Gmail).
+- Routes: `/import` → `/receipts/import`; `/review`, `/documents` → `/receipts`; the tabs component, `review/reviewContent.tsx` and `documents/documentsContent.tsx` go (their APIs stay).
+- Tests (`receipts.spec.ts`): review card highlights and reason per flag; approve with edits posts them; duplicate shows Keep both; reject removes the card and the badge drops; library chips and search; statements in the library and the imports card; inbox connected vs not; upload dialog's document path; old routes redirect; CLS < 0.01; phone fit (nothing past the edge).
+
+### Task E3: docs, checks, PR, whole-branch review
+- STORY 36, guide (Receipts), spec §12.
+- Full checks green, screenshots at 1440 and 375, PR stacked on #100. Then the whole-branch review of Phase 5 (5a–5e) on the most capable model; Critical/Important fixed test-first, Minors to §12.
diff --git a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
index 306eef4..f6cf1f5 100644
--- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
+++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
@@ -214,30 +214,47 @@ Small items found while building, parked here so they aren't lost. Tick them off
- [ ] **Phase 6.** The global reduced-motion rule also stops loading spinners. Consider exempting spinners with `motion-safe:` or giving them a static "Loading…" label. *(Phase 0 review)*
- [ ] **Phase 6.** The e2e suite is local and Chromium-only. Add Firefox once Windows allows Playwright's build (`E2E_FIREFOX=1`), and consider running it in CI with the `signIn` fixture. *(Phase 0)*
- [x] ~~**Phase 6.** `(app)/layout` reads the sidebar cookie on the server.~~ Not needed: the sidebar only renders client-side (behind the auth guard), so Phase 1 reads the cookie there and the app routes stay static. *(Phase 1)*
-- [ ] **Phase 5.** Spending and Bills both show the payment calendar and reminders cards in the interim. Remove the duplicate when the pages are redesigned. *(Phase 1 decision)*
+- [x] **Phase 5.** Spending and Bills both show the payment calendar and reminders cards in the interim. Remove the duplicate when the pages are redesigned. *(Phase 1 decision)* *(Done in 5a: those cards read fields the backend never sent and were removed; Bills shows upcoming bills until 5c.)*
- [x] **Phase 3.** The sidebar's review badge downloads every flagged item just to count them. Add a count-only endpoint (e.g. `GET /api/review?status=flagged&count=1`). *(Phase 1 review)*
-- [ ] **Phase 5.** `/spending` stacks two old pages, so it has two ``s and fetches the calendar and reminders twice. It goes away with the redesign. *(Phase 1 review)*
+- [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)*
- [x] **Phase 3.** Replace Home's interim parts: the brief (summary endpoint), Lumen found (subscriptions, spikes, duplicates), Activity (a real feed, including statement imports dated earlier than this month), and wire up Budget and the draft-budget promo. *(Phase 2)*
- [ ] **Later.** Home sub-tabs (Overview / This month / Year). The chart's range chips cover this for now. *(Phase 2 decision)*
-- [ ] **Phase 5.** The Export and Upload buttons on Home still use the old loud blue `Button` and the dark upload dialog. Restyle them with the dialogs. *(Phase 2)*
+- [x] **Phase 5.** The Export and Upload buttons on Home still use the old loud blue `Button` and the dark upload dialog. Restyle them with the dialogs. *(Phase 2)* *(Done in 5a.)*
- [x] **Phase 3.** The calendar's `kShort` hardcodes `₹`; use the user's currency symbol like `useMoney`. *(Phase 2 review)*
- [x] **Phase 3.** `sameDayDelta` only guards a zero previous total. A refund-heavy start of month (negative running total) flips the brief's "lighter"/"heavier". Use `then <= 0`. *(Phase 2 review)*
- [ ] **Phase 6.** `CountUp` paints the final number for one frame before counting from 0. Start at 0 when it will animate. *(Phase 2 review)*
-- [ ] **Phase 6.** Measure each Home card's real content height against its `skeletonHeight` (the spend card's is estimated at about 270px vs 262px); the CLS test threshold of 0.01 can hide a small shift. *(Phase 2 review)*
+- [x] **Phase 6.** Measure each Home card's real content height against its `skeletonHeight` (the spend card's is estimated at about 270px vs 262px); the CLS test threshold of 0.01 can hide a small shift. *(Phase 2 review)* *(Done in 5a at the desktop layout; see the stacked-layout item below.)*
- [x] **Phase 3.** Confirm statement imports write rows before the POST returns. Confirmed: `api/statements.py` commits inside the request. *(Phase 2 review)*
- [ ] **Later (owner request).** Make "Lumen found" and the budget draft smarter. A plain 3-month mean is skewed by one-off big purchases (a phone, a laptop). Ideas: a median or trimmed mean per category; treat single large purchases as one-offs and leave them out of the limit (or suggest a separate "planned purchases" line); use seasonality and income; let the agent (LLM) explain or adjust a draft line, while approval still stays with the user. Measure any new rule against an eval set before switching. *(Phase 3)*
- [x] **Phase 4.** `_decide` (api/agent.py) checks `status == pending` without a conditional UPDATE. Two concurrent approvals on Postgres can both apply (duplicate audit lines), and two approvals for the same new category can return 500 on the unique constraint. Use the conditional `update(...).where(status=="pending")` pattern from `_save_invoice`. *(Phase 3 review)*
- [ ] **Phase 4.** Drafting a budget (HTTP or the agent tool) retires all pending `set_budget` proposals. A draft from Ask while Home's dialog is open makes that dialog's approvals return 409. Two quick drafts can leave two pending sets. Consider scoping by draft id and a per-user lock. *(Phase 3 review; Phase 4: Home's dialog now says "This draft was replaced. Draft again." instead of erroring. Scoping is still open.)*
- [x] **Phase 4.** After approving draft lines, Home reloads only the budget. Also reload the summary so Activity shows "You approved…" at once. *(Phase 3 review)*
- [ ] **Phase 6.** `/api/home/summary` and `/api/budget` each scan all of a user's transactions in Python on every Home visit. Fine for a few thousand rows; filter by date in SQL (and cache) if a user grows past that. *(Phase 3 review)*
-- [ ] **Phase 5.** Draft dialog: per-line buttons are named just "Approve" / "Skip". Add the category to their accessible names. The `basis.months` list always shows 3 months even when only 2 had spend. *(Phase 3 review)*
-- [ ] **Phase 5.** `POST /chat` commits a new thread before saving its messages. If that save fails, the response points at an empty titled thread. Create both in one commit. *(Phase 4 review)*
-- [ ] **Phase 5.** A lost approval race returns 409 "Proposal is already pending" (status read before the claim). Re-read the status or use a generic message. *(Phase 4 review)*
+- [x] **Phase 5.** Draft dialog: per-line buttons are named just "Approve" / "Skip". Add the category to their accessible names. The `basis.months` list always shows 3 months even when only 2 had spend. *(Phase 3 review; 5d: the draft moved to the Budget page with named buttons, and `basis.months` lists only months with spend.)*
+- [x] **Phase 5.** `POST /chat` commits a new thread before saving its messages. If that save fails, the response points at an empty titled thread. Create both in one commit. *(Phase 4 review)* *(Done in 5a.)*
+- [x] **Phase 5.** A lost approval race returns 409 "Proposal is already pending" (status read before the claim). Re-read the status or use a generic message. *(Phase 4 review)* *(Done in 5a: generic "This proposal was already decided.")*
- [ ] **Phase 6.** The `chat_messages.thread_id` index exists only on fresh databases (`_ADDED_COLUMNS` adds the column, not the index). Add `CREATE INDEX IF NOT EXISTS`. Fine at current scale because every query also filters on the indexed `user_id`. *(Phase 4 review)*
-- [ ] **Phase 5.** Thread rail: disable Keep/Delete while an answer is in flight; move focus to "Keep" when the confirm row opens; the trash icon is hover-only, so it's invisible on touch (mobile Sheet). *(Phase 4 review)*
-- [ ] **Phase 5.** Below 768px the sidebar is a Sheet, and `AskPalette` renders inside it, so Ctrl+K on a tablet keyboard does nothing until the sheet is open. Render the palette outside ``. *(Phase 4 review)*
+- [x] **Phase 5.** Thread rail: disable Keep/Delete while an answer is in flight; move focus to "Keep" when the confirm row opens; the trash icon is hover-only, so it's invisible on touch (mobile Sheet). *(Phase 4 review)* *(Done in 5a.)*
+- [x] **Phase 5.** Below 768px the sidebar is a Sheet, and `AskPalette` renders inside it, so Ctrl+K on a tablet keyboard does nothing until the sheet is open. Render the palette outside ``. *(Phase 4 review)* *(Done in 5a: the palette lives in app-shell.)*
- [ ] **Later.** Suggested question "What's waiting for my review?": check the agent can answer it (no review-queue tool). `price_rise` could use its subject ("Why did Netflix go up?"). *(Phase 4 review)*
+- [x] **Phase 5c.** Home's "Coming up" and bill pins come from `/api/analytics/reminders`, which only has rows after the spending-pattern analysis has run (the old Spending page triggers it). Switch Home and Bills to the recurring-charges API in 5c. *(5a, owner report)* *(Done in 5c: Home and Bills read `/api/bills/recurring`.)*
+- [ ] **Phase 6.** Below `lg` the Home cards stack and their placeholders no longer match the loaded heights; the Budget card also grows once a budget exists. Size placeholders per breakpoint, or load the budget with the page. *(5a; not done in 5d, moved to Phase 6)*
+- [ ] **Later.** Budget page: "Safe per day" is left ÷ days left; subtract bills still due this month (the Bills data) once both are on the page. *(5d)*
+- [ ] **Later.** The overall monthly budget (`User.monthly_budget`) isn't versioned like category limits, so history uses today's total for past budgeted months. Version it if people change it often. *(5d)*
+- [ ] **Later.** Draft lines could show a small month-by-month glyph once `basis` carries per-month amounts. *(5d)*
+- [ ] **Later.** Receipts: tell receipts from invoices in the data so the library can have both chips; "Compare" on a duplicate needs the review item to carry the record it duplicates. *(5e)*
+- [ ] **Later.** Receipts library lists the latest 100 receipts; page through `/transactions` if people need older ones there. *(5e)*
+- [x] **Phase 6.** Phase 5 review minors: refunds (negative spend) draw a full bar instead of an empty one (budget hero/categories, Home budget card, Spending by category) — clamp at 0. *(5 review)* Done: bars clamp at 0.
+- [x] **Phase 6.** Bills calendar projects a monthly bill into months before its first charge, and hides cancelled bills in months they really were charged. Project from `last_charge_date` on; show real charges for past months. *(5 review)* Done: shown from the first known charge, and a cancelled/stopped one up to its last; real past charges still not drawn.
+- [x] **Phase 6.** Naive UTC timestamps (`created_at`, `last_successful_poll`) are parsed as local time: "Checked 6 h ago" right after a poll in IST, late uploads dated a day off. Send them with `Z`. *(5 review)* Done client-side (`parseUtc`).
+- [x] **Phase 6.** Spending brief can name a payment bigger than the category's change ("one ₹9,999 payment is most of a ₹2,000 jump"); check the size first. *(5 review)* Done: named only when it is between half and all of the rise.
+- [x] **Phase 6.** Receipts brief says "Everything … is filed" when the review list failed to load; say nothing instead. *(5 review)* Done: says how many files instead.
+- [ ] **Phase 6.** Small layout shifts: "Connect email" joins the header after load (wraps on phones); Budget hero/History taller than their skeletons on phones; a pending draft narrows By category one frame after load on lg. *(5 review)* Not reproduced: phone CLS tests for Budget and Receipts pass (< 0.01); kept as regression tests.
+- [x] **Phase 6.** Home's spike link `?category=` is matched case-sensitively on Spending ("food" rows missed under "Food"). *(5 review)* Done: matched ignoring case.
+- [ ] **Phase 6.** Import page currency starts as USD until the profile loads; an INR user who doesn't notice imports in USD. *(5 review; predates 5e)* Not reproduced: a delayed-profile test starts in INR; kept as a regression test.
+- [x] **Phase 6.** If the budget-versions migration is skipped on Postgres (only a warning), edits to an existing category in a new month hit the old unique constraint and 500. Make that failure loud. *(5 review; migration smoke-tested on Postgres 16 locally)* Done: logged as an error naming the consequence.
+- [ ] **Later.** Spending: the category edit (pencil) is hidden below `sm`, so phones can't recategorise; and "Lumen noticed" wraps beside its pill at 375px. *(5b)*
- [ ] **Owner, manual.** In Firefox, check that the profile/avatar menu doesn't shift the Ask Lumen column, and click through the old pages on light in a demo session. *(Phase 0 PR #89)*
diff --git a/docs/superpowers/specs/2026-10-22-phase5/bills.html b/docs/superpowers/specs/2026-10-22-phase5/bills.html
new file mode 100644
index 0000000..90c53c5
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-22-phase5/bills.html
@@ -0,0 +1,230 @@
+
+Bills mockup
+
+
+
+
+
+
+
+
+
+
+
+
Bills & subscriptions
+
+
‹ October 2026 ›
+
Upload
+
+
NB
+
+
+
Rent of ₹25,000 is due Friday, and your BESCOM bill is three days late . Two subscriptions look unused, about ₹1,618 a month.
+
+
+
Due in the next 30 days
+ ₹51,664
8 bills · biggest is rent on 24 Oct
+
Recurring every month
+ ₹32,383
9 regular charges · yearly ones spread per month
+
Subscriptions you may not use
+ ₹1,618 a month
2 subscriptions · Spotify, Cult.fit
+
+
+
+
+ BESCOM electricity, ₹1,860, was due Sun 19 Oct No payment seen yet. It usually lands within 2 days of the bill.
+
+ Airtel postpaid was paid 3 days late on 14 Oct.
+
+
Mark as paid
+
Remind me tomorrow
+
Not a bill
+
+
+
+
+
+ October, bill by bill
+
Paid Due Paid late Overdue
+
+
+
+
+
+
+ This week₹25,000
+ Fri 24 RentMonthly · Rent & housing ₹25,000
+ Next week₹1,767
+ Mon 27 Jio FiberMonthly · Broadband ₹999
+ Sat 1 NetflixMonthly · Streaming ₹649
+ Sun 2 Spotify unused Monthly · Streaming ₹119
+ Later₹24,897
+ Wed 5 Cult.fit unused Monthly · Fitness ₹1,499
+ Tue 11 Airtel postpaidMonthly · Phone ₹599
+ Fri 14 Amazon PrimeYearly · Subscriptions ₹1,499
+ Sun 16 HDFC credit cardStatement due · varies about ₹21,300
+
+
+
+
+
+ Name Last charged Next Amount
+
+
+
Sp
+
Spotify unused 7 weeks Monthly · since Mar 2024
+
2 Oct 2 Nov
+
₹119a month
+
Mark as cancelled Set reminder How to cancel
+
+
+
Cf
+
Cult.fit unused 5 weeks Monthly · since Jan 2026
+
5 Oct 5 Nov
+
₹1,499a month
+
Mark as cancelled Set reminder How to cancel
+
+
+ N
+ Netflix ₹499 → ₹649 Monthly · price went up in Aug
+ 1 Oct 1 Nov
+ ₹649a month
+ ···
+
+
+ Pr
+ Amazon Prime Yearly · ₹125 a month spread
+ 14 Nov 2025 14 Nov
+ ₹1,499a year
+ ···
+
+
+ Jf
+ Jio Fiber Monthly · broadband
+ 27 Sep 27 Oct
+ ₹999a month
+ ···
+
+
+ YT
+ YouTube Premium looks stopped Monthly · no charge since 9 Aug
+ 9 Aug —
+ ₹149a month
+ Hide Confirm cancelled
+
+
+
+
+
+
+Bills: decisions + open questions
+
+Calendar shows bills, not spend (Home already has spend). Paid/due/late/overdue as soft fills, one bill per cell + "+1" if more.
+Row actions only on flagged subscriptions; calm ones get a ··· menu. OK?
+"How to cancel" opens Ask Lumen with a ready question; Mark cancelled / Set reminder act in place.
+Needs backend: REST route for find_recurring (agent-only today); paid/late/overdue matching per cycle; "unused" (no usage data, only "looks stopped"); saved cancelled/reminder/not-a-bill state; card-bill due dates (amount varies, not detected).
+Quarterly cadence (LIC) isn't detected (weekly/monthly/yearly only). Add?
+
+
+
+
diff --git a/docs/superpowers/specs/2026-10-22-phase5/budget.html b/docs/superpowers/specs/2026-10-22-phase5/budget.html
new file mode 100644
index 0000000..d484157
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-22-phase5/budget.html
@@ -0,0 +1,253 @@
+
+Budget mockup
+
+
+
+
+
+
+
+
+
+
+
+
Budget
+
‹ October 2026 ›
+
+
Edit budget
+
Let Lumen draft
+
+
+
You're on track : ₹8,786 under where an even pace would put you. Shopping is the one line over, mostly the Croma order.
+
+
+
+
Spent in October On track
+
₹62,182 of ₹1,00,000
+
+
62% spent Even pace today: 71%
+
+
Left to spend ₹37,818
+
Days left 9
+
Safe per day ₹4,091 after ₹999 Jio Fiber
+
+
+
+
Pace
+
Actual Even pace to ₹1,00,000
+
+
+
+
+
+
+
+
+
+ Category Budget Spent Left Used
+
+ Shopping₹8,000 ₹16,840 ₹8,840 over
210% · Croma ₹9,999 is most of it
+ Food delivery₹6,000 ₹5,930 ₹70
99%
+ Rent & housing₹25,000 ₹25,000 ₹0
Paid in full · 3 Oct
+ Groceries₹11,000 Enter to save · Esc ₹8,120 ₹2,880
74% at ₹11,000
+ Travel₹5,000 ₹2,140 ₹2,860
43%
+ Bills & utilities₹6,000 ₹1,998 ₹4,002
33% · ₹999 due 27 Oct
+ Entertainment₹3,000 ₹768 ₹2,232
26%
+ Health₹3,000 ₹640 ₹2,360
21%
+ Other₹5,000 ₹746 ₹4,254
15%
+
+
+
+
+
+
+
+ Lumen's draft for November 2 of 6 decided
+ Built from Jul–Sep. Each line is a monthly limit; nothing changes until you approve it.
+
+ Rent & housing ₹25,000no change
+ Same ₹25,000 every month. ✓ Approved
+
+ Shoppingone-off excluded ₹9,000was ₹8,000
+ Based on the Jul–Sep median (₹8,410), excluding a one-off ₹9,999 Croma order on 18 Aug.
+ Skip Approve
+
+ Groceries ₹11,000was ₹12,000
+ Averaged ₹10,620 a month over Jul–Sep, rounded up. You've stayed under ₹12,000 all three months.
+ Skip Approve
+
+ Food delivery ₹6,500was ₹6,000
+ Averaged ₹6,230 over Jul–Sep and rising each month. Approving accepts the trend; skipping keeps ₹6,000.
+ Skip Approve
+
+ Travel ₹4,000was ₹5,000
+ Only Aug and Sep had travel (₹3,640 avg). July had none, so it's left out of the average.
+ Skip Approve
+
+ Health ₹2,000was ₹3,000
+ Averaged ₹1,720 over Jul–Sep, rounded up. Skipped · keeps ₹3,000
+
+
+
+
+
+
+ Last 6 months ›
+
Within budget Over October forecast Budget
+
+
+
+
Track record
+
3 of 5 months under
+
Over in June (travel) and August (the Croma order). October is heading for ₹86,400 .
+
+
+
+
+
+
+
+
+ Budget page: decisions + open questions
+ 1. Draft lives on the page (not a dialog); Approve/Skip carry the category in their accessible names.
+ 2. Outlier basis ("median, excluding a one-off Croma order") and "2 of 3 months had spend" need backend : draft_budget is mean-of-3 today and basis.months always lists 3.
+ 3. Month switcher, pace chart and 6-month history need backend : GET /api/budget is current month only, no history or daily series.
+ 4. Safe per day subtracts known upcoming bills. OK, or plain left ÷ days?
+ 5. Unassigned ₹27,000 shown honestly. Should it nudge to "Other", or stay?
+
+
+
+
diff --git a/docs/superpowers/specs/2026-10-22-phase5/home.html b/docs/superpowers/specs/2026-10-22-phase5/home.html
new file mode 100644
index 0000000..54f8359
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-22-phase5/home.html
@@ -0,0 +1,196 @@
+
+Home v2 mockup
+
+
+
+
+
+
+
+
+
+
+
+
Good evening, Nishant
+
+
+
October is running 34% heavier than September, mostly shopping. Rent of ₹25,000 is due Friday.
+
+
+ Spent in October ›
+
October September Forecast
+
+ ₹62,182
+ +₹15,822 (34%) vs September by the 22nd · heading for ₹86,400
+
+ 1W 1M 3M YTD
+
+
+
+
+
+
+
+
October, day by day ›
+
₹62,182 across 21 days
+
+
+
+
+
FM FreshMart21 Oct · Groceries ₹1,284
+
Sw Swiggy21 Oct · Food delivery ₹436
+
Ub Uber20 Oct · Travel ₹212
+
Cr Croma12 Oct · Shopping ₹9,999
+
Jf Jio Fiber9 Oct · Bills ₹999
+
+
+
+
+
+
+
+
+
FreshMart charged you twice for one order ₹1,284 on 18 Oct, same items, 4 minutes apart.
+
+
+
+
Shopping is ₹11,400 over your usual Mostly one Croma order on 12 Oct (₹9,999).
+
+
+
+
Spotify hasn't been used in 7 weeks ₹119 a month, renews 2 Nov.
+
+
+
+
+
+
+
+ ₹62,182 of ₹1,00,000
+
+
+
Rent & housing ₹25,000 / 25,000
+
Shopping ₹16,840 / 8,000
+
Groceries ₹8,120 / 12,000
+
Food delivery ₹5,930 / 6,000
+
+
+
+
+
+ Fri 24 RentMonthly ₹25,000
+ Mon 27 Jio FiberMonthly ₹999
+ Sat 1 NetflixMonthly ₹649
+ Sun 2 Spotify unused Monthly ₹119
+
+
+
+
+
+
+
+
diff --git a/docs/superpowers/specs/2026-10-22-phase5/receipts.html b/docs/superpowers/specs/2026-10-22-phase5/receipts.html
new file mode 100644
index 0000000..1fbb7e0
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-22-phase5/receipts.html
@@ -0,0 +1,249 @@
+
+Receipts mockup
+
+
+
+
+
+
+
+
+
+
+
+
Receipts & statements
+
+
Upload
+
Import statement
+
Connect email
+
+
+
Four receipts need your OK before they count. The other 42 files are filed.
+
+
+
+
+
+
+
Cr Croma Invoice · uploaded 12 Oct ₹9,999
+
Vendor Croma, Andheri W Date 12 Oct 2026 Items + GST ₹8,474 + ₹1,525 Total ₹9,999
+
Totals don't add up. Items and GST add to ₹9,999; the printed total looks like ₹9,990.
+
Approve Edit Reject
+
+
+
FM FreshMart Receipt · from email 18 Oct ₹1,284
+
Vendor FreshMart, Powai Date 18 Oct 2026 Bill no. FM-55120 Total ₹1,284
+
Looks like a duplicate. FreshMart FM-55120 is already recorded from 4 minutes earlier.
+
Keep both Compare Reject
+
+
+
Am Amazon.in Invoice · uploaded 20 Oct ₹2,349
+
Vendor Amazon Seller Services Date 20/10/26 ? Order 404-8812… Total ₹2,349
+
Date unclear. Printed as 20/10/26; Lumen read it as 20 Oct 2026.
+
Approve Edit Reject
+
+
+
BT Blue Tokai Receipt · photo 21 Oct ₹640
+
Vendor Blue Tokai Coffee Date 21 Oct 2026 Currency none printed Total ₹640
+
New to you. No earlier payments to this vendor, and no currency on the bill (assumed ₹).
+
Approve Edit Reject
+
+
+
+
+
+
+
+ Everything you've given Lumen 46 files
+
+ File Came from Date Amount Status
+ JPG Uber trip receipt Receipt · uber-22oct.jpg Upload 22 Oct ₹212 Reading
+ JPG Blue Tokai Coffee Receipt · IMG_4412.jpg Upload 21 Oct ₹640 Needs review
+ PDF Amazon.in order 404-8812… Invoice · amazon-invoice.pdf Upload 20 Oct ₹2,349 Needs review
+ PNG zomato-order.png Receipt · couldn't read the total Upload 19 Oct — Failed Retry
+ PDF FreshMart bill FM-55120 Receipt · billing@freshmart.in Gmail 18 Oct ₹1,284 Needs review
+ PDF Croma tax invoice Invoice · croma-CR2210.pdf Upload 12 Oct ₹9,999 Needs review
+ PDF Jio Fiber bill, October Invoice · ebill@jio.com Gmail 9 Oct ₹999 Done
+ CSV HDFC Savings, September Statement · 142 lines Import 3 Oct — Done
+ PDF Rental agreement, Powai flat Contract · 14 sections indexed Upload 1 Oct ₹25,000/mo Done
+
+
+
+
+
+
+
+
+
HD HDFC Savings ··4417 1–30 Sep · imported 3 Oct 142 lines
+
+
97 new 21 matched receipts 3 duplicates 21 credits skipped
+
+
+
IC ICICI Amazon Pay card 1–30 Sep · imported 4 Oct 58 lines
+
+
44 new 11 matched 3 credits skipped
+
+ October statements are usually out by 5 Nov. Lumen will remind you.
+
+
+
+
+
+ G nishant.b@gmail.comChecked 12 min ago · 11 receipts this month
+ Check now Pause
+
+
+
+
+
+
+
+
+State: upload dialog open
+
+
+
+
+
Upload to Lumen
+
Lumen reads each file, fills in the details and asks you only when it isn't sure.
+
+
Receipts & invoices Read the vendor, date and total, then add to spending
+
Documents Contracts, policies, POs. Indexed so Ask Lumen can quote them
+
+
+
+
Drop files here
+
or browse your computer · PDF, PNG or JPG, several at once
+
+
+
PDF croma-CR2210.pdf Croma · ₹9,999 · 1 thing to check
Needs review
+
JPG freshmart-21oct.jpg Reading the receipt…
64%
+
PDF amazon-invoice.pdf Waiting
Queued
+
HEIC IMG_4420.heic HEIC isn't supported yet. Export it as JPG and try again.
Remove
+
+
+
+
Open questions for the owner
+1. Review cards replace the old Review tab; Approve on a duplicate becomes "Keep both". OK?
+2. Field highlights come from the existing validation flags (total, date, vendor, currency). True per-field confidence is needs-backend .
+3. Library merges RAG documents + invoices into one list with a status pill. Persisted "Reading" state and Retry are needs-backend (extraction is synchronous today).
+4. Statement imports history (bank, period, counts) is needs-backend ; import returns counts but nothing is stored. PDF statements too: CSV only today.
+5. List view over grid: calmer and scans by amount. Grid toggle kept for photo receipts.
+
+
+
+
diff --git a/docs/superpowers/specs/2026-10-22-phase5/shell.css b/docs/superpowers/specs/2026-10-22-phase5/shell.css
new file mode 100644
index 0000000..ebc5adb
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-22-phase5/shell.css
@@ -0,0 +1,72 @@
+/* Shared mockup shell for the Phase 4.5 polish + Phase 5 pages. Sky palette from globals.css. */
+*{box-sizing:border-box;margin:0;padding:0}
+:root{
+ --bg:#f5f4f0;--side:#f0efea;--card:#fff;--line:#e7e5df;--line2:#efede8;
+ --text:#1c1c1a;--ink2:#46453f;--mute:#7a7871;--faint:#a8a69f;
+ --acc:#2f6fe0;--accSoft:#e4edfc;--acc2:#7fa8ee;
+ --good:#2e8b57;--goodSoft:#e3f1e8;--warn:#b7791f;--warnSoft:#faf0dc;--bad:#c4572f;--badSoft:#fae6dd;
+ --chip:#ecebe6;
+ --band:linear-gradient(180deg,#d3e4fb 0%,#e8f0fb 50%,rgba(245,244,240,0) 100%);
+ --shadow:0 1px 2px rgba(28,28,26,.04),0 4px 16px -8px rgba(28,28,26,.08);
+}
+body{background:#d9d8d3;font-family:'Funnel Sans',system-ui,sans-serif;color:var(--text);font-size:14px;-webkit-font-smoothing:antialiased}
+.num{font-variant-numeric:tabular-nums}
+.serif{font-family:Newsreader,serif;font-weight:400}
+
+.app{display:flex;width:1440px;min-height:1000px;background:var(--bg);margin:0 auto}
+
+/* sidebar */
+.side{width:232px;flex:none;background:var(--side);border-right:1px solid var(--line);padding:14px 10px 12px;display:flex;flex-direction:column;align-self:stretch}
+.brand{display:flex;align-items:center;gap:9px;padding:4px 8px 18px;font-weight:600;font-size:16.5px;letter-spacing:-.01em}
+.brand img{width:22px;height:22px}
+.brand .col{margin-left:auto;width:26px;height:26px;border-radius:7px;display:grid;place-items:center;color:var(--mute);opacity:.0;transition:opacity .15s}
+.side:hover .brand .col,.brand .col.show{opacity:1}
+.brand .col:hover{background:var(--chip)}
+.grp{padding:14px 10px 5px;font-size:11.5px;font-weight:500;color:var(--faint);letter-spacing:.02em}
+.it{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;color:var(--ink2);font-size:13.5px}
+.it svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.6;opacity:.75}
+.it.on{background:var(--card);color:var(--text);font-weight:500;box-shadow:var(--shadow)}
+.it.on svg{opacity:1;stroke:var(--acc)}
+.it .n{margin-left:auto;font-size:11px;font-weight:600;background:var(--accSoft);color:var(--acc);border-radius:10px;padding:1px 7px}
+.it .new{margin-left:auto;font-size:10.5px;color:var(--acc)}
+.foot{margin-top:auto;display:flex;flex-direction:column;gap:8px}
+.askbox{display:flex;align-items:center;gap:9px;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:9px 11px;color:var(--mute);font-size:13px}
+.askbox svg{width:15px;height:15px;stroke:var(--acc);fill:none;stroke-width:1.6}
+.askbox kbd{margin-left:auto;font:11px 'Funnel Sans';color:var(--faint);border:1px solid var(--line);border-radius:5px;padding:1px 5px}
+.me{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:10px}
+.me:hover{background:var(--chip)}
+.av{width:28px;height:28px;border-radius:50%;background:linear-gradient(135deg,#6c93d8,#2f6fe0);color:#fff;display:grid;place-items:center;font-size:11.5px;font-weight:600}
+.me b{font-weight:500;font-size:13px;display:block}
+.me small{color:var(--mute);font-size:11.5px}
+.me .chev{margin-left:auto;color:var(--faint)}
+
+/* main */
+.main{flex:1;min-width:0;position:relative}
+.band{position:absolute;inset:0 0 auto 0;height:420px;background:var(--band);pointer-events:none}
+.wrap{position:relative;padding:26px 36px 48px;max-width:1208px}
+.label{font-size:12.5px;font-weight:500;color:var(--mute);letter-spacing:.01em}
+.card{background:var(--card);border:1px solid var(--line2);border-radius:16px;padding:18px 20px;box-shadow:var(--shadow)}
+.ch{display:flex;align-items:center;gap:8px;margin-bottom:12px}
+.ch a{margin-left:auto;font-size:12.5px;color:var(--mute);text-decoration:none}
+.ch a:hover{color:var(--text)}
+.btn{display:inline-flex;align-items:center;gap:7px;height:34px;padding:0 13px;border-radius:9px;font:500 13px 'Funnel Sans';border:1px solid var(--line);background:rgba(255,255,255,.7);color:var(--ink2);backdrop-filter:blur(8px)}
+.btn svg{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:1.6}
+.btn.pri{background:var(--text);color:#fff;border-color:var(--text)}
+.chips{display:inline-flex;gap:2px;background:rgba(255,255,255,.55);border:1px solid var(--line2);border-radius:9px;padding:2px}
+.chips span{font-size:12px;font-weight:500;padding:4px 10px;border-radius:7px;color:var(--mute)}
+.chips .on{background:var(--card);color:var(--text);box-shadow:var(--shadow)}
+.pill{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:500;border-radius:20px;padding:2px 9px}
+.pill.good{background:var(--goodSoft);color:var(--good)}
+.pill.warn{background:var(--warnSoft);color:var(--warn)}
+.pill.bad{background:var(--badSoft);color:var(--bad)}
+.pill.acc{background:var(--accSoft);color:var(--acc)}
+.mute{color:var(--mute)}
+.row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--line2)}
+.row:last-child{border:0}
+.logo{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:12px;font-weight:600;flex:none}
+.amt{margin-left:auto;font-variant-numeric:tabular-nums;font-weight:500}
+.bar{height:6px;background:var(--chip);border-radius:6px;overflow:hidden}
+.bar i{display:block;height:100%;border-radius:6px}
+svg text{font:10.5px 'Funnel Sans';fill:var(--faint)}
+.note{position:fixed;right:16px;bottom:16px;max-width:340px;background:#1c1c1a;color:#eee;font-size:12.5px;line-height:1.5;padding:12px 14px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.25)}
+.note b{color:#fff}
diff --git a/docs/superpowers/specs/2026-10-22-phase5/spending.html b/docs/superpowers/specs/2026-10-22-phase5/spending.html
new file mode 100644
index 0000000..48a2993
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-22-phase5/spending.html
@@ -0,0 +1,272 @@
+
+Spending mockup
+
+
+
+
+
+
+
+
+
+
+
+
Shopping is most of the jump, and one Croma order is ₹9,999 of it.
+
+
+
This month Last month 3M YTD Custom
+
Compare with September
+
1 – 22 Oct 2026 · vs 1 – 22 Sep
+
+
+
+
+
Spent in October
+
October September
+
₹62,182
+
+₹15,822 (34%) vs September by the 22nd · 214 transactions
+
+
+
+
Month by month
+
₹71,720 avg, May–Sep
+
Shown as bars when you pick 3M or YTD. October so far is under your average.
+
+
+
+
+
+
+ By category Filtering transactions: Shopping Clear
+ Category Share of October Spent Share vs Sep
+
+
+
+
+
+ Lumen noticed 4 to look at
+
+
+
FreshMart charged you twice ₹1,284 on 18 Oct, same items, 4 minutes apart.
+
+
+
+
Shopping is ₹11,400 over usual Mostly one Croma order on 12 Oct (₹9,999).
+
+
+
+
Netflix went up to ₹649 Was ₹499 until September. Next charge 1 Nov.
+
+
+
+
First time at Decathlon ₹899 on 17 Oct. Filed under Shopping.
+
+
+
+
+
+
+ Transactions
+
+
+ Date Merchant Category Source Amount
+
+
+
+
+
+
+
+ RK R. Kulkarni (rent)1 payment · Rent & housing ₹25,000
+ Cr Croma1 order · Shopping ₹9,999
+ FM FreshMart6 visits · Groceries ₹6,420
+ Am Amazon4 orders · Shopping ₹3,744
+ Sw Swiggy9 orders · Food delivery ₹3,480
+ Zo Zomato6 orders · Food delivery ₹2,450
+ Ub Uber11 rides · Travel ₹2,160
+
+
Spending: decisions + questions
+1. Categories as horizontal bars, not a donut: rent (40%) would swallow a donut. Click a row to filter the table; Clear undoes.
+2. Calendar + reminders moved out to Bills. Spending has no due dates.
+3. Month bars live in the hero inset; for 3M/YTD they become the main chart and the cumulative line hides.
+4. "First time at merchant" is not a finding kind yet (backend has duplicate / spike / price_rise / review). Add it, or drop the card?
+5. Export: CSV of the filtered list only, or the whole period?
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md b/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md
new file mode 100644
index 0000000..b956db2
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-22-ui-revamp-phase5-design.md
@@ -0,0 +1,181 @@
+# UI revamp, Phase 5: Home v2 and the inner pages
+
+Addendum to `2026-10-20-ui-revamp-design.md` (IA, Sky palette, motion, zero layout-shift rule and §12 backlog still
+apply). Approved by the owner on 2026-10-22 from the mockups in `2026-10-22-phase5/` (open the `.html` files in a
+browser; `shell.css` is the shared style). The dark "decisions + questions" box on each mockup is a review aid, not UI.
+
+## 1. Why
+
+The owner ran Phases 0–4 locally and found the app still felt like the old one with a new coat: Home didn't resemble
+the inspiration (Origin's app home), labels used a generic all-caps mono font, sidebar groups weren't marked, and some
+chips sent text to the AI when they should have done something. The inner pages (Spending, Bills, Receipts) are still
+the old pages stacked inside new URLs, and Budget has no page. Phase 5 makes every main page look and behave like one
+calm product: "not too pushy with info, not too implicit".
+
+The shell fixes (labelled groups, collapse in the sidebar header, no desktop top bar, `.label-ui` sentence-case labels,
+upload chip in Ask) shipped first in PR #96 and are the baseline for this phase.
+
+## 2. Goals, non-goals, anti-goals
+
+**Goals**
+- Home v2 and four pages (Spending, Bills & subscriptions, Budget, Receipts & statements) built to the mockups.
+- Budget becomes a page with a **Plan** sidebar group (Plan · Budget), between Track and Records.
+- One shared page skeleton so the pages can't drift: header (title, one-line serif brief, quiet actions), cards,
+ labels, number style, the smooth area chart, the calendar grid.
+- The cheap backend pieces each page needs (§5), and the §12 items tagged Phase 5 (§7).
+- Zero layout shift: every range/filter/month change, hover-edit and dialog keeps its box (bounding-box or CLS tests,
+ as in Phases 0–4).
+
+**Non-goals**
+- Forecast page (Plan · Forecast) and Phase 6 performance work.
+- Smarter "Lumen found" and budget drafts (median/trimmed mean, one-off detection): stays §12 *Later*, gated on an eval
+ set first (owner request). The draft UI is built so a "basis" sentence can carry it later.
+- Paid-vs-overdue matching of bills to payments, per-field extraction confidence, "unused" (usage) detection, PDF bank
+ statements, async extraction states (Reading / Failed + Retry): deferred, see §6.
+
+**Anti-goals** (would be a failure even if it "works")
+- Showing anything the data can't back: a deferred element is **hidden**, never faked or filled with placeholders.
+- Loud saturated blue buttons, all-caps mono labels, a dark dialog inside the light app.
+- A chip or button whose label is an action ("Upload…", "Mark as cancelled") that sends text to the chat instead.
+- Layout jumps when data loads, filters change or a row enters edit mode.
+
+## 3. Shared skeleton (all pages)
+
+As in `home.html`:
+- **Header row:** sans 26px `h1`, quiet actions on the right (`Upload` as a light button, icon buttons for export/help),
+ leaving room for the floating account menu (`md:pr-12`). Below it a one-line Newsreader brief built from real numbers
+ (same style as Home's brief; one emphasised phrase in italic accent).
+- **Cards:** white, hairline border, 16px radius, very soft shadow; title row = `.label-ui` + optional `›` link + optional
+ small `+` action on the right. One strong number per card, tabular numerals.
+- **Charts:** smooth (monotone/Catmull-Rom) area line in accent with a soft gradient, previous period dashed grey,
+ forecast dotted light accent, today dot, bill pins as small round markers on the x-axis; y labels right-aligned;
+ sparse x ticks (no overlap at any width; fixes the "01 OC09 OCT" overlap on phones).
+- **Range/period chips** centred under the chart (Home) or in a segmented control under the header (pages).
+- **Buttons:** `.btn` quiet (light, hairline) or dark primary for the one main action of a page. The shadcn `Button`
+ default blue is not used on these pages; Export/Upload get restyled (§12).
+- **Dialogs:** light (Sky tokens), including the upload dialog.
+
+Implementation is the builder's call (a `PageHeader` + a few card primitives in `components/page/` is the expected
+shape); the rule is that the five pages use the same pieces.
+
+## 4. Pages
+
+### 4.1 Home v2 (`home.html`)
+Greeting row; brief; **hero card "Spent in ›"** (big number, delta vs previous period with sign colour, "heading
+for ₹X" from the forecast, legend, `+` = upload, chart, range chips 1W/1M/3M/YTD); then a two-column grid:
+- left: **", day by day"** card = month total + calendar with the amount in each day cell (heat shade, today
+ outlined, future days dashed with known bills named) beside **Recent transactions** (5, logo chip, date · category,
+ amount, "See all" → Spending); **Lumen found** as a quiet list (icon, headline, detail, two action links that *do*
+ things: e.g. Review duplicate → the review item, See in Spending → filtered Spending, Ask why → Ask with the question
+ filled).
+- right: **Budget in ** (spent of total, green bar + %, top 4 category bars; no budget → the draft promo linking to
+ the Budget page); **Coming up** (next 4 bills with date chips); Activity stays only if it fits, else moves to
+ Receipts.
+Everything on Home already has data except per-category budget bars (exists via `/api/budget`) — no new endpoint.
+
+### 4.2 Spending (`spending.html`)
+Answers "where did my money go, and is anything off?". Header + period control (This month / Last month / 3M / YTD /
+Custom) + "Compare with " toggle + date range. Hero: total, delta, cumulative line vs previous; inset "Month
+by month" bars (last 6 months, average). For 3M/YTD the bars become the main chart (owner may revisit).
+Row 2: **By category** (horizontal bars: amount, share, change vs previous; click a row = filter the table, "Clear";
+"N transactions still need a category → Sort them now") and **Lumen noticed** (findings from `/api/home/summary`
+kinds: duplicate, spike, price_rise, review; each with real actions + "Ask Lumen why"). The mockup's "First time at a
+merchant" card is **dropped** (no such finding kind).
+Row 3: **Transactions** table (search, category chip filter, source filter, date/merchant/category pill/source/amount,
+hover pencil → category menu; "Always file here" only if a rule store exists — otherwise omit) and **Top
+merchants**. Export = CSV of the current filtered view.
+Removes the duplicated payment calendar + reminders and the two ``s (§12).
+
+### 4.3 Bills & subscriptions (`bills.html`)
+Answers "what's due, what recurs, what can I cut?". Header + month switcher. Three tiles: due in next 30 days, recurring
+per month, "subscriptions that look stopped" (renamed from "you may not use": we only know charges stopped, not usage).
+**Month calendar** with bill names/amounts on due days (states shown: upcoming, today; paid/late/overdue states are
+deferred) beside an **Upcoming** agenda grouped This week / Next week / Later with group totals. **Subscriptions table**
+(tabs Subscriptions / Bills / All): logo chip, cadence, last charged, next charge, amount, pills for price rise and
+"looks stopped". Actions: **Mark as cancelled**, **Not a bill** (both saved, §5), **How to cancel** → opens Ask with
+"How do I cancel ?" typed but not sent (owner choice). "Set reminder" and the overdue strip are deferred.
+Bills owns the calendar and reminders; Spending no longer shows them.
+
+### 4.4 Budget (`budget.html`) — new page, sidebar Plan · Budget
+Answers "am I on track, and what should my plan be?". Header + month switcher, "Edit budget" (quiet) and "Let Lumen
+draft" (dark). Hero: spent of total, green bar with an even-pace tick, left / days left / safe per day (= left ÷ days
+left; subtracting known upcoming bills is the builder's call if the bills data is already loaded), pace chart (actual
+vs straight line to the budget). **By category** table: budget, spent, left, bar (green / amber ≥90% / red over), hover
+→ inline edit of the amount (Enter saves, Esc cancels, no row height change), footer "categories add up to X, Y
+unassigned" + Add category. **Draft panel on the page** (owner choice; replaces the dialog): each line shows proposed
+amount, old limit, basis sentence, a small month-bars glyph, Approve / Skip with the category in the accessible name,
+decided state in place, "Approve remaining N". Home's promo links here. **Last 6 months**: budget outline vs actual
+bars (green under / red over), current month as "so far" + forecast.
+
+### 4.5 Receipts & statements (`receipts.html`)
+Answers "what have I given Lumen, and what needs my OK?". Header actions Upload / Import statement / Connect email.
+**Needs your review** row of cards (only when the queue is non-empty; count = sidebar badge): extracted fields with the
+doubtful ones highlighted from existing validation flags (`total_mismatch`, `duplicate`, `bad_date`, `unknown_vendor`,
+`no_currency`), a plain-words reason, Approve / Edit / Reject (duplicate: "Keep both" / Compare / Reject). This replaces
+the old Review tab. **Library** list (filter chips All / Receipts / Invoices / Statements / Contracts, search, list view
+default; grid toggle optional): file icon, title, came from (Upload / Gmail / Import), date, amount, status (Needs
+review, Done; Reading/Failed deferred). Right column: **Statement imports** history (bank/file, period, lines, new /
+matched / duplicates / credits skipped bar) and **From your inbox** (Gmail status, Check now, Pause). **Light upload
+dialog**: choose Receipts & invoices vs Documents, drop zone, accepted types, per-file progress rows, pointer to Import
+statement for CSVs.
+
+## 5. Backend in scope ("build what's cheap", owner choice)
+- `GET /api/bills/recurring`: wraps `ai/recurring.find_recurring` (vendor, cadence, typical amount, last/next date,
+ bill vs subscription, price change, looks-stopped) minus vendors the user dismissed; plus
+ `POST /api/bills/recurring//state` with `cancelled | not_a_bill | active` saved per user (new small table).
+ Home's bills card and the Bills page both use it.
+- `GET /api/budget?month=YYYY-MM` and `GET /api/budget/history?months=6` (budget vs actual per month). Daily cumulative
+ series for the pace chart comes from the transactions the page already fetches (no new endpoint unless that is
+ heavy).
+- Statement import history: persist one row per import (file name, account/bank label if given, period from the
+ rows, counts already returned by the import); `GET /api/statements/imports`.
+- §12 backend items: `POST /chat` creates the thread and its messages in one commit; the lost approval race returns a
+ generic "This proposal was already decided" (re-read status after a failed claim); `basis.months` lists only months
+ that had spend.
+- Everything new: per-user scoping, rate limits like the neighbouring routes, tests first.
+
+**Decisions made after the builder check (agent's calls, owner can overrule):**
+- **Budgets are versioned by month.** Today `Budget` is one `monthly_limit` per category. Add an `effective_from`
+ month (migration; existing rows get the month they were created). A month's budget = for each category, the latest
+ row with `effective_from <= month`. Editing (inline or Edit budget) writes a row effective from the **current**
+ month, so past months keep what they were; approving a draft "for November" writes rows effective from November.
+ Past months are read-only in the UI. History therefore compares each month with the limits that applied then; months
+ before any budget show actual spend only (no outline). `User.monthly_budget` stays the overall total fallback.
+- **Saved recurring states are keyed by the normalised vendor** (`ai/recurring._norm`, the same key `find_recurring`
+ groups by); the route takes the display name and normalises it. `not_a_bill` hides the vendor from every recurring
+ list (a "Hidden" tab or footer link lets you undo). `cancelled` keeps the row in a dimmed "Cancelled" group at the
+ bottom; if a charge from that vendor appears **after** the cancel date, the state is ignored and the row shows a
+ "charged again after you cancelled" pill.
+- **Library "Statements" filter** lists the new import-history rows as library items (type Statement, came from
+ Import, no file to open since CSVs aren't kept). The chip shows only when there is at least one.
+- **Import history starts with the new table** (older imports don't appear). Counts are stored as the import returned
+ them; a re-import of the same file is a new history row and its "matched" count may repeat, labelled "at import".
+
+## 6. Deferred (hidden until built; recorded in §12)
+Paid / paid-late / overdue matching per bill cycle and the overdue strip; "Set reminder"; usage-based "unused";
+quarterly cadence detection; credit-card bill due dates from statements; per-field extraction confidence; persisted
+Reading/Failed + Retry; PDF statement import; "Approve all high-confidence"; category rules ("Always file X here");
+median/outlier budget drafts (eval set first); Spending's "first time at a merchant" finding.
+
+## 7. §12 items folded in
+Duplicate calendar/reminders and the double `` on Spending; Export/Upload restyle + light upload dialog; draft
+buttons' accessible names + `basis.months`; `POST /chat` single commit; generic 409 text; thread rail (disable
+Keep/Delete while busy, focus "Keep" when confirm opens, trash visible on touch); AskPalette rendered outside
+`` so Ctrl+K works on tablets with the sheet closed.
+
+## 8. Delivery
+Stacked PRs into `refactor`, each green and checkable on its own, in this order (later ones may depend on earlier):
+1. **5a** shared skeleton + Home v2 + Export/Upload restyle + light upload dialog + Ask/§12 frontend fixes.
+2. **5b** Spending.
+3. **5c** Bills (+ recurring API and saved states).
+4. **5d** Budget page + Plan group (+ month/history API, draft panel, §12 draft items).
+5. **5e** Receipts & statements (+ import history API, review cards replacing the Review tab).
+Each PR: TDD per task, layout-shift tests for its controls, a manual-check list for the owner, a STORY entry, and the
+codebase guide updated. One whole-branch review at the end of the phase; Critical/Important fixed test-first, Minors to
+§12.
+
+## 9. Left to the builder
+Component boundaries and file layout; chart implementation (existing chart code vs a small SVG helper); exact
+breakpoints (pages must work at phone width with no sideways scroll); empty and loading states in the established
+skeleton style; whether Activity stays on Home; how the brief sentence is phrased per page as long as it uses real
+numbers only.
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 4b01206..ee88db3 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -8,6 +8,7 @@
# next.js
/.next/
+/.next-e2e/
/out/
# production
diff --git a/frontend/e2e/ask.spec.ts b/frontend/e2e/ask.spec.ts
index 3aa7b42..9d434eb 100644
--- a/frontend/e2e/ask.spec.ts
+++ b/frontend/e2e/ask.spec.ts
@@ -73,6 +73,28 @@ test("deleting a thread removes it", async ({ page }) => {
await expect(rail(page).getByRole("button", { name: /^Bills due/ })).toHaveCount(0);
});
+test("rail actions wait for the answer", async ({ page }) => {
+ await openAsk(page, { ...old, delayMs: 1500 });
+ await rail(page).getByRole("button", { name: "Delete chat: Bills due" }).click();
+ await expect(rail(page).getByRole("button", { name: "Keep" })).toBeFocused();
+ await composer(page).fill("Slow one");
+ await composer(page).press("Enter");
+ await expect(rail(page).getByRole("button", { name: "Keep" })).toBeDisabled();
+ await expect(rail(page).getByRole("button", { name: "Confirm delete" })).toBeDisabled();
+ await expect(rail(page).getByRole("button", { name: "Delete chat: Food last month" })).toBeDisabled();
+ await expect(page.getByText("Answer to: Slow one")).toBeVisible();
+ await expect(rail(page).getByRole("button", { name: "Keep" })).toBeEnabled();
+});
+
+test("the trash button shows without hover at phone width", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ await openAsk(page, old);
+ await page.getByRole("button", { name: "History" }).click();
+ const trash = rail(page).getByRole("button", { name: "Delete chat: Bills due" });
+ await expect(trash).toBeVisible();
+ await expect(trash).toHaveCSS("opacity", "1");
+});
+
test("handoff sends once", async ({ page }) => {
const posts: string[] = [];
page.on("request", (r) => r.method() === "POST" && r.url().endsWith("/chat") && posts.push(r.postDataJSON().query));
@@ -163,3 +185,73 @@ test("the send button looks different when it can send", async ({ page }) => {
const lum = (c: number[]) => (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * (c[3] / 255) + 244 * (1 - c[3] / 255);
expect(Math.abs(lum(on) - lum(off))).toBeGreaterThan(80); // a dark filled button vs a faint one on the light page
});
+
+test("an upload suggestion opens the upload dialog instead of asking Lumen", async ({ page }) => {
+ await openAsk(page, { followUp: "Upload an invoice or receipt to get started." });
+ await composer(page).fill("Do I have invoices?");
+ await composer(page).press("Enter");
+ const chip = page.getByRole("link", { name: "Upload an invoice or receipt to get started" });
+ await expect(chip).toHaveAttribute("href", "/home?upload=1");
+ await chip.click();
+ await expect(page).toHaveURL(/\/home\?upload=1$/);
+ await expect(page.getByRole("dialog")).toBeVisible();
+});
+
+test("a question suggestion is still sent to Lumen", async ({ page }) => {
+ await openAsk(page, { followUp: "Show last month by category" });
+ await composer(page).fill("How much did I spend?");
+ await composer(page).press("Enter");
+ await page.getByRole("button", { name: "Show last month by category" }).click();
+ await expect(page.getByText("Answer to: Show last month by category")).toBeVisible();
+});
+
+test("draft prefills the composer without sending", async ({ page }) => {
+ const posts: string[] = [];
+ page.on("request", (r) => r.method() === "POST" && r.url().endsWith("/chat") && posts.push("x"));
+ await openAsk(page, {}, "/ask?draft=Why%20is%20Food%20up%3F");
+ await expect(composer(page)).toHaveValue("Why is Food up?");
+ await expect(composer(page)).toBeFocused();
+ await expect(page).toHaveURL(/\/ask$/);
+ await page.waitForTimeout(500);
+ expect(posts).toHaveLength(0);
+ await page.reload();
+ await expect(composer(page)).toHaveValue("");
+});
+
+test("a sent draft doesn't come back in the follow-up box", async ({ page }) => {
+ await openAsk(page, {}, "/ask?draft=Why%20is%20Food%20up%3F");
+ await composer(page).press("Enter");
+ await expect(page.getByText("Answer to: Why is Food up?")).toBeVisible();
+ await expect(composer(page)).toHaveValue("");
+});
+
+test("a late answer can't take over a chat started after it", async ({ page }) => {
+ const posts: { query: string; thread_id?: string | null }[] = [];
+ const threadOf: Record = {};
+ page.on("request", (r) => r.method() === "POST" && r.url().endsWith("/chat") && posts.push(r.postDataJSON()));
+ page.on("response", async (r) => {
+ if (r.request().method() === "POST" && r.url().endsWith("/chat"))
+ threadOf[r.request().postDataJSON().query] = (await r.json()).data.thread_id;
+ });
+ // the first question answers after the second one
+ await openAsk(page, { delayMs: (q) => (q === "Slow one" ? 2500 : 100) });
+ await composer(page).fill("Slow one");
+ await composer(page).press("Enter");
+ // the Ask anything palette hands off to /ask?q= in the same page: a new chat while the first answer is in flight
+ await page.getByRole("button", { name: /^Ask anything/ }).click();
+ const palette = page.getByRole("dialog", { name: "Ask anything" });
+ await palette.getByRole("textbox").fill("Fast one");
+ await palette.getByRole("textbox").press("Enter");
+ await expect(page.getByText("Answer to: Fast one")).toBeVisible();
+ await page.waitForTimeout(3000); // the slow answer lands now
+ await expect(page.getByText("Answer to: Slow one")).toHaveCount(0);
+ await composer(page).fill("And a follow-up");
+ await composer(page).press("Enter");
+ await expect(page.getByText("Answer to: And a follow-up")).toBeVisible();
+ const fast = posts.find((p) => p.query === "Fast one")!;
+ const follow = posts.find((p) => p.query === "And a follow-up")!;
+ expect(fast.thread_id ?? null).toBeNull();
+ // the follow-up goes into the Fast chat's thread, never the slow one's
+ expect(follow.thread_id).toBe(threadOf["Fast one"]);
+ expect(threadOf["Fast one"]).not.toBe(threadOf["Slow one"]);
+});
diff --git a/frontend/e2e/bills-math.spec.ts b/frontend/e2e/bills-math.spec.ts
new file mode 100644
index 0000000..2bd5f5e
--- /dev/null
+++ b/frontend/e2e/bills-math.spec.ts
@@ -0,0 +1,79 @@
+// Node-only unit tests (no browser) for the Bills page's calculations (UI revamp Phase 5c).
+import { test, expect } from "@playwright/test";
+import { billsOnCalendar, dueDays, monthlyEquivalent, tiles, toBills, upcoming } from "../src/lib/bills-math";
+import type { RecurringItem } from "../src/lib/api/client";
+
+const item = (vendor: string, cadence: RecurringItem["cadence"], next: string, amount: number, extra: Partial = {}): RecurringItem => ({
+ vendor, category: "Bills", cadence, typical_amount: amount, last_charge_date: "2026-09-24", next_due_date: next,
+ charges_count: 3, price_change: null, kind: "bill", possibly_stopped: false, state: "active", ...extra,
+});
+const today = new Date(2026, 9, 22); // Thu 22 Oct 2026
+
+test("monthlyEquivalent spreads weekly and yearly charges per month", () => {
+ expect(monthlyEquivalent(item("Rent", "monthly", "2026-10-24", 25000))).toBe(25000);
+ expect(monthlyEquivalent(item("Maid", "weekly", "2026-10-24", 1200))).toBe(5200);
+ expect(monthlyEquivalent(item("Prime", "yearly", "2026-11-14", 1500))).toBe(125);
+});
+
+test("dueDays projects each cadence onto a month", () => {
+ expect(dueDays(item("Rent", "monthly", "2026-10-24", 1), 2026, 9)).toEqual([24]);
+ expect(dueDays(item("Rent", "monthly", "2026-10-24", 1), 2026, 10)).toEqual([24]);
+ expect(dueDays(item("Gym", "monthly", "2026-10-31", 1), 2026, 10)).toEqual([30]); // November has 30 days
+ expect(dueDays(item("Maid", "weekly", "2026-10-24", 1), 2026, 9)).toEqual([3, 10, 17, 24, 31]);
+ expect(dueDays(item("Prime", "yearly", "2026-11-14", 1), 2026, 10)).toEqual([14]);
+ expect(dueDays(item("Prime", "yearly", "2026-11-14", 1), 2026, 9)).toEqual([]);
+});
+
+test("upcoming groups the next 30 days into this week, next week and later", () => {
+ const g = upcoming([item("Rent", "monthly", "2026-10-24", 25000), item("Jio", "monthly", "2026-10-27", 999), item("Netflix", "monthly", "2026-11-12", 649), item("Old", "monthly", "2026-12-30", 5)], today);
+ expect(g.map((x) => [x.label, x.rows.map((r) => r.vendor), x.total])).toEqual([
+ ["This week", ["Rent"], 25000],
+ ["Next week", ["Jio"], 999],
+ ["Later", ["Netflix"], 649],
+ ]);
+});
+
+test("tiles: due soon, recurring per month, and subscriptions that look stopped", () => {
+ const t = tiles([
+ item("Rent", "monthly", "2026-10-24", 25000),
+ item("Prime", "yearly", "2027-03-14", 1500, { kind: "subscription" }),
+ item("Spotify", "monthly", "2026-08-02", 119, { kind: "subscription", possibly_stopped: true }),
+ item("Gym", "monthly", "2026-10-30", 1500, { state: "cancelled" }),
+ ], today);
+ expect(t.dueSoon).toEqual({ total: 25000, count: 1 });
+ expect(t.perMonth).toBe(25125); // rent + prime/12; stopped and cancelled ones don't count
+ expect(t.stopped).toEqual({ count: 1, perMonth: 119 });
+});
+
+test("a bill that's late but not stopped stays on the list as not seen yet", () => {
+ const late = item("Power", "monthly", "2026-10-18", 1800);
+ expect(upcoming([late, item("Rent", "monthly", "2026-10-24", 25000)], today).map((g) => [g.label, g.rows.map((r) => r.vendor)])).toEqual([
+ ["Not seen yet", ["Power"]],
+ ["This week", ["Rent"]],
+ ]);
+ expect(tiles([late], today).dueSoon).toEqual({ total: 1800, count: 1 });
+});
+
+test("toBills: Home keeps late and charged-again bills, drops cancelled and stopped ones", () => {
+ const bills = toBills([
+ item("Power", "monthly", "2026-10-18", 1800),
+ item("Netflix", "monthly", "2026-10-30", 649, { state: "charged_again" }),
+ item("Gym", "monthly", "2026-10-28", 1500, { state: "cancelled" }),
+ item("Spotify", "monthly", "2026-08-02", 119, { possibly_stopped: true }),
+ item("Far", "monthly", "2026-12-30", 5),
+ ], today);
+ expect(bills.map((b) => b.vendor)).toEqual(["Power", "Netflix"]);
+});
+
+test("the calendar shows a bill only from its first charge on, and a cancelled one only up to its last", () => {
+ // Netflix: 3 monthly charges, the last on 24 Sep 2026 → first ≈ 24 Jul
+ const netflix = item("Netflix", "monthly", "2026-10-24", 649, { last_charge_date: "2026-09-24", charges_count: 3 });
+ expect(billsOnCalendar([netflix], 2026, 5).size).toBe(0); // June: before it started
+ expect([...billsOnCalendar([netflix], 2026, 6).keys()]).toEqual([24]); // July
+ expect([...billsOnCalendar([netflix], 2027, 0).keys()]).toEqual([24]); // still coming
+ const gym = { ...netflix, vendor: "Gym", state: "cancelled" as const };
+ expect([...billsOnCalendar([gym], 2026, 8).keys()]).toEqual([24]); // September: it was charged
+ expect(billsOnCalendar([gym], 2026, 10).size).toBe(0); // November: cancelled
+ const stopped = { ...netflix, vendor: "Old", possibly_stopped: true };
+ expect(billsOnCalendar([stopped], 2026, 9).size).toBe(0); // October: after its last charge
+});
diff --git a/frontend/e2e/bills.spec.ts b/frontend/e2e/bills.spec.ts
new file mode 100644
index 0000000..acf74c7
--- /dev/null
+++ b/frontend/e2e/bills.spec.ts
@@ -0,0 +1,124 @@
+import { test, expect, type Page } from "@playwright/test";
+import { signIn, type HomeFixture } from "./fixtures";
+
+const now = new Date();
+const inDays = (n: number) => {
+ const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + n);
+ return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}`;
+};
+const item = (vendor: string, next: number, amount: number, kind: string, extra: object = {}) => ({
+ vendor, category: kind === "bill" ? "Rent" : "Entertainment", cadence: "monthly", typical_amount: amount,
+ last_charge_date: inDays(next - 30), next_due_date: inDays(next), charges_count: 3, price_change: null, kind,
+ possibly_stopped: false, state: "active", ...extra,
+});
+const sample: HomeFixture = {
+ recurring: [
+ item("Rent", 2, 25000, "bill"),
+ item("Netflix", 9, 649, "subscription", { price_change: { previous: 499, latest: 649, percent: 30.1 } }),
+ item("Spotify", -50, 119, "subscription", { possibly_stopped: true }),
+ { ...item("Prime", 120, 1500, "subscription"), cadence: "yearly" },
+ ],
+};
+async function openBills(page: Page, home: HomeFixture = sample) {
+ await signIn(page, { home });
+ await page.goto("/bills");
+}
+const table = (page: Page) => page.getByTestId("recurring-table");
+
+test("bills: one heading and the three tiles", async ({ page }) => {
+ await openBills(page);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText("Bills & subscriptions");
+ await expect(page.getByTestId("tile-due")).toContainText("₹25,649");
+ await expect(page.getByTestId("tile-due")).toContainText("2 bills");
+ await expect(page.getByTestId("tile-monthly")).toContainText("₹25,774");
+ await expect(page.getByTestId("tile-stopped")).toContainText("₹119");
+ await expect(page.getByTestId("tile-stopped")).toContainText("Spotify");
+});
+
+test("the calendar shows each bill on its day, month after month, without moving", async ({ page }) => {
+ await openBills(page);
+ const rent = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
+ const cal = page.getByTestId("bill-calendar");
+ const before = (await cal.boundingBox())!;
+ if (rent.getMonth() !== now.getMonth()) await page.getByRole("button", { name: "Next month" }).click();
+ await expect(cal.getByTestId(`bill-day-${rent.getDate()}`)).toContainText("Rent");
+ await page.getByRole("button", { name: "Next month" }).click();
+ await expect(cal.getByTestId(`bill-day-${Math.min(rent.getDate(), 28)}`)).toBeVisible();
+ await expect(cal).toContainText("Rent");
+ const after = (await cal.boundingBox())!;
+ expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1);
+});
+
+test("upcoming groups the next 30 days", async ({ page }) => {
+ await openBills(page);
+ const up = page.getByTestId("upcoming");
+ await expect(up).toContainText("Rent");
+ await expect(up).toContainText("Netflix");
+ await expect(up).not.toContainText("Spotify"); // looks stopped
+});
+
+test("tabs: subscriptions vs bills", async ({ page }) => {
+ await openBills(page);
+ await table(page).getByRole("tab", { name: /Subscriptions/ }).click();
+ await expect(table(page).getByTestId("recurring-row")).toHaveCount(3);
+ await expect(table(page)).not.toContainText("Rent");
+ await table(page).getByRole("tab", { name: /^Bills/ }).click();
+ await expect(table(page).getByTestId("recurring-row")).toHaveCount(1);
+ await expect(table(page).getByTestId("recurring-row")).toContainText("Rent");
+ await expect(table(page).getByRole("tab", { name: /Subscriptions/ })).toContainText("3");
+});
+
+test("mark as cancelled moves the row into Cancelled, and undo brings it back", async ({ page }) => {
+ const posts: unknown[] = [];
+ page.on("request", (r) => r.method() === "POST" && r.url().includes("/api/bills/recurring/state") && posts.push(r.postDataJSON()));
+ await openBills(page);
+ const netflix = table(page).getByTestId("recurring-row").filter({ hasText: "Netflix" });
+ await expect(netflix).toContainText("₹499 → ₹649");
+ await netflix.getByRole("button", { name: "Mark as cancelled" }).click();
+ await expect(page.getByTestId("cancelled-group")).toContainText("Netflix");
+ expect(posts).toEqual([{ vendor: "Netflix", state: "cancelled" }]);
+ await page.getByTestId("cancelled-group").getByRole("button", { name: "Undo" }).click();
+ await expect(page.getByTestId("cancelled-group")).toHaveCount(0);
+ expect(posts.at(-1)).toEqual({ vendor: "Netflix", state: "active" });
+});
+
+test("not a bill hides it; hidden lists it with undo", async ({ page }) => {
+ await openBills(page);
+ await table(page).getByTestId("recurring-row").filter({ hasText: "Rent" }).getByRole("button", { name: "Not a bill" }).click();
+ await expect(table(page).getByTestId("recurring-row").filter({ hasText: "Rent" })).toHaveCount(0);
+ await page.getByRole("button", { name: "Hidden (1)" }).click();
+ await expect(page.getByTestId("hidden-list")).toContainText("Rent");
+ await page.getByTestId("hidden-list").getByRole("button", { name: "Undo" }).click();
+ await expect(table(page).getByTestId("recurring-row").filter({ hasText: "Rent" })).toHaveCount(1);
+});
+
+test("how to cancel opens Ask with the question typed", async ({ page }) => {
+ await openBills(page);
+ const link = table(page).getByTestId("recurring-row").filter({ hasText: "Netflix" }).getByRole("link", { name: "How to cancel" });
+ await expect(link).toHaveAttribute("href", "/ask?draft=How%20do%20I%20cancel%20Netflix%3F");
+});
+
+test("bills has no layout shift while loading", async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.addInitScript(() => {
+ (window as unknown as { cls: number }).cls = 0;
+ new PerformanceObserver((list) => {
+ for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[])
+ if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value;
+ }).observe({ type: "layout-shift", buffered: true });
+ });
+ await openBills(page, { ...sample, delayMs: 1500 });
+ await expect(page.getByTestId("brief")).toBeVisible({ timeout: 15_000 });
+ await page.waitForTimeout(500);
+ expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01);
+});
+
+test("tabs stay on one line at phone width", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ await openBills(page);
+ for (const name of [/^All/, /^Subscriptions/, /^Bills/]) {
+ const box = (await table(page).getByRole("tab", { name }).boundingBox())!;
+ expect(box.height, String(name)).toBeLessThan(28);
+ }
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+});
diff --git a/frontend/e2e/budget-math.spec.ts b/frontend/e2e/budget-math.spec.ts
new file mode 100644
index 0000000..12b1975
--- /dev/null
+++ b/frontend/e2e/budget-math.spec.ts
@@ -0,0 +1,37 @@
+// Node-only unit tests (no browser) for the Budget page's calculations (UI revamp Phase 5d).
+import { test, expect } from "@playwright/test";
+import { evenLine, pace, runRate, usedTone } from "../src/lib/budget-math";
+
+const today = new Date(2026, 9, 22); // 22 Oct 2026; October has 31 days
+const october = new Date(2026, 9, 1);
+
+test("pace: this month has an even-pace mark, days left and a safe amount per day", () => {
+ const p = pace(100000, 62182, today, october);
+ expect(p.pct).toBeCloseTo(0.62182);
+ expect(p.evenPct).toBeCloseTo(22 / 31);
+ expect(p.left).toBe(37818);
+ expect(p.daysLeft).toBe(10); // today counts
+ expect(p.perDay).toBeCloseTo(3781.8);
+ expect(p.vsPace).toBeCloseTo(62182 - (100000 * 22) / 31); // negative = under an even pace
+});
+
+test("pace: a past month has no pace, days left or per day; over budget has no per day", () => {
+ const past = pace(50000, 61000, today, new Date(2026, 8, 1));
+ expect([past.evenPct, past.daysLeft, past.perDay, past.vsPace]).toEqual([null, 0, null, null]);
+ expect(past.left).toBe(-11000);
+ expect(pace(50000, 61000, today, october).perDay).toBeNull();
+ expect(pace(0, 500, today, october).pct).toBe(0);
+});
+
+test("usedTone: green, amber from 90%, red when over", () => {
+ expect(usedTone(899, 1000)).toBe("good");
+ expect(usedTone(900, 1000)).toBe("warn");
+ expect(usedTone(1000, 1000)).toBe("warn");
+ expect(usedTone(1001, 1000)).toBe("bad");
+});
+
+test("evenLine reaches the budget on the last day; runRate projects the month", () => {
+ const line = evenLine(31000, 31);
+ expect([line[0], line[30], line.length]).toEqual([1000, 31000, 31]);
+ expect(runRate(22000, today)).toBe(31000);
+});
diff --git a/frontend/e2e/budget.spec.ts b/frontend/e2e/budget.spec.ts
new file mode 100644
index 0000000..adf1215
--- /dev/null
+++ b/frontend/e2e/budget.spec.ts
@@ -0,0 +1,234 @@
+import { test, expect, type Page } from "@playwright/test";
+import { signIn, type HomeFixture } from "./fixtures";
+
+const now = new Date();
+const ym = (n: number) => {
+ const d = new Date(now.getFullYear(), now.getMonth() - n, 1);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
+};
+const monthName = (n: number) => new Date(now.getFullYear(), now.getMonth() - n, 1).toLocaleString("en-US", { month: "long" });
+const daysLeft = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate() - now.getDate() + 1;
+const c = (category: string, limit: number, spent: number) => ({ category, limit, spent });
+const line = (id: string, target: string, limit: number) => ({
+ id, type: "set_budget", target, risk: "low", reason: "Averaged a month over Jul–Sep, rounded up.",
+ payload: { limit, basis: { months: [], average: limit } }, status: "pending", created_at: null,
+});
+const sample: HomeFixture = {
+ budget: { monthly_budget: 100000, spent: 62182, categories: [c("Shopping", 8000, 16840), c("Food", 6000, 5930), c("Travel", 5000, 2140)] },
+ history: [
+ { month: ym(3), budget: null, spent: 70000 },
+ { month: ym(2), budget: 100000, spent: 104200 },
+ { month: ym(1), budget: 100000, spent: 79900 },
+ { month: ym(0), budget: 100000, spent: 62182 },
+ ],
+};
+async function openBudget(page: Page, home: HomeFixture = sample, path = "/budget") {
+ await signIn(page, { home });
+ await page.goto(path);
+ await expect(page.getByTestId("budget-hero")).toContainText("₹", { timeout: 15_000 });
+}
+const row = (page: Page, name: string) => page.getByTestId("budget-row").filter({ hasText: name });
+
+test("budget: heading, spent of total, left, days left and safe per day", async ({ page }) => {
+ await openBudget(page);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText("Budget");
+ const hero = page.getByTestId("budget-hero");
+ await expect(hero).toContainText("₹62,182");
+ await expect(hero).toContainText("of ₹100,000");
+ await expect(hero.getByTestId("left")).toContainText("₹37,818");
+ await expect(hero.getByTestId("days-left")).toContainText(String(daysLeft));
+ await expect(hero.getByTestId("per-day")).toContainText(`₹${Math.round(37818 / daysLeft).toLocaleString("en-US")}`);
+ await expect(hero.getByTestId("even-pace")).toBeVisible();
+});
+
+test("the sidebar has a Plan group with Budget", async ({ page }) => {
+ await openBudget(page);
+ const link = page.locator('[data-sidebar="menu-button"]', { hasText: "Budget" });
+ await expect(link).toHaveAttribute("href", "/budget");
+ await expect(link).toHaveAttribute("data-active", "true");
+ await expect(page.getByText("Plan", { exact: true })).toBeVisible();
+});
+
+test("by category: tones, unassigned footer", async ({ page }) => {
+ await openBudget(page);
+ await expect(row(page, "Shopping")).toHaveAttribute("data-tone", "bad");
+ await expect(row(page, "Food")).toHaveAttribute("data-tone", "warn");
+ await expect(row(page, "Travel")).toHaveAttribute("data-tone", "good");
+ await expect(row(page, "Shopping")).toContainText("₹8,840 over");
+ await expect(page.getByTestId("budget-footer")).toContainText("Categories add up to ₹19,000");
+ await expect(page.getByTestId("budget-footer")).toContainText("₹81,000 of your ₹100,000 is unassigned");
+});
+
+test("editing a category's budget in place: Enter saves, the row keeps its height", async ({ page }) => {
+ const puts: unknown[] = [];
+ page.on("request", (r) => r.method() === "PUT" && r.url().endsWith("/api/budget") && puts.push(r.postDataJSON()));
+ await openBudget(page);
+ const before = (await row(page, "Food").boundingBox())!;
+ await row(page, "Food").getByRole("button", { name: "Edit Food budget" }).click();
+ const input = row(page, "Food").getByRole("textbox", { name: "Food budget" });
+ await expect(input).toBeFocused();
+ expect((await row(page, "Food").boundingBox())!.height).toBe(before.height);
+ await input.fill("9000");
+ await input.press("Enter");
+ await expect(row(page, "Food")).toContainText("₹9,000");
+ expect(puts).toEqual([{ month: ym(0), categories: [{ category: "Food", limit: 9000 }] }]);
+});
+
+test("Esc cancels an edit without saving", async ({ page }) => {
+ let puts = 0;
+ page.on("request", (r) => r.method() === "PUT" && puts++);
+ await openBudget(page);
+ await row(page, "Food").getByRole("button", { name: "Edit Food budget" }).click();
+ await row(page, "Food").getByRole("textbox").fill("1");
+ await row(page, "Food").getByRole("textbox").press("Escape");
+ await expect(row(page, "Food")).toContainText("₹6,000");
+ expect(puts).toBe(0);
+});
+
+test("add a category", async ({ page }) => {
+ await openBudget(page);
+ await page.getByRole("button", { name: "Add category" }).click();
+ await page.getByRole("textbox", { name: "New category", exact: true }).fill("Health");
+ await page.getByRole("textbox", { name: "New category budget" }).fill("3000");
+ await page.getByRole("textbox", { name: "New category budget" }).press("Enter");
+ await expect(row(page, "Health")).toContainText("₹3,000");
+});
+
+test("edit budget changes the overall total", async ({ page }) => {
+ await openBudget(page);
+ await page.getByRole("button", { name: "Edit budget" }).click();
+ const input = page.getByRole("textbox", { name: "Monthly budget" });
+ await expect(input).toBeFocused();
+ await input.fill("120000");
+ await input.press("Enter");
+ await expect(page.getByTestId("budget-hero")).toContainText("of ₹120,000");
+});
+
+test("a past month is read-only and loads that month", async ({ page }) => {
+ const asked = page.waitForRequest((r) => r.url().includes(`/api/budget?month=${ym(1)}`));
+ await openBudget(page, { ...sample, budgetMonths: { [ym(1)]: { monthly_budget: 100000, spent: 79900, categories: [c("Food", 5000, 4100)] } } });
+ await expect(page.getByRole("button", { name: "Next month" })).toBeDisabled();
+ await page.getByRole("button", { name: "Previous month" }).click();
+ await asked;
+ await expect(page.getByTestId("month-label")).toContainText(monthName(1));
+ await expect(page.getByTestId("budget-hero")).toContainText("₹79,900");
+ await expect(page.getByRole("button", { name: "Let Lumen draft" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Edit Food budget" })).toHaveCount(0);
+ await expect(page.getByTestId("even-pace")).toHaveCount(0);
+});
+
+test("draft panel: pending lines with named buttons, skip one, approve the rest", async ({ page }) => {
+ const approved: string[] = [];
+ page.on("request", (r) => r.url().endsWith("/approve") && approved.push(r.url().split("/").at(-2)!));
+ await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500), line("p2", "Travel", 4000), line("p3", "Health", 2000)] });
+ const panel = page.getByTestId("draft-panel");
+ await expect(panel.getByTestId("draft-line")).toHaveCount(3);
+ await expect(panel.getByTestId("draft-line").filter({ hasText: "Food" })).toContainText("was ₹6,000");
+ await expect(panel.getByTestId("draft-line").filter({ hasText: "Health" })).toContainText("new");
+ await panel.getByRole("button", { name: "Skip Travel" }).click();
+ await expect(panel.getByTestId("draft-line").filter({ hasText: "Travel" })).toContainText("Skipped");
+ await panel.getByRole("button", { name: "Approve remaining 2" }).click();
+ await expect(panel.getByText("Approved")).toHaveCount(2);
+ expect(approved.sort()).toEqual(["p1", "p3"]);
+ await expect(panel.getByRole("button", { name: "Approve Food at ₹6,500" })).toHaveCount(0);
+});
+
+test("let Lumen draft files a draft", async ({ page }) => {
+ await openBudget(page, { ...sample, draft: [line("p1", "Food", 6500)] });
+ await page.getByRole("button", { name: "Let Lumen draft" }).click();
+ await expect(page.getByTestId("draft-panel").getByRole("button", { name: "Approve Food at ₹6,500" })).toBeVisible();
+});
+
+test("a draft with no history says why", async ({ page }) => {
+ await openBudget(page, { ...sample, draft: [] });
+ await page.getByRole("button", { name: "Let Lumen draft" }).click();
+ await expect(page.getByTestId("draft-panel")).toContainText("Not enough history yet");
+});
+
+test("?draft=1 drafts on arrival (Home's promo links here)", async ({ page }) => {
+ const drafted = page.waitForRequest((r) => r.url().endsWith("/api/budget/draft") && r.method() === "POST");
+ await openBudget(page, { ...sample, draft: [line("p1", "Food", 6500)] }, "/budget?draft=1");
+ await drafted;
+ await expect(page.getByTestId("draft-line")).toHaveCount(1);
+});
+
+test("a replaced draft says so", async ({ page }) => {
+ await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500)], replaced: ["p1"] });
+ await page.getByRole("button", { name: "Approve Food at ₹6,500" }).click();
+ await expect(page.getByTestId("draft-panel")).toContainText("This draft was replaced. Draft again.");
+});
+
+test("approve remaining ignores a double click", async ({ page }) => {
+ let approvals = 0;
+ page.on("request", (r) => r.url().endsWith("/approve") && approvals++);
+ await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500), line("p2", "Travel", 4000)] });
+ await page.getByRole("button", { name: "Approve remaining 2" }).dblclick();
+ await expect(page.getByTestId("draft-panel").getByText("Approved")).toHaveCount(2);
+ await page.waitForTimeout(300);
+ expect(approvals).toBe(2);
+});
+
+test("last months: budget outline vs actual, over in red, track record", async ({ page }) => {
+ await openBudget(page);
+ const bars = page.getByTestId("history-month");
+ await expect(bars).toHaveCount(4);
+ await expect(bars.nth(1)).toHaveAttribute("data-over", "true");
+ await expect(bars.nth(2)).not.toHaveAttribute("data-over", "true");
+ await expect(bars.nth(0).getByTestId("history-budget")).toHaveCount(0); // no budget then
+ await expect(page.getByTestId("track-record")).toContainText("1 of 2");
+});
+
+test("budget has no layout shift while loading", async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.addInitScript(() => {
+ (window as unknown as { cls: number }).cls = 0;
+ new PerformanceObserver((list) => {
+ for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[])
+ if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value;
+ }).observe({ type: "layout-shift", buffered: true });
+ });
+ await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500)], delayMs: 1500 });
+ await expect(page.getByTestId("draft-line")).toHaveCount(1);
+ await page.waitForTimeout(500);
+ expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01);
+});
+
+test("budget fits a phone without sideways scroll", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ await openBudget(page, { ...sample, history: [5, 4, 3, 2, 1, 0].map((n) => ({ month: ym(n), budget: 100000, spent: 104200 })) });
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+ // the page clips rather than scrolls, so check nothing ends past the screen edge either
+ const ends = await page
+ .locator('header button, [data-testid="history-month"], [data-testid="budget-row"] > *')
+ .evaluateAll((els) => els.map((e) => [e.getAttribute("aria-label") ?? e.textContent, e.getBoundingClientRect().right] as const));
+ for (const [what, right] of ends) expect(right, String(what)).toBeLessThanOrEqual(375);
+});
+
+test("every budget request names the browser's month (the server's date can differ around midnight on the 1st)", async ({ page }) => {
+ const urls: string[] = [];
+ page.on("request", (r) => r.url().includes("/api/budget") && r.method() === "GET" && urls.push(r.url()));
+ await openBudget(page);
+ expect(urls.some((u) => u.includes(`/api/budget?month=${ym(0)}`))).toBe(true);
+ expect(urls.some((u) => u.includes(`/api/budget/history?months=6&to=${ym(0)}`))).toBe(true);
+ expect(urls.filter((u) => /\/api\/budget$/.test(u))).toEqual([]);
+});
+
+test("a category with more refunds than spend draws an empty bar, not a full one", async ({ page }) => {
+ await openBudget(page, { ...sample, budget: { monthly_budget: 100000, spent: -400, categories: [c("Food", 6000, -400)] } });
+ await expect(row(page, "Food").locator("i").first()).toHaveAttribute("style", /width: 0%/);
+ await expect(page.getByTestId("budget-hero").locator("i").first()).toHaveAttribute("style", /width: 0%/);
+});
+
+test("budget has no layout shift while loading on a phone", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ await page.addInitScript(() => {
+ (window as unknown as { cls: number }).cls = 0;
+ new PerformanceObserver((list) => {
+ for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[])
+ if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value;
+ }).observe({ type: "layout-shift", buffered: true });
+ });
+ await openBudget(page, { ...sample, delayMs: 1500 });
+ await page.waitForTimeout(500);
+ expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01);
+});
diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts
index 6f5d5c1..b4936f5 100644
--- a/frontend/e2e/fixtures.ts
+++ b/frontend/e2e/fixtures.ts
@@ -6,8 +6,10 @@ import type { Page } from "@playwright/test";
// - the `lumen_session` cookie gets past middleware.
// - every backend call (NEXT_PUBLIC_BACKEND_URL in playwright.config.ts) is stubbed.
export type HomeFixture = {
- txns?: { id: string; vendor_name: string; date: string; total_amount: number; category: string | null; created_at: string | null }[];
+ txns?: { id: string; vendor_name: string; date: string; total_amount: number; category: string | null; created_at: string | null; source?: string | null; invoice_number?: string | null }[];
reminders?: { id: string; vendor: string; amount: number; date: string; type: string; category?: string }[];
+ /** GET /api/bills/recurring items as the API sends them; default: built from `reminders` (monthly, active) */
+ recurring?: Record[];
forecast?: { dates: string[]; values: number[] } | "fail";
delayMs?: number;
forecastDelayMs?: number;
@@ -15,11 +17,27 @@ export type HomeFixture = {
summary?: { found: object[]; more?: number; worth_total?: number; activity?: object[] };
/** GET/PUT /api/budget; default: nothing set */
budget?: { monthly_budget: number | null; spent: number; categories: { category: string; limit: number; spent: number }[] };
+ /** GET /api/budget?month=YYYY-MM for other months, keyed by month; default: the current `budget` */
+ budgetMonths?: Record;
+ /** GET /api/budget/history months, oldest first; default [] */
+ history?: { month: string; budget: number | null; spent: number }[];
/** POST /api/budget/draft proposals; default [] */
draft?: object[];
+ /** GET /api/agent/proposals (pending); default [] */
+ proposals?: object[];
currency?: string;
/** proposal ids whose approve returns 409 (a newer draft replaced them) */
replaced?: string[];
+ /** the flagged review queue as GET /api/review sends it; default: `reviewCount` plain items */
+ review?: Record[];
+ /** GET /api/documents; default [] (DELETE removes, POST adds) */
+ documents?: Record[];
+ /** GET /api/review answers 500 */
+ reviewFails?: boolean;
+ /** GET /api/statements/imports; default [] */
+ imports?: Record[];
+ /** GET /api/v1/email-config/status; default not configured */
+ email?: Record;
};
export type ChatFixture = {
@@ -27,14 +45,20 @@ export type ChatFixture = {
threads?: { id: string; title: string; updated_at: string; message_count: number; messages?: { role: string; content: string }[] }[];
/** the answer text POST /chat returns (default "Answer to: ") */
answer?: string;
+ /** the "Next:" suggestion POST /chat returns (default none) */
+ followUp?: string;
suggestions?: string[];
+ /** hold each POST /chat this long (or per question), to test the in-flight state */
+ delayMs?: number | ((query: string) => number);
};
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 ?? {});
+ const billStates = new Map(); // vendor (lower case) → cancelled | not_a_bill | active
// a tiny stateful chat backend: POST /chat creates or extends a thread, the list and messages follow
const threads = (opts.chat?.threads ?? []).map((t) => ({ ...t, messages: [...(t.messages ?? [])] }));
let nextThread = 1;
@@ -54,32 +78,79 @@ export async function signIn(
);
});
// a tiny stateful review queue: approving/rejecting removes the item, so the sidebar badge can be checked
- let queue = Array.from({ length: opts.reviewCount ?? 0 }, (_, i) => ({
+ const generated = Array.from({ length: opts.reviewCount ?? 0 }, (_, i) => ({
id: `r${i}`,
status: "flagged",
confidence: "low",
- invoice: { vendor_name: `Vendor ${i}` },
- flags: [],
+ invoice: { vendor_name: `Vendor ${i}` } as Record,
+ flags: [] as unknown[],
transaction_id: null,
note: null,
- created_at: null,
+ created_at: null as string | null,
}));
+ let queue = (home.review as typeof generated | undefined) ?? generated;
+ const documents = [...(home.documents ?? [])];
await page.route("http://127.0.0.1:9/**", async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (home.delayMs) await new Promise((r) => setTimeout(r, home.delayMs));
+ const method = route.request().method();
+ if (path === "/api/documents" && method === "GET") return route.fulfill({ json: { success: true, documents } });
+ if (path === "/api/documents" && method === "POST") {
+ const doc = { id: `d${documents.length + 1}`, title: "New document", doc_type: "other", filename: "new.pdf", chunk_count: 3, created_at: new Date().toISOString() };
+ documents.unshift(doc);
+ return route.fulfill({ json: { success: true, document: doc } });
+ }
+ const doc = path.match(/^\/api\/documents\/([^/]+)$/);
+ if (doc && method === "DELETE") {
+ documents.splice(documents.findIndex((d) => d.id === doc[1]), 1);
+ return route.fulfill({ json: { success: true } });
+ }
+ if (path === "/api/review" && home.reviewFails && !url.searchParams.get("count")) return route.fulfill({ status: 500, json: { success: false, error: "boom" } });
+ if (path === "/api/statements/preview")
+ return route.fulfill({ json: { success: true, columns: ["Date", "Description", "Amount"], mapping: { date: 0, description: 1, debit: null, credit: null, amount: 2, drcr: null },
+ header_line: 1, sample: [{ line: 2, date: "2026-10-01", description: "X", amount: 5, kind: "debit" }], counts: { rows: 1, debits: 1, credits: 0, unreadable: 0 } } });
+ if (path.startsWith("/api/files/")) return route.fulfill({ body: "original", contentType: "image/png" });
+ if (path === "/api/statements/imports") return route.fulfill({ json: { success: true, imports: home.imports ?? [] } });
+ if (path === "/api/v1/email-config/status") return route.fulfill({ json: home.email ?? { configured: false } });
+ if (path.startsWith("/api/v1/email-config/")) return route.fulfill({ json: { success: true } });
+ // recurring charges (api/bills.py): the fixture's items, or simple monthly ones built from `reminders`; state
+ // changes are kept, like the real table
+ if (path === "/api/bills/recurring" || path === "/api/bills/recurring/state") {
+ if (route.request().method() === "POST") {
+ const { vendor, state } = route.request().postDataJSON() as { vendor: string; state: string };
+ billStates.set(vendor.toLowerCase(), state);
+ }
+ const all = (home.recurring ?? (home.reminders ?? []).filter((r) => r.vendor).map((r) => ({
+ vendor: r.vendor, category: r.category ?? null, cadence: "monthly", typical_amount: r.amount,
+ last_charge_date: r.date, next_due_date: r.date, charges_count: 3, price_change: null, kind: "subscription",
+ possibly_stopped: false, state: "active",
+ }))) as { vendor: string; state: string }[];
+ const items = all
+ .map((i) => ({ ...i, state: billStates.get(i.vendor.toLowerCase()) ?? i.state }))
+ .filter((i) => i.state !== "not_a_bill");
+ const hidden = all.filter((i) => billStates.get(i.vendor.toLowerCase()) === "not_a_bill").map((i) => ({ vendor: i.vendor, vendor_key: i.vendor.toLowerCase() }));
+ return route.fulfill({ json: { success: true, items, hidden } });
+ }
+ // category edits from Spending's table (stateful, like the real PUT)
+ const edit = path.match(/^\/transactions\/([^/]+)$/);
+ if (edit && route.request().method() === "PUT") {
+ 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";
- const all = (home.txns ?? []).filter((t) => t.date >= from && t.date <= to).sort((a, b) => b.date.localeCompare(a.date));
+ const skip = url.searchParams.get("exclude_source");
+ const all = (home.txns ?? []).filter((t) => t.date >= from && t.date <= to && (!skip || t.source !== skip)).sort((a, b) => b.date.localeCompare(a.date));
// like the real API: at most 100 rows per page
const size = Math.min(Number(url.searchParams.get("page_size") ?? 10), 100);
const page = Number(url.searchParams.get("page") ?? 1);
const data = all.slice((page - 1) * size, page * size);
return route.fulfill({ json: { success: true, data, pagination: { page, has_next: page * size < all.length, total_items: all.length } } });
}
- if (path === "/api/analytics/reminders")
- return route.fulfill({ json: { success: true, reminders: home.reminders ?? [], count: 0 } });
if (path === "/api/analytics/forecast") {
if (home.forecastDelayMs) await new Promise((r) => setTimeout(r, home.forecastDelayMs));
if (home.forecast === "fail") return route.fulfill({ status: 500, json: { error: "boom" } });
@@ -93,25 +164,44 @@ export async function signIn(
if (path === "/api/home/summary") {
const review = queue.length
? [{ kind: "review", tag: "RECEIPTS", headline: `${queue.length} ${queue.length === 1 ? "receipt needs" : "receipts need"} a glance`,
- detail: "Lumen wasn't sure about these. Nothing is saved until you approve.", amounts: [], worth: null, href: "/receipts?tab=review" }]
+ detail: "Lumen wasn't sure about these. Nothing is saved until you approve.", amounts: [], worth: null, href: "/receipts" }]
: [];
const s = home.summary ?? { found: review };
return route.fulfill({ json: { success: true, more: 0, worth_total: 0, activity: [], ...s } });
}
- if (path === "/api/budget")
- return route.fulfill({ json: { success: true, month: "", ...(home.budget ?? { monthly_budget: null, spent: 0, categories: [] }) } });
+ if (path === "/api/budget") {
+ const none = { monthly_budget: null, spent: 0, categories: [] };
+ // PUT is stateful like the real one: a null limit removes the category
+ if (route.request().method() === "PUT") {
+ const body = route.request().postDataJSON() as { monthly_budget?: number | null; categories?: { category: string; limit: number | null }[] };
+ const b = (home.budget = structuredClone(home.budget ?? none));
+ if ("monthly_budget" in body) b.monthly_budget = body.monthly_budget ?? null;
+ for (const c of body.categories ?? []) {
+ const row = b.categories.find((x) => x.category.toLowerCase() === c.category.toLowerCase());
+ if (c.limit === null) b.categories = b.categories.filter((x) => x !== row);
+ else if (row) row.limit = c.limit;
+ else b.categories.push({ category: c.category, limit: c.limit, spent: 0 });
+ }
+ }
+ const month = url.searchParams.get("month");
+ const state = (month && home.budgetMonths?.[month]) || home.budget || none;
+ return route.fulfill({ json: { success: true, month: month ?? "", ...state } });
+ }
+ if (path === "/api/budget/history") return route.fulfill({ json: { success: true, months: home.history ?? [] } });
if (path === "/api/budget/draft") return route.fulfill({ json: { success: true, proposals: home.draft ?? [] } });
const proposal = path.match(/^\/api\/agent\/proposals\/([^/]+)\/(approve|reject)$/);
if (proposal && home.replaced?.includes(proposal[1]))
return route.fulfill({ status: 409, json: { detail: "Proposal is already rejected" } });
if (proposal) return route.fulfill({ json: { success: true, proposal: { id: proposal[1], status: `${proposal[2]}d` }, applied: null } });
const decided = path.match(/^\/api\/review\/([^/]+)\/(approve|reject)$/);
+ if (decided && home.replaced?.includes(decided[1]))
+ return route.fulfill({ status: 409, json: { success: false, error: "This invoice is already recorded", code: "conflict" } });
if (decided) {
const item = queue.find((q) => q.id === decided[1])!;
queue = queue.filter((q) => q.id !== decided[1]);
return route.fulfill({ json: { item: { ...item, status: `${decided[2]}d` } } });
}
- if (path === "/api/agent/proposals") return route.fulfill({ json: { proposals: [] } });
+ if (path === "/api/agent/proposals") return route.fulfill({ json: { proposals: home.proposals ?? [] } });
if (path === "/chat/suggestions") return route.fulfill({ json: { suggestions: opts.chat?.suggestions ?? [] } });
if (path === "/chat/threads")
return route.fulfill({
@@ -131,6 +221,8 @@ export async function signIn(
}
if (path === "/chat" && route.request().method() === "POST") {
const body = route.request().postDataJSON() as { query: string; thread_id?: string };
+ const delay = typeof opts.chat?.delayMs === "function" ? opts.chat.delayMs(body.query) : opts.chat?.delayMs;
+ if (delay) await new Promise((r) => setTimeout(r, delay));
let t = threads.find((x) => x.id === body.thread_id);
if (!t) {
t = { id: `t${nextThread++}`, title: body.query.slice(0, 60), updated_at: new Date().toISOString(), message_count: 0, messages: [] };
@@ -139,7 +231,7 @@ export async function signIn(
const response = opts.chat?.answer ?? `Answer to: ${body.query}`;
t.messages.push({ role: "user", content: body.query }, { role: "assistant", content: response });
return route.fulfill({
- json: { success: true, data: { response, follow_up: null, sources: [], steps: [], proposals: [], stopped: "answer", thread_id: t.id } },
+ json: { success: true, data: { response, follow_up: opts.chat?.followUp ?? null, sources: [], steps: [], proposals: [], stopped: "answer", thread_id: t.id } },
});
}
if (path === "/api/profile")
diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts
index 00fec18..fdef1bf 100644
--- a/frontend/e2e/home-math.spec.ts
+++ b/frontend/e2e/home-math.spec.ts
@@ -1,6 +1,8 @@
// Node-only unit tests (no browser) for the Home calculations.
import { test, expect } from "@playwright/test";
+import { pickTicks, smoothPath } from "../src/lib/chart";
import {
+ parseUtc,
briefSentence,
fill,
suggestedQuestions,
@@ -10,6 +12,7 @@ import {
relativeTime,
sameDayDelta,
type Txn,
+ vendorChip,
} from "../src/lib/home-math";
const d = (s: string) => {
@@ -153,3 +156,69 @@ test("suggestedQuestions maps found items, dedupes, caps at 4", () => {
]);
expect(suggestedQuestions([], ["A", "B"])).toEqual(["A", "B"]);
});
+
+// ---- lib/chart.ts (Phase 5) ----
+
+// y of each cubic segment of a path "M x y C x1 y1 x2 y2 x y C …" sampled at t
+function samples(d: string, ts = [0.25, 0.5, 0.75]): number[] {
+ const n = d.match(/-?\d+(\.\d+)?/g)!.map(Number);
+ let [x0, y0] = [n[0], n[1]];
+ const out: number[] = [];
+ for (let i = 2; i + 5 < n.length + 1; i += 6) {
+ const [, c1, , c2, x, y] = n.slice(i, i + 6);
+ for (const t of ts) out.push((1 - t) ** 3 * y0 + 3 * (1 - t) ** 2 * t * c1 + 3 * (1 - t) * t ** 2 * c2 + t ** 3 * y);
+ [x0, y0] = [x, y];
+ }
+ void x0;
+ return out;
+}
+
+test("smoothPath passes through every point", () => {
+ const d = smoothPath([[0, 10], [10, 20], [20, 20], [30, 40]]);
+ expect(d.startsWith("M0 10")).toBe(true);
+ expect(d).toContain("20 20");
+ expect(d.endsWith("30 40")).toBe(true);
+ expect(smoothPath([])).toBe("");
+ expect(smoothPath([[5, 6]])).toBe("M5 6");
+});
+
+test("smoothPath never overshoots a running total", () => {
+ const ys = samples(smoothPath([[0, 0], [10, 100], [20, 100], [30, 101]]));
+ for (let i = 0; i < ys.length; i++) {
+ expect(ys[i]).toBeLessThanOrEqual(101 + 1e-9);
+ if (i) expect(ys[i]).toBeGreaterThanOrEqual(ys[i - 1] - 1e-9);
+ }
+});
+
+test("pickTicks drops ticks that crowd a higher-ranked one", () => {
+ const t = (x: number, rank: number) => ({ x, w: 10, anchor: "middle" as const, rank, label: String(x) });
+ expect(pickTicks([t(0, 1), t(20, 2), t(40, 2), t(300, 0), t(310, 1)], 30).map((k) => k.label)).toEqual(["0", "40", "300"]);
+ expect(pickTicks([t(0, 1), t(60, 2), t(120, 2), t(200, 1)], 30).map((k) => k.label)).toEqual(["0", "60", "120", "200"]);
+});
+
+test("vendorChip gives two initials and a stable hue", () => {
+ expect(vendorChip("Swiggy Instamart").initials).toBe("SI");
+ expect(vendorChip("swiggy").initials).toBe("SW");
+ expect(vendorChip("Swiggy Instamart").hue).toBe(vendorChip("Swiggy Instamart").hue);
+ expect(vendorChip("").initials).toBe("?");
+});
+
+test("pickTicks compares real label edges, not centres", () => {
+ // "1 Oct" is left-anchored (covers 0–32), "Today" centred at 50 (covers 32–68): they touch, so the edge label goes
+ const t = (x: number, w: number, anchor: "start" | "middle" | "end", label: string, rank: number) => ({ x, w, anchor, label, rank });
+ const shown = pickTicks([t(0, 32, "start", "1 Oct", 1), t(50, 36, "middle", "Today", 0), t(300, 38, "end", "31 Oct", 1)], 6);
+ expect(shown.map((s) => s.label)).toEqual(["Today", "31 Oct"]);
+ // right edge: "31 Oct" covers 262–300, "Today" at 250 covers 232–268
+ expect(pickTicks([t(0, 32, "start", "1 Oct", 1), t(250, 36, "middle", "Today", 0), t(300, 38, "end", "31 Oct", 1)], 6).map((s) => s.label)).toEqual(["1 Oct", "Today"]);
+ // middle ticks give way to the higher-ranked ones and to each other
+ expect(pickTicks([t(0, 32, "start", "1 Oct", 1), t(36, 20, "middle", "8", 2), t(120, 20, "middle", "15", 2), t(200, 36, "middle", "Today", 0)], 6).map((s) => s.label)).toEqual(["1 Oct", "15", "Today"]);
+});
+
+test("parseUtc reads the backend's naive UTC timestamps as UTC; dates and zoned times as they are", () => {
+ expect(parseUtc("2026-10-22T10:00:00").toISOString()).toBe("2026-10-22T10:00:00.000Z");
+ expect(parseUtc("2026-10-22T10:00:00.123456").toISOString()).toBe("2026-10-22T10:00:00.123Z");
+ expect(parseUtc("2026-10-22T10:00:00+05:30").toISOString()).toBe("2026-10-22T04:30:00.000Z");
+ expect(parseUtc("2026-10-22T10:00:00Z").toISOString()).toBe("2026-10-22T10:00:00.000Z");
+ const day = parseUtc("2026-10-22"); // a plain date stays a local calendar day
+ expect([day.getFullYear(), day.getMonth(), day.getDate(), day.getHours()]).toEqual([2026, 9, 22, 0]);
+});
diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts
index 9caaceb..41df169 100644
--- a/frontend/e2e/home.spec.ts
+++ b/frontend/e2e/home.spec.ts
@@ -68,20 +68,23 @@ test("old dashboard is gone", async ({ page }) => {
});
const spend = (page: Page) => page.getByTestId("spend-card");
-const prevShort = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "short" });
+const prevLong = new Date(Y, M - 1, 1).toLocaleString("en-US", { month: "long" });
+const monthLong = new Date(Y, M, 1).toLocaleString("en-US", { month: "long" });
+const ord = (n: number) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th")}`;
const lastDay = new Date(Y, M + 1, 0).getDate();
test("spend card shows total and delta", async ({ page }) => {
await openHome(page);
await expect(spend(page).getByText("₹18,412", { exact: true })).toBeVisible();
- await expect(spend(page).getByTestId("spend-delta")).toHaveText(`↓ ₹1,868 vs ${prevShort} at this point`);
+ await expect(spend(page).getByTestId("spend-delta")).toHaveText(`−₹1,868 (9%) vs ${prevLong} by the ${ord(now.getDate())}`);
});
test("range chips switch period", async ({ page }) => {
await openHome(page);
- await expect(spend(page)).toContainText("SPENT THIS MONTH", { ignoreCase: true });
+ await expect(spend(page)).toContainText(`Spent in ${monthLong}`);
+ await expect(spend(page).getByRole("link", { name: new RegExp(`Spent in ${monthLong}`) })).toHaveAttribute("href", "/spending");
await spend(page).getByRole("button", { name: "1W" }).click();
- await expect(spend(page)).toContainText("SPENT THIS WEEK", { ignoreCase: true });
+ await expect(spend(page)).toContainText("Spent this week");
await expect(spend(page).getByRole("button", { name: "1W" })).toHaveAttribute("aria-pressed", "true");
});
@@ -96,6 +99,9 @@ test("forecast draws to month end", async ({ page }) => {
const dates = Array.from({ length: 31 }, (_, i) => ymd(Y, M, now.getDate() + 1 + i));
await openHome(page, { ...typical, forecast: { dates, values: dates.map(() => 100) } });
await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(1);
+ // 18,412 so far + 100 for each day left in the month
+ const left = lastDay - now.getDate();
+ await expect(spend(page)).toContainText(`heading for ₹${(18412 + 100 * left).toLocaleString("en-IN")}`);
});
test("bill pins show upcoming bills", async ({ page }) => {
@@ -104,7 +110,8 @@ test("bill pins show upcoming bills", async ({ page }) => {
...typical,
reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: thisMonth(now.getDate() + 1), type: "upcoming" }],
});
- await expect(spend(page).getByTestId("bill-pin")).toHaveText("Netflix");
+ await expect(spend(page).getByTestId("bill-pin")).toHaveAttribute("title", "Netflix");
+ await expect(spend(page).getByTestId("bill-pin")).toHaveText("N");
});
test("new user sees empty states", async ({ page }) => {
@@ -130,7 +137,7 @@ test("lumen found links to review", async ({ page }) => {
await openHome(page, typical, 2);
const found = page.getByTestId("lumen-found");
await expect(found.getByText("2 receipts need a glance")).toBeVisible();
- await expect(found.getByRole("link", { name: /review/i })).toHaveAttribute("href", "/receipts?tab=review");
+ await expect(found.getByRole("link", { name: /review/i })).toHaveAttribute("href", "/receipts");
});
test("lumen found is honest when there is nothing", async ({ page }) => {
@@ -161,18 +168,24 @@ test("long names truncate", async ({ page }) => {
expect(Math.abs(a!.height - b!.height)).toBeLessThanOrEqual(1);
});
-test("bills card lists next three", async ({ page }) => {
+test("coming up lists the next four bills", async ({ page }) => {
const r = (id: string, vendor: string, n: number) => ({ id, vendor, amount: 100, date: inDays(n), type: "upcoming" });
- await openHome(page, { ...typical, reminders: [r("4", "Rent", 9), r("1", "Netflix", 1), r("3", "Gym", 6), r("2", "Spotify", 3)] });
+ await openHome(page, {
+ ...typical,
+ reminders: [r("4", "Rent", 9), r("1", "Netflix", 1), r("3", "Gym", 6), r("2", "Spotify", 3), r("5", "Jio", 12), r("6", "LIC", 20)],
+ });
const rows = page.getByTestId("bill-row");
- await expect(rows).toHaveCount(3);
+ await expect(rows).toHaveCount(4);
await expect(rows.first()).toContainText("Netflix");
+ const d = new Date(Y, M, now.getDate() + 1);
+ await expect(rows.first().getByTestId("date-chip")).toHaveText(`${d.toLocaleString("en-GB", { weekday: "short" })}${d.getDate()}`);
+ await expect(page.getByRole("link", { name: /^Coming up/ })).toHaveAttribute("href", "/bills");
});
test("budget card is honest with no budget", async ({ page }) => {
await openHome(page);
await expect(page.getByText("No budget yet")).toBeVisible();
- await expect(page.getByRole("button", { name: "DRAFT BUDGET" })).toBeEnabled();
+ await expect(page.getByRole("link", { name: "Draft budget" })).toBeVisible();
});
test("activity comes from the summary", async ({ page }) => {
@@ -191,7 +204,7 @@ test("side column order", async ({ page }) => {
const titles = await side.locator(":scope > section").evaluateAll((els) =>
els.map((e) => (e.querySelector("h2, h3, a")?.textContent ?? "").replace(" ›", "").trim().toLowerCase()),
);
- expect(titles.map((t) => t.split(" · ")[0])).toEqual(["budget", "upcoming bills", "let lumen draft your budget", "activity"]);
+ expect(titles).toEqual([`budget in ${monthLong.toLowerCase()}`, "coming up", "let lumen draft your budget", "activity"]);
});
test("sky band is drawn behind the greeting", async ({ page }) => {
@@ -228,6 +241,7 @@ test("a forecast with no matching days draws no line", async ({ page }) => {
await openHome(page, { ...typical, forecast: { dates: [lastMonth(2), lastMonth(3)], values: [500, 500] } });
await expect(spend(page).locator('[data-series="current"]')).toHaveCount(1);
await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(0);
+ await expect(spend(page)).not.toContainText("heading for");
});
const dupe = { kind: "duplicate", tag: "DUPLICATES", headline: "Possible double charge: Croma {0}", detail: "Charged on 14 Oct and 15 Oct.",
@@ -238,8 +252,8 @@ const spike = { kind: "spike", tag: "SPENDING", headline: "Food is up 35%", deta
test("lumen found shows server signals and worth", async ({ page }) => {
await openHome(page, { ...typical, summary: { found: [dupe, spike], worth_total: 2499 } });
const found = page.getByTestId("lumen-found");
- await expect(found).toContainText("2 things worth a look · ₹2,499 you may get back");
- await expect(found.getByTestId("found-tile")).toHaveCount(2);
+ await expect(found).toContainText("2 to look at · ₹2,499 you may get back");
+ await expect(found.getByTestId("found-row")).toHaveCount(2);
await expect(found).toContainText("Possible double charge: Croma ₹2,499");
await expect(found).toContainText("₹6,750 so far vs ₹5,000 by this point last month.");
});
@@ -253,6 +267,7 @@ test("budget card shows progress against the monthly budget", async ({ page }) =
await openHome(page, { ...typical, budget: { monthly_budget: 28000, spent: 18412, categories: [{ category: "Food", limit: 8000, spent: 7480 }] } });
const card = page.getByTestId("budget-card");
await expect(card).toContainText("₹18,412 of ₹28,000");
+ await expect(card).toContainText("66%");
await expect(card).toContainText("₹9,588 left");
await expect(card).toContainText("Food");
await expect(page.getByText("Let Lumen draft your budget")).toHaveCount(0); // has category budgets already
@@ -263,30 +278,10 @@ test("budget card says when over", async ({ page }) => {
await expect(page.getByTestId("budget-card")).toContainText("₹3,412 over");
});
-const draftLine = (id: string, target: string, limit: number) => ({
- id, type: "set_budget", target, risk: "low", reason: "Averaged a month over Jul–Sep, rounded up.",
- payload: { limit, basis: { months: [], average: limit } }, status: "pending", created_at: null,
-});
-
-test("draft budget opens proposals and approve all applies them", async ({ page }) => {
- await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)] });
- await page.getByRole("button", { name: "DRAFT BUDGET" }).click();
- const dialog = page.getByRole("dialog");
- await expect(dialog.getByTestId("draft-line")).toHaveCount(2);
- await expect(dialog).toContainText("₹4,000");
- const approvals = [page.waitForRequest(/proposals\/p1\/approve/), page.waitForRequest(/proposals\/p2\/approve/)];
- await dialog.getByRole("button", { name: "Approve all" }).click();
- await Promise.all(approvals);
- await expect(dialog.getByText("Approved")).toHaveCount(2);
- const refetch = page.waitForRequest((r) => r.url().endsWith("/api/budget") && r.method() === "GET");
- await page.keyboard.press("Escape");
- await refetch;
-});
-
-test("draft with no history explains why", async ({ page }) => {
- await openHome(page, { ...typical, draft: [] });
- await page.getByRole("button", { name: "DRAFT BUDGET" }).click();
- await expect(page.getByRole("dialog")).toContainText("Not enough history yet");
+test("the draft promo and the budget card lead to the Budget page", async ({ page }) => {
+ await openHome(page, typical);
+ await expect(page.getByRole("link", { name: "Draft budget" })).toHaveAttribute("href", "/budget?draft=1");
+ await expect(page.getByTestId("budget-card").getByRole("link", { name: /Budget in/ })).toHaveAttribute("href", "/budget");
});
test("calendar uses the user's currency", async ({ page }) => {
@@ -301,35 +296,195 @@ test("sidebar badge uses the count endpoint", async ({ page }) => {
await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("2");
});
-test("draft dialog ignores a double click", async ({ page }) => {
- await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)] });
- let approvals = 0;
- page.on("request", (r) => r.url().includes("/proposals/") && r.url().endsWith("/approve") && approvals++);
- await page.getByRole("button", { name: "DRAFT BUDGET" }).click();
- await page.getByRole("dialog").getByRole("button", { name: "Approve all" }).dblclick();
- await expect(page.getByRole("dialog").getByText("Approved")).toHaveCount(2);
- await page.waitForTimeout(300);
- expect(approvals).toBe(2);
-});
-
-test("closing the draft dialog refreshes activity", async ({ page }) => {
- await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000)] });
- await page.getByRole("button", { name: "DRAFT BUDGET" }).click();
- await page.getByRole("dialog").getByRole("button", { name: "Approve all" }).click();
- await expect(page.getByRole("dialog").getByText("Approved")).toHaveCount(1);
- const summary = page.waitForRequest((r) => r.url().endsWith("/api/home/summary"));
- await page.keyboard.press("Escape");
- await summary;
-});
-
-test("a replaced draft says so", async ({ page }) => {
- await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)], replaced: ["p1"] });
- let approvals = 0;
- page.on("request", (r) => r.url().includes("/proposals/") && r.url().endsWith("/approve") && approvals++);
- await page.getByRole("button", { name: "DRAFT BUDGET" }).click();
- const dialog = page.getByRole("dialog");
- await dialog.getByRole("button", { name: "Approve all" }).click();
- await expect(dialog).toContainText("This draft was replaced. Draft again.");
+const overlaps = (a: { x: number; y: number; width: number; height: number }, b: typeof a) =>
+ a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
+
+test("home header keeps clear of the account menu", async ({ page }) => {
+ for (const [width, height] of [[1440, 900], [1024, 800]]) {
+ await page.setViewportSize({ width, height });
+ await openHome(page);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveCSS("font-size", "26px");
+ const menu = (await page.getByRole("button", { name: "Account menu" }).boundingBox())!;
+ for (const name of [/upload/i, /^export$/i]) {
+ const box = (await page.getByRole("button", { name }).first().boundingBox())!;
+ expect(overlaps(menu, box), `${String(name)} at ${width}px`).toBe(false);
+ }
+ }
+});
+
+test("hero shows an increase with its percent", async ({ page }) => {
+ await openHome(page, { txns: [tx("a", "Croma", thisMonth(1), 30000), tx("b", "Rent", lastMonth(1), 20000)] });
+ await expect(spend(page).getByTestId("spend-delta")).toHaveText(`+₹10,000 (50%) vs ${prevLong} by the ${ord(now.getDate())}`);
+});
+
+test("chart ticks never overlap at phone width", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ await openHome(page);
+ await expect(spend(page).getByTestId("tick").first()).toBeVisible();
+ const boxes = await spend(page).getByTestId("tick").evaluateAll((els) => els.map((e) => e.getBoundingClientRect().toJSON()));
+ expect(boxes.length).toBeGreaterThanOrEqual(2);
+ for (let i = 0; i < boxes.length; i++)
+ for (let j = i + 1; j < boxes.length; j++) expect(overlaps(boxes[i], boxes[j]), `ticks ${i} and ${j}`).toBe(false);
+});
+
+test("the hero + opens upload", async ({ page }) => {
+ await openHome(page);
+ await spend(page).getByRole("button", { name: "Upload a receipt" }).click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+});
+
+test("calendar marks today and names future bills", async ({ page }) => {
+ test.skip(now.getDate() === lastDay, "no future days left this month");
+ const tomorrow = now.getDate() + 1;
+ await openHome(page, {
+ ...typical,
+ reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: thisMonth(tomorrow), type: "upcoming" }],
+ });
+ await expect(page.getByTestId(`cal-day-${now.getDate()}`)).toHaveAttribute("data-today", "true");
+ await expect(page.getByTestId(`cal-day-${tomorrow}`)).toHaveAttribute("data-future", "true");
+ await expect(page.getByTestId(`cal-day-${tomorrow}`)).toContainText("Netflix");
+ const card = page.getByTestId("calendar-card");
+ await expect(card.getByRole("link", { name: new RegExp(`${monthLong}, day by day`) })).toHaveAttribute("href", "/spending");
+ await expect(card.getByRole("link", { name: "See all" })).toHaveAttribute("href", "/spending");
+});
+
+test("lumen found rows do things", async ({ page }) => {
+ await openHome(page, { ...typical, summary: { found: [dupe, spike], worth_total: 2499 } });
+ const rows = page.getByTestId("lumen-found").getByTestId("found-row");
+ // the spike links to where you can act on it; a row with nowhere to go gets no dead link
+ await expect(rows.nth(1).getByRole("link", { name: "See in Spending" })).toHaveAttribute("href", "/spending");
+ await expect(rows.nth(0).getByRole("link", { name: /^Review|^See/ })).toHaveCount(0);
+ // "Ask Lumen" opens Ask with the question typed, never sent
+ const ask = rows.nth(1).getByRole("link", { name: "Ask Lumen" });
+ await expect(ask).toHaveAttribute("href", /^\/ask\?draft=/);
+ expect(decodeURIComponent((await ask.getAttribute("href"))!.split("draft=")[1])).toBe("Tell me more about this: Food is up 35%");
+});
+
+test("budget card shows the top four categories by spend", async ({ page }) => {
+ const c = (category: string, limit: number, spent: number) => ({ category, limit, spent });
+ await openHome(page, {
+ ...typical,
+ budget: { monthly_budget: 100000, spent: 62182, categories: [c("Travel", 5000, 2140), c("Rent", 25000, 25000), c("Shopping", 8000, 16840), c("Groceries", 12000, 8120), c("Food", 6000, 5930), c("Health", 3000, 640)] },
+ });
+ const rows = page.getByTestId("budget-cat");
+ await expect(rows).toHaveCount(4);
+ expect(await rows.evaluateAll((els) => els.map((e) => e.getAttribute("data-category")))).toEqual(["Rent", "Shopping", "Groceries", "Food"]);
+ await expect(rows.nth(1)).toHaveAttribute("data-over", "true");
+ await expect(rows.nth(0)).not.toHaveAttribute("data-over", "true");
+});
+
+test("an empty account keeps every card at its loading height", async ({ page }) => {
+ await openHome(page, { txns: [], delayMs: 1500 });
+ const cards = page.locator('[data-testid="home-main"] > section, [data-testid="home-side"] > section, [data-testid="spend-card"]');
+ await expect(cards.first()).toBeVisible();
+ const loading = await cards.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().height)));
+ await expect(page.getByTestId("brief")).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(300);
- expect(approvals).toBe(1);
+ const ready = await cards.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().height)));
+ expect(ready.length).toBe(loading.length);
+ ready.forEach((h, i) => expect(Math.abs(h - loading[i]), `card ${i}`).toBeLessThanOrEqual(1));
+ await expect(page.locator("main")).not.toContainText(/NaN|of ₹0/);
+});
+
+test("calendar amounts stay readable at phone width", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ await openHome(page, { ...typical, txns: [tx("big", "Rent", thisMonth(1), 25640), tx("b", "Rent", lastMonth(1), 20280)] });
+ const amount = page.getByTestId("cal-day-1").locator("b");
+ await expect(amount).toBeVisible();
+ expect(await amount.evaluate((e) => e.scrollWidth <= e.clientWidth)).toBe(true);
+});
+
+test("a restock reminder (no vendor) neither crashes Home nor shows as a bill", async ({ page }) => {
+ await openHome(page, {
+ ...typical,
+ reminders: [
+ { id: "g", vendor: "", category: "Groceries", amount: 900, date: inDays(1), type: "upcoming" },
+ { id: "n", vendor: "Netflix", amount: 649, date: inDays(2), type: "upcoming" },
+ ],
+ });
+ await expect(page.getByTestId("bill-row")).toHaveCount(1);
+ await expect(page.getByTestId("bill-row")).toContainText("Netflix");
+ await expect(page.getByTestId("calendar-card")).toBeVisible();
+});
+
+// relative luminance of an element's background; the theme uses oklch(), so let a canvas turn it into sRGB first
+const bgLuminance = (el: import("@playwright/test").Locator) =>
+ el.evaluate((e) => {
+ const ctx = document.createElement("canvas").getContext("2d")!;
+ ctx.fillStyle = getComputedStyle(e).backgroundColor;
+ ctx.fillRect(0, 0, 1, 1);
+ const [r, g, b] = [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3).map((v) => {
+ const x = v / 255;
+ return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ });
+
+test("Home's actions are quiet, not loud blue", async ({ page }) => {
+ await openHome(page);
+ const upload = page.getByRole("button", { name: "Upload", exact: true });
+ await expect(upload).toBeVisible();
+ expect(await upload.evaluate((e) => getComputedStyle(e).backgroundColor)).not.toBe("rgb(37, 99, 235)");
+ const exp = page.getByRole("button", { name: "Export", exact: true });
+ await expect(exp).toBeVisible();
+ await expect(exp).toHaveText("");
+});
+
+test("upload and export dialogs are light", async ({ page }) => {
+ await openHome(page);
+ for (const name of ["Upload", "Export"]) {
+ await page.getByRole("button", { name, exact: true }).click();
+ const dialog = page.getByRole("dialog");
+ await expect(dialog).toBeVisible();
+ expect(await bgLuminance(dialog), `${name} dialog`).toBeGreaterThan(0.85);
+ expect(await dialog.evaluate((e) => e.outerHTML.match(/neutral-[89]00|#0b0612|text-white/g))).toBeNull();
+ await page.keyboard.press("Escape");
+ await expect(dialog).toBeHidden();
+ }
+});
+
+test("the upload dialog has one close button", async ({ page }) => {
+ await openHome(page);
+ await page.getByRole("button", { name: "Upload", exact: true }).click();
+ await expect(page.getByRole("dialog").locator("button:has(svg.lucide-x)")).toHaveCount(1);
+});
+
+// Review fix: labels are anchored at the edges, so "Today" near the start or end of the axis used to collide.
+for (const [day, range] of [["2026-10-26", "1M"], ["2026-10-04", "1M"], ["2026-10-29", "1M"], ["2026-10-27", "1W"], ["2026-10-26", "3M"]] as const) {
+ test(`ticks don't overlap on ${day} (${range}) at phone width`, async ({ page }) => {
+ await page.clock.setFixedTime(new Date(`${day}T12:00:00`));
+ await page.setViewportSize({ width: 375, height: 812 });
+ const [y, m] = day.split("-").map(Number);
+ const at = (mm: number, dd: number) => `${y}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
+ await openHome(page, { txns: [tx("a", "Swiggy", at(m, 1), 18412), tx("t", "Uber", day, 300), tx("b", "Rent", at(m - 1, 1), 20280), tx("c", "Rent", at(m - 3, 1), 20280)] });
+ if (range !== "1M") await spend(page).getByRole("button", { name: range }).click();
+ await expect(spend(page).getByTestId("tick").first()).toBeVisible();
+ await page.waitForTimeout(300);
+ const boxes = await spend(page).getByTestId("tick").evaluateAll((els) => els.map((e) => e.getBoundingClientRect().toJSON()));
+ for (let i = 0; i < boxes.length; i++)
+ for (let j = i + 1; j < boxes.length; j++) expect(overlaps(boxes[i], boxes[j]), `ticks ${i} and ${j}`).toBe(false);
+ if (range === "3M") {
+ // every label names its month, so "14" can't mean any month
+ const labels = await spend(page).getByTestId("tick").allTextContents();
+ for (const l of labels) expect(l, l).toMatch(/Today|[A-Z][a-z]{2}/);
+ }
+ });
+}
+
+test("home's bills come from the recurring charges, active ones only", async ({ page }) => {
+ const item = (vendor: string, days: number, extra: object = {}) => ({
+ vendor, category: "Entertainment", cadence: "monthly", typical_amount: 649, last_charge_date: inDays(days - 30),
+ next_due_date: inDays(days), charges_count: 3, price_change: null, kind: "subscription", possibly_stopped: false, state: "active", ...extra,
+ });
+ await openHome(page, {
+ ...typical,
+ recurring: [item("Netflix", 2), item("Spotify", 3, { state: "cancelled" }), item("Gym", 4, { possibly_stopped: true }), item("Rent", 45)],
+ });
+ await expect(page.getByTestId("bill-row")).toHaveCount(1);
+ await expect(page.getByTestId("bill-row")).toContainText("Netflix");
+});
+
+test("budget card: refunds draw an empty bar", async ({ page }) => {
+ await openHome(page, { ...typical, budget: { monthly_budget: 28000, spent: -500, categories: [{ category: "Food", limit: 8000, spent: -500 }] } });
+ await expect(page.getByTestId("budget-cat").locator("i")).toHaveAttribute("style", /width: 0%/);
});
diff --git a/frontend/e2e/receipts.spec.ts b/frontend/e2e/receipts.spec.ts
new file mode 100644
index 0000000..31d55a5
--- /dev/null
+++ b/frontend/e2e/receipts.spec.ts
@@ -0,0 +1,239 @@
+import { test, expect, type Page } from "@playwright/test";
+import { signIn, type HomeFixture } from "./fixtures";
+
+const flagged = (id: string, invoice: Record, rules: string[]) => ({
+ id, status: "flagged", confidence: "low", invoice, transaction_id: null, note: null, created_at: "2026-10-12T10:00:00",
+ flags: rules.map((rule) => ({ rule, severity: "fail", detail: `detail for ${rule}` })),
+});
+const sample: HomeFixture = {
+ review: [
+ flagged("r1", { vendor_name: "Croma", date: "2026-10-12", total_amount: 9999, invoice_number: "CR-2210" }, ["total_mismatch"]),
+ flagged("r2", { vendor_name: "FreshMart", date: "2026-10-18", total_amount: 1284, invoice_number: "FM-55120", source: "email" }, ["duplicate"]),
+ ],
+ txns: [
+ { id: "t1", vendor_name: "Jio Fiber", date: "2026-10-09", total_amount: 999, category: "Bills", created_at: null, source: "email", invoice_number: "JIO-1" },
+ { id: "t2", vendor_name: "Uber", date: "2026-10-08", total_amount: 212, category: "Transport", created_at: null, source: null, invoice_number: "U-9" },
+ { id: "t3", vendor_name: "Swiggy", date: "2026-10-07", total_amount: 450, category: "Food", created_at: null, source: "statement" },
+ ],
+ documents: [{ id: "d1", title: "Rental agreement", doc_type: "contract", filename: "rent.pdf", chunk_count: 14, created_at: "2026-10-01T09:00:00" }],
+ imports: [
+ { id: "i1", file_name: "hdfc-sep.csv", label: "HDFC Savings ··4417", period_from: "2026-09-01", period_to: "2026-09-30", lines: 142,
+ imported: 97, matched: 21, duplicates: 3, credits: 21, unreadable: 0, created_at: "2026-10-03T08:00:00" },
+ ],
+ email: { configured: true, email_address: "me@gmail.com", provider: "gmail", polling_enabled: true, transactions_added: 11,
+ last_successful_poll: new Date(Date.now() - 12 * 60_000).toISOString() },
+};
+async function openReceipts(page: Page, home: HomeFixture = sample) {
+ await signIn(page, { home });
+ await page.goto("/receipts");
+ await expect(page.getByTestId("library")).toContainText("Rental agreement", { timeout: 15_000 });
+}
+const card = (page: Page, vendor: string) => page.getByTestId("review-card").filter({ hasText: vendor });
+
+test("receipts: heading and a brief from the queue", async ({ page }) => {
+ await openReceipts(page);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText("Receipts & statements");
+ await expect(page.getByTestId("brief")).toContainText("2 receipts need your OK before they count.");
+});
+
+test("review cards highlight the doubtful field and say why", async ({ page }) => {
+ await openReceipts(page);
+ await expect(page.getByTestId("review-card")).toHaveCount(2);
+ await expect(card(page, "Croma").locator("[data-doubtful]")).toHaveText(["Total"]);
+ await expect(card(page, "Croma")).toContainText("Totals don't add up.");
+ await expect(card(page, "FreshMart").locator("[data-doubtful]")).toHaveText(["Bill no."]);
+ await expect(card(page, "FreshMart")).toContainText("Looks like a duplicate.");
+ await expect(card(page, "FreshMart")).toContainText("from email");
+ await expect(card(page, "FreshMart").getByRole("button", { name: "Keep both" })).toBeVisible();
+ await expect(card(page, "FreshMart").getByRole("button", { name: "Approve", exact: true })).toHaveCount(0);
+});
+
+test("edit then approve sends only the changed fields", async ({ page }) => {
+ const bodies: unknown[] = [];
+ page.on("request", (r) => r.url().endsWith("/api/review/r1/approve") && bodies.push(r.postDataJSON()));
+ await openReceipts(page);
+ await card(page, "Croma").getByRole("button", { name: "Edit" }).click();
+ await card(page, "Croma").getByRole("textbox", { name: "Total" }).fill("9990");
+ await card(page, "Croma").getByRole("button", { name: "Save & approve" }).click();
+ await expect(card(page, "Croma")).toHaveCount(0);
+ expect(bodies).toEqual([{ edits: { total_amount: 9990 } }]);
+});
+
+test("reject removes the card and the sidebar badge drops", async ({ page }) => {
+ await openReceipts(page);
+ await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("2");
+ await card(page, "FreshMart").getByRole("button", { name: "Reject" }).click();
+ await expect(card(page, "FreshMart")).toHaveCount(0);
+ await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("1");
+});
+
+test("no queue, no review section", async ({ page }) => {
+ await openReceipts(page, { ...sample, review: [] });
+ await expect(page.getByTestId("review")).toHaveCount(0);
+ await expect(page.getByTestId("brief")).toContainText("Everything you've given Lumen is filed.");
+});
+
+test("library: receipts, review items, documents and statements in one list", async ({ page }) => {
+ await openReceipts(page);
+ const rows = page.getByTestId("library-row");
+ await expect(rows).toHaveCount(6); // 2 receipts (not the statement line), 2 in review, 1 document, 1 import
+ await expect(rows.filter({ hasText: "Jio Fiber" })).toContainText("Gmail");
+ await expect(rows.filter({ hasText: "Croma" })).toContainText("Needs review");
+ await page.getByRole("button", { name: "Statements", exact: true }).click();
+ await expect(rows).toHaveCount(1);
+ await expect(rows).toContainText("HDFC Savings ··4417");
+ await expect(rows).toContainText("Import");
+ await page.getByRole("button", { name: "All", exact: true }).click();
+ await page.getByRole("searchbox", { name: "Search" }).fill("uber");
+ await expect(rows).toHaveCount(1);
+});
+
+test("deleting a document from the library", async ({ page }) => {
+ await openReceipts(page);
+ await page.getByRole("button", { name: "Delete Rental agreement" }).click();
+ await expect(page.getByTestId("library-row").filter({ hasText: "Rental agreement" })).toHaveCount(0);
+});
+
+test("statement imports card shows counts", async ({ page }) => {
+ await openReceipts(page);
+ const imports = page.getByTestId("imports");
+ await expect(imports).toContainText("HDFC Savings ··4417");
+ await expect(imports).toContainText("142 lines");
+ for (const text of ["97 new", "21 matched", "3 duplicates", "21 credits skipped"]) await expect(imports).toContainText(text);
+});
+
+test("inbox: connected shows the address and checks now", async ({ page }) => {
+ const polled = page.waitForRequest((r) => r.url().endsWith("/api/v1/email-config/poll-now") && r.method() === "POST");
+ await openReceipts(page);
+ const inbox = page.getByTestId("inbox");
+ await expect(inbox).toContainText("me@gmail.com");
+ await expect(inbox).toContainText("11 receipts added");
+ await inbox.getByRole("button", { name: "Check now" }).click();
+ await polled;
+ await expect(inbox.getByRole("button", { name: "Pause" })).toBeVisible();
+});
+
+test("inbox: not connected offers Gmail", async ({ page }) => {
+ await openReceipts(page, { ...sample, email: { configured: false } });
+ await expect(page.getByTestId("inbox").getByRole("button", { name: /Connect with Google/ })).toBeVisible();
+});
+
+test("upload dialog can file a document", async ({ page }) => {
+ const posted = page.waitForRequest((r) => r.url().endsWith("/api/documents") && r.method() === "POST");
+ await openReceipts(page);
+ await page.getByRole("button", { name: "Upload" }).click();
+ const dialog = page.getByRole("dialog");
+ await dialog.getByRole("radio", { name: "Documents" }).click();
+ await dialog.locator('input[type="file"]').setInputFiles({ name: "policy.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4") });
+ await dialog.getByRole("button", { name: "Upload", exact: true }).click();
+ const body = (await posted).postDataBuffer()!.toString();
+ expect(body).toContain("policy.pdf");
+ expect(body).toContain('name="doc_type"');
+ await expect(dialog).toContainText("Statements are CSV files");
+});
+
+test("import statement goes to the import flow", async ({ page }) => {
+ await openReceipts(page);
+ await page.getByRole("link", { name: "Import statement" }).first().click();
+ await expect(page).toHaveURL(/\/receipts\/import$/);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText("Import a statement");
+});
+
+test("old routes land on the new page", async ({ page }) => {
+ await signIn(page, { home: sample });
+ await page.goto("/review");
+ await expect(page).toHaveURL(/\/receipts$/);
+ await page.goto("/import");
+ await expect(page).toHaveURL(/\/receipts\/import$/);
+});
+
+test("receipts has no layout shift while loading", async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.addInitScript(() => {
+ (window as unknown as { cls: number }).cls = 0;
+ new PerformanceObserver((list) => {
+ for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[])
+ if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value;
+ }).observe({ type: "layout-shift", buffered: true });
+ });
+ await signIn(page, { home: { ...sample, delayMs: 1500 } });
+ await page.goto("/receipts");
+ await expect(page.getByTestId("library")).toContainText("Rental agreement", { timeout: 15_000 });
+ await page.waitForTimeout(500);
+ expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01);
+});
+
+test("receipts fits a phone", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ await openReceipts(page);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+ const ends = await page
+ .locator('header a, header button, [data-testid="review-card"], [data-testid="library-row"] > *')
+ .evaluateAll((els) => els.map((e) => [e.getAttribute("aria-label") ?? e.textContent, e.getBoundingClientRect().right] as const));
+ for (const [what, right] of ends) expect(right, String(what)).toBeLessThanOrEqual(375);
+});
+
+test("a big statement import doesn't push the receipts out of the library", async ({ page }) => {
+ const lines = Array.from({ length: 120 }, (_, i) => ({ id: `s${i}`, vendor_name: `Line ${i}`, date: "2026-10-20", total_amount: 10, category: null, created_at: null, source: "statement" }));
+ await openReceipts(page, { ...sample, txns: [...lines, ...sample.txns!] });
+ await expect(page.getByTestId("library-row").filter({ hasText: "Jio Fiber" })).toHaveCount(1);
+});
+
+test("a review card and a document open their original", async ({ page }) => {
+ const opened: string[] = [];
+ page.on("request", (r) => r.url().includes("/api/files/") && opened.push(r.url().split("/api/files/")[1]));
+ const review = [flagged("r1", { vendor_name: "Croma", total_amount: 9999, file_key: "inv/croma.png" }, ["total_mismatch"])];
+ await openReceipts(page, { ...sample, review, documents: [{ ...sample.documents![0], file_key: "doc/rent.pdf" }] });
+ await card(page, "Croma").getByRole("button", { name: "Open original" }).click();
+ await page.getByRole("button", { name: "Open Rental agreement" }).click();
+ await expect.poll(() => opened).toEqual(["inv/croma.png", "doc/rent.pdf"]);
+});
+
+test("a decision the server refuses says why", async ({ page }) => {
+ await openReceipts(page, { ...sample, replaced: ["r1"] });
+ await card(page, "Croma").getByRole("button", { name: "Approve", exact: true }).click();
+ await expect(page.getByText("This invoice is already recorded")).toBeVisible();
+ await expect(card(page, "Croma")).toHaveCount(1);
+});
+
+test("an edit can't approve an empty or non-numeric total", async ({ page }) => {
+ await openReceipts(page);
+ await card(page, "Croma").getByRole("button", { name: "Edit" }).click();
+ const total = card(page, "Croma").getByRole("textbox", { name: "Total" });
+ const save = card(page, "Croma").getByRole("button", { name: "Save & approve" });
+ await total.fill("");
+ await expect(save).toBeDisabled();
+ await total.fill("1,234");
+ await expect(save).toBeDisabled();
+ await total.fill("1234.5");
+ await expect(save).toBeEnabled();
+});
+
+test("if the review queue can't load, the brief doesn't claim everything is filed", async ({ page }) => {
+ await openReceipts(page, { ...sample, reviewFails: true });
+ await expect(page.getByTestId("brief")).not.toContainText("Everything");
+ await expect(page.getByTestId("brief")).toContainText("files with Lumen");
+});
+
+test("receipts has no layout shift while loading on a phone", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 812 });
+ await page.addInitScript(() => {
+ (window as unknown as { cls: number }).cls = 0;
+ new PerformanceObserver((list) => {
+ for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[])
+ if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value;
+ }).observe({ type: "layout-shift", buffered: true });
+ });
+ await signIn(page, { home: { ...sample, email: { configured: false }, delayMs: 1500 } });
+ await page.goto("/receipts");
+ await expect(page.getByTestId("library")).toContainText("Rental agreement", { timeout: 15_000 });
+ await page.waitForTimeout(500);
+ expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01);
+});
+
+test("the import page starts in the profile's currency", async ({ page }) => {
+ await signIn(page, { home: { currency: "INR", delayMs: 800 } });
+ await page.goto("/receipts/import");
+ await page.locator("#statement-file").setInputFiles({ name: "s.csv", mimeType: "text/csv", buffer: Buffer.from("Date,Description,Amount" + String.fromCharCode(10) + "2026-10-01,X,-5") });
+ await expect(page.locator("#import-currency")).toHaveValue("INR");
+});
diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts
index 53a6963..2e81faf 100644
--- a/frontend/e2e/shell.spec.ts
+++ b/frontend/e2e/shell.spec.ts
@@ -17,8 +17,8 @@ const moved: [string, string][] = [
["/ai-analytics", "/spending"],
["/chatbot", "/ask"],
["/documents", "/receipts"],
- ["/import", "/receipts?tab=import"],
- ["/review", "/receipts?tab=review"],
+ ["/import", "/receipts/import"],
+ ["/review", "/receipts"],
["/upload", "/home?upload=1"],
];
@@ -44,7 +44,7 @@ test("new pages render for a signed-in user", async ({ page }) => {
["/spending", "Spending"],
["/bills", "Bills & subscriptions"],
["/receipts", "Receipts & statements"],
- ["/receipts?tab=review", "Receipts & statements"],
+ ["/receipts/import", "Import a statement"],
["/ask", "Ask Lumen"],
];
for (const [p, title] of pages) {
@@ -61,8 +61,11 @@ test("sidebar shows the new IA", async ({ page }) => {
await signIn(page);
await page.goto("/home");
const links = sidebar(page).locator('[data-sidebar="menu-button"]');
- await expect(links).toHaveText(["Home", "Spending", "Bills & subscriptions", "Receipts & statements", "Ask Lumen", /^Ask anything/]);
- await expect(sidebar(page).getByText("Track", { exact: true })).toBeVisible();
+ await expect(links).toHaveText(["Home", "Spending", "Bills & subscriptions", "Budget", "Receipts & statements", "Ask Lumen", /^Ask anything/]);
+ // every group is labelled (owner, 2026-10-22)
+ for (const group of ["Overview", "Track", "Plan", "Records", "Assistant"]) {
+ await expect(sidebar(page).getByText(group, { exact: true })).toBeVisible();
+ }
for (const old of ["Workspace", "Compare", "Financial command center"]) {
await expect(page.getByText(old, { exact: true })).toHaveCount(0);
}
@@ -85,6 +88,20 @@ test("ctrl+b collapses to a 56px rail with tooltips", async ({ page }) => {
await expect(page.getByRole("tooltip")).toHaveText("Spending");
});
+test("the collapse control sits in the sidebar header, with no top bar on desktop", async ({ page }) => {
+ await signIn(page);
+ await page.goto("/home");
+ const toggle = sidebar(page).getByRole("button", { name: "Toggle sidebar (Ctrl+B)" });
+ await toggle.click();
+ await expect.poll(() => railWidth(page)).toBeCloseTo(56, 0);
+ // on the rail the logo turns into the expand button on hover
+ await sidebar(page).locator('[data-sidebar="header"]').hover();
+ await toggle.click();
+ await expect.poll(() => railWidth(page)).toBeGreaterThan(200);
+ await expect(page.locator("#main > header")).toBeHidden();
+ await expect(page.getByRole("button", { name: "Account menu" })).toBeVisible();
+});
+
test("collapsed state survives reload without a jump", async ({ page }) => {
// record the sidebar's data-state the moment it first enters the DOM (the auth guard renders it client-side)
await page.addInitScript(() => {
@@ -123,6 +140,15 @@ test("ctrl+k opens the ask palette and enter hands off", async ({ page }) => {
expect(posts).toEqual(["Where did my money go"]);
});
+test("ctrl+k works below md with the sheet closed", async ({ page }) => {
+ await page.setViewportSize({ width: 767, height: 1024 });
+ await signIn(page);
+ await page.goto("/home");
+ await expect(page.getByRole("button", { name: "Account menu" })).toBeVisible();
+ await page.keyboard.press("Control+k");
+ await expect(palette(page)).toBeVisible();
+});
+
test("palette works with caps lock", async ({ page }) => {
await signIn(page);
await page.goto("/home");
@@ -210,7 +236,7 @@ test("body is never its own scroll container (it would lose its scrollbar when a
test("review badge updates after a decision on the same page", async ({ page }) => {
await signIn(page, { reviewCount: 2 });
- await page.goto("/receipts?tab=review");
+ await page.goto("/receipts");
const badge = sidebar(page).locator('[data-sidebar="menu-badge"]');
await expect(badge).toHaveText("2");
await page.getByRole("button", { name: "Approve" }).first().click();
@@ -236,3 +262,16 @@ test("sidebar never scrolls sideways", async ({ page }) => {
await expect(content).toBeVisible();
expect(await content.evaluate((e) => e.scrollWidth - e.clientWidth)).toBeLessThanOrEqual(0);
});
+
+test("spending and bills show no cards that can never load", async ({ page }) => {
+ await signIn(page, {
+ home: { reminders: [{ id: "n", vendor: "Netflix", amount: 649, date: new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10), type: "upcoming" }] },
+ });
+ await page.goto("/spending");
+ await expect(page.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");
+ await expect(page.getByTestId("recurring-table")).toContainText("Netflix");
+ await expect(page.getByText(/Could not load/)).toHaveCount(0);
+});
diff --git a/frontend/e2e/spending-math.spec.ts b/frontend/e2e/spending-math.spec.ts
new file mode 100644
index 0000000..273e864
--- /dev/null
+++ b/frontend/e2e/spending-math.spec.ts
@@ -0,0 +1,78 @@
+// Node-only unit tests (no browser) for the Spending page's calculations (UI revamp Phase 5b).
+import { test, expect } from "@playwright/test";
+import { byCategory, inCategory, monthlyTotals, needsCategory, spendPeriods, toCsv, topMerchants } from "../src/lib/spending-math";
+
+const tx = (id: string, vendor_name: string | null, date: string, total_amount: number, category: string | null = "Food") => ({
+ id,
+ 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);
+});
+
+test("toCsv: text that a spreadsheet would run as a formula is kept as text; amounts stay numbers", () => {
+ const row = (vendor_name: string, total_amount: number) => ({ id: 1, vendor_name, date: "2026-10-01", total_amount, category: null, created_at: null });
+ const csv = toCsv([row("=HYPERLINK(\"http://x\")", 10), row("+91 Store", -400), row("@sum", 5), row("Plain, Co", 1)]).split("\n");
+ expect(csv[1]).toBe(`2026-10-01,"'=HYPERLINK(""http://x"")",,Receipt,10`);
+ expect(csv[2]).toBe("2026-10-01,'+91 Store,,Receipt,-400");
+ expect(csv[3]).toBe("2026-10-01,'@sum,,Receipt,5");
+ expect(csv[4]).toBe('2026-10-01,"Plain, Co",,Receipt,1');
+});
+
+test("inCategory matches regardless of case (links carry the most common spelling)", () => {
+ const t = (category: string | null) => ({ id: 1, vendor_name: "V", date: "2026-10-01", total_amount: 1, category, created_at: null });
+ expect(inCategory(t("food"), "Food")).toBe(true);
+ expect(inCategory(t("Food "), "food")).toBe(true);
+ expect(inCategory(t("Travel"), "Food")).toBe(false);
+ expect(inCategory(t(null), "Uncategorised")).toBe(true);
+ expect(inCategory(t("Other"), "Uncategorised")).toBe(true);
+});
diff --git a/frontend/e2e/spending.spec.ts b/frontend/e2e/spending.spec.ts
new file mode 100644
index 0000000..8c71dac
--- /dev/null
+++ b/frontend/e2e/spending.spec.ts
@@ -0,0 +1,199 @@
+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);
+});
+
+test("the brief only names a payment that fits inside the jump", async ({ page }) => {
+ await openSpending(page, { txns: [tx("a", "Croma", thisMonth(1), 9999, "Shopping"), tx("b", "Bata", thisMonth(1), 1, "Shopping"), tx("c", "Myntra", lastMonth(1), 8000, "Shopping")] });
+ await expect(page.getByTestId("brief")).toContainText("Shopping is most of the jump.");
+ await expect(page.getByTestId("brief")).not.toContainText("Croma");
+});
diff --git a/frontend/next.config.js b/frontend/next.config.js
index fdcda45..c5db174 100644
--- a/frontend/next.config.js
+++ b/frontend/next.config.js
@@ -19,6 +19,9 @@ if (missing.length > 0) {
const nextConfig = {
reactStrictMode: true,
+ // e2e runs its own dev server (playwright.config.ts) with dummy env; a separate build folder keeps it from
+ // clobbering the `npm run dev` server's cache in the same checkout ("missing required error components").
+ distDir: process.env.NEXT_DIST_DIR || ".next",
// The login page used to live at /signin; old bookmarks and already-sent email links still work
// (query strings carry over, and browsers keep the #fragment Supabase puts on email links).
async redirects() {
@@ -29,8 +32,8 @@ const nextConfig = {
["/ai-analytics", "/spending"],
["/chatbot", "/ask"],
["/documents", "/receipts"],
- ["/import", "/receipts?tab=import"],
- ["/review", "/receipts?tab=review"],
+ ["/import", "/receipts/import"],
+ ["/review", "/receipts"],
["/upload", "/home?upload=1"],
].map(([source, destination]) => ({ source, destination, permanent: false }));
return [{ source: "/signin", destination: "/login", permanent: true }, ...moved];
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index 547b4f9..3c6d3d5 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -22,6 +22,7 @@ export default defineConfig({
NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321",
NEXT_PUBLIC_SUPABASE_ANON_KEY: "e2e-dummy",
NEXT_PUBLIC_BACKEND_URL: "http://127.0.0.1:9",
+ NEXT_DIST_DIR: ".next-e2e",
},
},
});
diff --git a/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx b/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx
deleted file mode 100644
index 5a3b52a..0000000
--- a/frontend/src/app/(app)/ai-analytics/aiAnalyticsContent.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { DashboardShell } from "@/components/dashboard-shell";
-import QuickStatsCard from "@/components/ai-analytics/quickStatsCard";
-import PaymentCalendarCard from "@/components/ai-analytics/paymentCalendarCard";
-import ReminderDetailsCard from "@/components/ai-analytics/reminderDetailsCard";
-import AnomalyDetectionCard from "@/components/ai-analytics/anomalyDetectionCard";
-import AISuggestionsCard from "@/components/ai-analytics/aiSuggestionsCard";
-import SpendingTrendGraph from "@/components/ai-analytics/spendingTrendGraph";
-import CategoryPieChart from "@/components/ai-analytics/categoryPieChart";
-import { aiAnalyticsApi } from "@/lib/api/client";
-
-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..."}
-
- }
- >
-
-
-
-
- );
-}
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)/ask/chatbotContent.tsx b/frontend/src/app/(app)/ask/chatbotContent.tsx
index 55bc560..6a57538 100644
--- a/frontend/src/app/(app)/ask/chatbotContent.tsx
+++ b/frontend/src/app/(app)/ask/chatbotContent.tsx
@@ -70,7 +70,14 @@ export default function ChatbotContent() {
const bottomRef = useRef(null);
const lastQ = useRef(null); // the ?q= already handed off (StrictMode runs effects twice)
const router = useRouter();
- const q = useSearchParams().get("q");
+ const params = useSearchParams();
+ const q = params.get("q");
+ const draftParam = params.get("draft");
+ const lastDraft = useRef(null);
+ // Bumped whenever the chat on screen changes (new chat, opening a thread). An answer that arrives for an older chat
+ // must not touch this one: it is already saved to its own thread, so it only refreshes the list.
+ const chatGen = useRef(0);
+ const [draft, setDraft] = useState<{ text: string; key: number } | null>(null);
const busy = messages.some((m) => m.pending);
const refreshThreads = useCallback(() => {
@@ -110,6 +117,21 @@ export default function ChatbotContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
+ // /ask?draft=… (Lumen found, Bills "How to cancel"): a new chat with the question typed in but NOT sent, so you can
+ // edit it first; the URL is cleaned so a reload starts empty
+ useEffect(() => {
+ if (draftParam === null) {
+ lastDraft.current = null;
+ return;
+ }
+ if (lastDraft.current === draftParam) return;
+ lastDraft.current = draftParam;
+ router.replace("/ask");
+ newChat();
+ setDraft({ text: draftParam, key: Date.now() });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [draftParam]);
+
async function ask(question: string, file?: File, replaceId?: string, inThread: string | null = threadId) {
const id = crypto.randomUUID();
const started = Date.now();
@@ -118,8 +140,10 @@ export default function ChatbotContent() {
...(replaceId ? [] : [{ id: `${id}-q`, role: "user" as const, content: question, file }]),
{ id, role: "assistant", content: "", question, file, pending: true },
]);
+ const gen = chatGen.current;
try {
const answer = await chatApi.sendMessage(question, file, inThread);
+ if (gen !== chatGen.current) return refreshThreads();
if (!inThread) {
setThreadId(answer.thread_id);
refreshThreads();
@@ -133,6 +157,7 @@ export default function ChatbotContent() {
)
);
} catch (error) {
+ if (gen !== chatGen.current) return;
setMessages((list) =>
list.map((m) => (m.id === id ? { ...m, content: errorMessage(error), pending: false, error: true, keyIssue: KEY_CODES.includes(errorCode(error) ?? "") } : m))
);
@@ -140,12 +165,14 @@ export default function ChatbotContent() {
}
function newChat() {
+ chatGen.current++;
setThreadId(null);
setMessages([]);
setHistoryOpen(false);
}
async function openThread(id: string) {
+ chatGen.current++;
setHistoryOpen(false);
try {
const { messages: rows } = await chatApi.thread(id);
@@ -183,7 +210,7 @@ export default function ChatbotContent() {
return (
-
+
@@ -194,7 +221,7 @@ export default function ChatbotContent() {
-
+
setHistoryOpen(true)}>
History
@@ -218,7 +245,7 @@ export default function ChatbotContent() {
Ask about spending, invoices or contracts. Answers come with their sources.
-
void ask(text, file)} busy={busy} />
+ { setDraft(null); void ask(text, file); }} busy={busy} />
{suggestions.map((text, i) => (
@@ -295,7 +322,7 @@ export default function ChatbotContent() {
-
void ask(text, file)} busy={busy} />
+ { setDraft(null); void ask(text, file); }} busy={busy} />
Lumen cites its sources and only proposes changes; nothing is applied until you approve it.
diff --git a/frontend/src/app/(app)/ask/thread-rail.tsx b/frontend/src/app/(app)/ask/thread-rail.tsx
index 4352439..112395a 100644
--- a/frontend/src/app/(app)/ask/thread-rail.tsx
+++ b/frontend/src/app/(app)/ask/thread-rail.tsx
@@ -7,7 +7,8 @@ import { relativeTime } from "@/lib/home-math";
import { cn } from "@/lib/utils";
// Past conversations (UI revamp Phase 4): one click resumes, delete asks once inline. Rendered as a column on
-// large screens and inside a Sheet below that (chatbotContent.tsx).
+// large screens and inside a Sheet below that (chatbotContent.tsx). `disabled` while an answer is in flight, so nothing
+// here can delete or swap the thread that answer is landing in.
export function ThreadRail({
threads,
activeId,
@@ -35,7 +36,7 @@ export function ThreadRail({
>
New chat
- Recent
+ Recent
{threads.length === 0 && No past chats yet. }
{threads.map((t) => (
@@ -43,13 +44,20 @@ export function ThreadRail({
{confirming === t.id ? (
Delete this chat?
-
setConfirming(null)}>
+ setConfirming(null)}
+ >
Keep
{
setConfirming(null);
onDelete(t.id);
@@ -76,8 +84,10 @@ export function ThreadRail({
setConfirming(t.id)}
- className="absolute top-1.5 right-1.5 rounded p-1 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:bg-background hover:text-foreground"
+ // no hover on touch screens, so the trash shows there all the time
+ className="absolute top-1.5 right-1.5 rounded p-1 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:bg-background hover:text-foreground disabled:pointer-events-none max-md:opacity-100 [@media(hover:none)]:opacity-100"
>
diff --git a/frontend/src/app/(app)/bills/bills-content.tsx b/frontend/src/app/(app)/bills/bills-content.tsx
new file mode 100644
index 0000000..4b986c9
--- /dev/null
+++ b/frontend/src/app/(app)/bills/bills-content.tsx
@@ -0,0 +1,298 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import Link from "next/link";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { PageCard } from "@/components/page/page-card";
+import { PageHeader } from "@/components/page/page-header";
+import { VendorChip } from "@/components/page/vendor-chip";
+import { billsApi, type RecurringItem, type RecurringState } from "@/lib/api/client";
+import { billsOnCalendar, tiles, upcoming } from "@/lib/bills-math";
+import { useMoney } from "@/lib/format";
+import { parseDay } from "@/lib/home-math";
+import { toast } from "@/lib/toast";
+import { cn } from "@/lib/utils";
+import { Brief } from "../home/_components/brief";
+
+type Tab = "subscriptions" | "bills" | "all";
+const TAB_LABEL: Record = { all: "All", subscriptions: "Subscriptions", bills: "Bills" };
+const monthYear = (y: number, m: number) => new Date(y, m, 1).toLocaleString("en-US", { month: "long", year: "numeric" });
+const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" });
+const weekday = (d: Date) => d.toLocaleString("en-GB", { weekday: "long" });
+const isSubscription = (i: RecurringItem) => i.kind === "subscription";
+
+// Bills & subscriptions (UI revamp Phase 5c, spec 2026-10-22 §4.3): what's due, what recurs, what can I cut?
+// Everything comes from GET /api/bills/recurring; the user's "cancelled" / "not a bill" choices are saved there.
+export default function BillsContent() {
+ const money = useMoney();
+ const today = useMemo(() => {
+ const n = new Date();
+ return new Date(n.getFullYear(), n.getMonth(), n.getDate());
+ }, []);
+ const [data, setData] = useState(null);
+ const [shown, setShown] = useState({ y: today.getFullYear(), m: today.getMonth() });
+ const [tab, setTab] = useState("all");
+ const [showHidden, setShowHidden] = useState(false);
+
+ useEffect(() => {
+ billsApi.recurring().then(setData, () => setData({ items: [], hidden: [] }));
+ }, []);
+
+ async function choose(vendor: string, state: "cancelled" | "not_a_bill" | "active") {
+ try {
+ setData(await billsApi.setState(vendor, state));
+ } catch {
+ toast.error("Couldn't save that. Try again.");
+ }
+ }
+
+ const loading = data === null;
+ const items = data?.items ?? [];
+ const t = tiles(items, today);
+ const groups = upcoming(items, today);
+ const next = groups[0]?.rows[0];
+ const brief = loading
+ ? null
+ : items.length === 0
+ ? "Lumen hasn't spotted any regular payments yet. They show up here after three similar charges."
+ : [
+ next && `${next.vendor} of ${money(next.amount)} is due ${next.date.getTime() === today.getTime() ? "today" : weekday(next.date)}.`,
+ t.stopped.count > 0 &&
+ `${t.stopped.count} ${t.stopped.count === 1 ? "subscription looks" : "subscriptions look"} stopped, about ${money(t.stopped.perMonth)} a month.`,
+ ]
+ .filter(Boolean)
+ .join(" ") || `${money(t.perMonth)} goes out every month in regular payments.`;
+
+ const listed = items.filter((i) => i.state !== "cancelled");
+ const counts = { subscriptions: listed.filter(isSubscription).length, bills: listed.filter((i) => !isSubscription(i)).length, all: listed.length };
+ const rows = listed.filter((i) => tab === "all" || (tab === "subscriptions") === isSubscription(i));
+ const cancelled = items.filter((i) => i.state === "cancelled");
+
+ const step = (d: number) => setShown(({ y, m }) => ({ y: new Date(y, m + d, 1).getFullYear(), m: new Date(y, m + d, 1).getMonth() }));
+ const monthSwitcher = (
+
+ step(-1)} className="p-1.5 text-muted-foreground hover:text-foreground">
+
+
+ {monthYear(shown.y, shown.m)}
+ step(1)} className="p-1.5 text-muted-foreground hover:text-foreground">
+
+
+
+ );
+
+ return (
+
+
+
+
} actions={monthSwitcher} />
+
+
+
+ {money(t.dueSoon.total)}
+
+ {t.dueSoon.count} {t.dueSoon.count === 1 ? "bill" : "bills"}
+
+
+
+ {money(t.perMonth)}
+ Yearly and weekly ones spread per month
+
+
+
+ {money(t.stopped.perMonth)} a month
+
+
+ {t.stopped.count === 0
+ ? "None right now"
+ : items.filter((i) => i.possibly_stopped && isSubscription(i) && i.state !== "cancelled").map((i) => i.vendor).join(", ")}
+
+
+
+
+
+
+
Next 30 days} loading={loading} skeletonHeight={420} data-testid="upcoming">
+ {groups.length === 0 ? (
+ Nothing due in the next 30 days.
+ ) : (
+ groups.map((g) => (
+
+
+ {g.label}
+ {money(g.total)}
+
+ {g.rows.map((r) => (
+
+
+ {r.date.toLocaleString("en-GB", { weekday: "short" })}
+ {r.date.getDate()}
+
+
+ {r.vendor}
+ {r.item.cadence}
+
+ {money(r.amount)}
+
+ ))}
+
+ ))
+ )}
+
+
+
+
+ {(["all", "subscriptions", "bills"] as Tab[]).map((k) => (
+ setTab(k)}
+ className={cn("rounded-[7px] px-2.5 py-0.5 text-[12px] whitespace-nowrap max-sm:px-1.5", tab === k ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground")}
+ >
+ {TAB_LABEL[k]} {counts[k]}
+
+ ))}
+
+ }
+ loading={loading}
+ skeletonHeight={240}
+ className="mt-3.5"
+ data-testid="recurring-table"
+ >
+ {rows.length === 0 ? (
+
Nothing here yet.
+ ) : (
+ rows.map((i) =>
)
+ )}
+ {cancelled.length > 0 && (
+
+
Cancelled
+ {cancelled.map((i) => (
+
+
+ {i.vendor}
+ {money(i.typical_amount)}
+ void choose(i.vendor, "active")} className="label-ui text-primary hover:underline">
+ Undo
+
+
+ ))}
+
+ )}
+ {(data?.hidden.length ?? 0) > 0 && (
+
+
setShowHidden((v) => !v)} className="text-muted-foreground hover:text-foreground">
+ Hidden ({data!.hidden.length})
+
+ {showHidden && (
+
+ {data!.hidden.map((h) => (
+
+ {h.vendor} · not a bill
+ void choose(h.vendor, "active")} className="label-ui text-primary hover:underline">
+ Undo
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ );
+}
+
+function RecurringRow({ item: i, onChoose }: { item: RecurringItem; onChoose: (vendor: string, state: "cancelled" | "not_a_bill") => void }) {
+ const money = useMoney();
+ return (
+
+
+
+
+
+ {i.vendor}
+ {i.price_change && (
+
+ {money(i.price_change.previous)} → {money(i.price_change.latest)}
+
+ )}
+ {i.possibly_stopped && looks stopped }
+ {i.state === "charged_again" && (
+ charged again after you cancelled
+ )}
+
+
+ {i.cadence} · {i.kind === "subscription" ? "subscription" : "bill"}
+
+
+
+ {dayMonth(i.last_charge_date)}
+ {i.possibly_stopped ? "—" : dayMonth(i.next_due_date)}
+
+ {money(i.typical_amount)}
+ a {i.cadence === "weekly" ? "week" : i.cadence === "yearly" ? "year" : "month"}
+
+
+ onChoose(i.vendor, "cancelled")} className="text-muted-foreground hover:text-foreground">
+ Mark as cancelled
+
+ onChoose(i.vendor, "not_a_bill")} className="text-muted-foreground hover:text-foreground">
+ Not a bill
+
+
+ How to cancel
+
+
+
+ );
+}
+
+// The shown month, Monday first and always six rows tall (no jump between months), with each bill on its due days.
+function BillCalendar({ items, year, month, today, loading }: { items: RecurringItem[]; year: number; month: number; today: Date; loading: boolean }) {
+ const money = useMoney();
+ const lead = (new Date(year, month, 1).getDay() + 6) % 7;
+ const days = new Date(year, month + 1, 0).getDate();
+ const byDay = billsOnCalendar(items, year, month);
+ const isToday = (d: number) => today.getFullYear() === year && today.getMonth() === month && today.getDate() === d;
+ return (
+
+
+ {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
+
+ {d}
+
+ ))}
+ {Array.from({ length: 42 }, (_, cell) => {
+ const d = cell - lead + 1;
+ if (d < 1 || d > days) return
;
+ const bills = byDay.get(d) ?? [];
+ return (
+
+ {d}
+ {bills.slice(0, 1).map((b) => (
+
+ {b.vendor}
+
+ ))}
+ {bills.length > 1 && +{bills.length - 1} more }
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/bills/page.tsx b/frontend/src/app/(app)/bills/page.tsx
index b8af91a..52a5b36 100644
--- a/frontend/src/app/(app)/bills/page.tsx
+++ b/frontend/src/app/(app)/bills/page.tsx
@@ -1,18 +1,8 @@
import type { Metadata } from "next";
-import { DashboardShell } from "@/components/dashboard-shell";
-import PaymentCalendarCard from "@/components/ai-analytics/paymentCalendarCard";
-import ReminderDetailsCard from "@/components/ai-analytics/reminderDetailsCard";
+import BillsContent from "./bills-content";
export const metadata: Metadata = { title: "Bills & subscriptions" };
-// Interim (UI revamp Phase 1): the payment calendar and reminders until Phase 5 redesigns Bills.
export default function Page() {
- return (
-
-
-
- );
+ return ;
}
diff --git a/frontend/src/app/(app)/budget/_components/categories.tsx b/frontend/src/app/(app)/budget/_components/categories.tsx
new file mode 100644
index 0000000..493b045
--- /dev/null
+++ b/frontend/src/app/(app)/budget/_components/categories.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { PageCard } from "@/components/page/page-card";
+import type { BudgetState } from "@/lib/api/client";
+import { usedTone } from "@/lib/budget-math";
+import { useMoney } from "@/lib/format";
+import { cn } from "@/lib/utils";
+
+const TONE = { good: "bg-good", warn: "bg-amber-500", bad: "bg-bad" } as const;
+const COLS = "grid grid-cols-[minmax(0,1.2fr)_76px_minmax(0,1fr)_minmax(0,1fr)] items-center gap-2 sm:gap-3 sm:grid-cols-[minmax(0,1.3fr)_100px_90px_minmax(0,1fr)_minmax(0,1.4fr)]";
+
+// By category (spec 2026-10-22 §4.4): budget, spent, left and a used bar per category. On the current month the
+// budget amount is a button that turns into an input of the same height (Enter saves, Esc cancels), and new
+// categories are added inline. Past months are read-only.
+export function BudgetCategories({
+ budget,
+ total,
+ editable,
+ onSet,
+ loading,
+}: {
+ budget: BudgetState | null;
+ total: number | null;
+ editable: boolean;
+ onSet: (category: string, limit: number) => Promise;
+ loading: boolean;
+}) {
+ const money = useMoney();
+ const [editing, setEditing] = useState(null);
+ const [adding, setAdding] = useState(false);
+ const cats = budget?.categories ?? [];
+ const sum = cats.reduce((s, c) => s + c.limit, 0);
+ const over = cats.filter((c) => usedTone(c.spent, c.limit) === "bad").length;
+ const close = cats.filter((c) => usedTone(c.spent, c.limit) === "warn").length;
+
+ return (
+
+ {over > 0 && {over} over }
+ {close > 0 && {close} close }
+ >
+ }
+ loading={loading}
+ skeletonHeight={180}
+ data-testid="budget-categories"
+ >
+ {cats.length === 0 && !adding ? (
+
+ No category budgets{editable ? " yet. Add one, or let Lumen draft them from your spending." : " this month."}
+
+ ) : (
+
+
+ Category
+ Budget
+ Spent
+ Left
+ Used
+
+ {cats.map((c) => {
+ const tone = usedTone(c.spent, c.limit);
+ const left = c.limit - c.spent;
+ return (
+
+
{c.category}
+
+ {editing === c.category ? (
+ setEditing(null)}
+ onSave={async (v) => {
+ await onSet(c.category, v);
+ setEditing(null);
+ }}
+ />
+ ) : editable ? (
+ setEditing(c.category)}
+ className="-mx-1.5 h-7 rounded-md px-1.5 hover:bg-muted"
+ >
+ {money(c.limit)}
+
+ ) : (
+ money(c.limit)
+ )}
+
+
{money(c.spent)}
+
+ {left < 0 ? `${money(-left)} over` : money(left)}
+
+
+
+
+
+ {Math.round((c.spent / c.limit) * 100)}%
+
+
+ );
+ })}
+ {adding && (
+
setAdding(false)}
+ onSave={async (name, v) => {
+ await onSet(name, v);
+ setAdding(false);
+ }}
+ />
+ )}
+
+ )}
+ {(total !== null || editable) && (
+
+ {total !== null && cats.length > 0 && (
+
+ Categories add up to {money(sum)}
+ {sum < total ? ` · ${money(total - sum)} of your ${money(total)} is unassigned` : sum > total ? ` · ${money(sum - total)} more than your ${money(total)}` : ""}
+
+ )}
+ {editable && !adding && (
+ setAdding(true)} className="ml-auto text-foreground/80 hover:text-foreground">
+ + Add category
+
+ )}
+
+ )}
+
+ );
+}
+
+function AmountInput({ label, initial, onSave, onCancel }: { label: string; initial: number; onSave: (v: number) => void; onCancel: () => void }) {
+ const [v, setV] = useState(String(initial));
+ const ref = useRef(null);
+ useEffect(() => ref.current?.select(), []);
+ return (
+ setV(e.target.value)}
+ onBlur={onCancel}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") onCancel();
+ if (e.key === "Enter" && Number(v) > 0) onSave(Number(v));
+ }}
+ className="-mx-1.5 h-7 w-full max-w-[96px] rounded-md border border-border bg-card px-1.5 tabular-nums"
+ />
+ );
+}
+
+function NewCategory({ onSave, onCancel }: { onSave: (name: string, v: number) => void; onCancel: () => void }) {
+ const [name, setName] = useState("");
+ const [v, setV] = useState("");
+ const ref = useRef(null);
+ useEffect(() => ref.current?.focus(), []);
+ const keys = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") onCancel();
+ if (e.key === "Enter" && name.trim() && Number(v) > 0) onSave(name.trim(), Number(v));
+ };
+ return (
+
+ setName(e.target.value)} onKeyDown={keys} maxLength={60} className="h-7 min-w-0 rounded-md border border-border bg-card px-1.5" />
+ setV(e.target.value)} onKeyDown={keys} className="h-7 w-full max-w-[96px] rounded-md border border-border bg-card px-1.5" />
+ Enter to save · Esc
+
+ );
+}
diff --git a/frontend/src/app/(app)/budget/_components/draft-panel.tsx b/frontend/src/app/(app)/budget/_components/draft-panel.tsx
new file mode 100644
index 0000000..eb49b89
--- /dev/null
+++ b/frontend/src/app/(app)/budget/_components/draft-panel.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { Sparkles } from "lucide-react";
+import { agentApi, type BudgetState, type Proposal } from "@/lib/api/client";
+import { useMoney } from "@/lib/format";
+import { toast } from "@/lib/toast";
+import { cn } from "@/lib/utils";
+
+export type DraftLine = Proposal & { decided?: "approve" | "reject" };
+
+// Lumen's draft, on the page (spec 2026-10-22 §4.4; it was a dialog on Home). Each line is a pending set_budget
+// proposal: the proposed limit against the one in force, the basis sentence, and Skip / Approve named for the category.
+// Nothing changes until a line is approved. No month-bars glyph: the basis has no per-month amounts to draw.
+export function DraftPanel({
+ lines,
+ setLines,
+ budget,
+ monthName,
+ onDecided,
+}: {
+ lines: DraftLine[];
+ setLines: (f: (ls: DraftLine[] | null) => DraftLine[] | null) => void;
+ budget: BudgetState | null;
+ monthName: string;
+ onDecided: () => void;
+}) {
+ const money = useMoney();
+ const [deciding, setDeciding] = useState(false);
+ const [replaced, setReplaced] = useState(false);
+ const inFlight = useRef(false); // a ref, so a double click is blocked before React re-renders
+ // the limits before this draft: approving a line reloads the budget, and "was" must not become "no change"
+ const [current] = useState(() => new Map((budget?.categories ?? []).map((c) => [c.category.toLowerCase(), c.limit])));
+ const was = (l: DraftLine) => current.get(l.target.toLowerCase());
+ const limit = (l: DraftLine) => Number(l.payload.limit);
+ const pending = lines.filter((l) => !l.decided);
+ const taking = lines.filter((l) => l.decided !== "reject");
+ const after = [...current.entries()].filter(([k]) => !taking.some((l) => l.target.toLowerCase() === k)).reduce((s, [, v]) => s + v, 0)
+ + taking.reduce((s, l) => s + limit(l), 0);
+
+ async function decide(ids: string[], decision: "approve" | "reject") {
+ if (inFlight.current) return;
+ inFlight.current = true;
+ setDeciding(true);
+ try {
+ for (const id of ids) {
+ await agentApi.decide(id, decision);
+ setLines((ls) => ls?.map((l) => (l.id === id ? { ...l, decided: decision } : l)) ?? ls);
+ }
+ } catch (error) {
+ // a newer draft (from here or from Ask Lumen) retired these lines
+ if ((error as { response?: { status?: number } })?.response?.status === 409) setReplaced(true);
+ else toast.error("Couldn't save that decision.");
+ } finally {
+ inFlight.current = false;
+ setDeciding(false);
+ onDecided();
+ }
+ }
+
+ return (
+
+
+
+
Lumen's draft for {monthName}
+ {lines.length > 0 && (
+
+ {lines.length - pending.length} of {lines.length} decided
+
+ )}
+
+ {lines.length === 0 ? (
+ Not enough history yet. Lumen needs about two months of spending to draft a budget.
+ ) : (
+ <>
+ Each line is a monthly limit; nothing changes until you approve it.
+
+ {lines.map((l) => {
+ const old = was(l);
+ return (
+
+
+ {l.target}
+ {money(limit(l))}
+
+ {old === undefined ? "new" : old === limit(l) ? "no change" : `was ${money(old)}`}
+
+
+
+
{l.reason}
+
+ {l.decided ? (
+
+ {l.decided === "approve" ? "✓ Approved" : old === undefined ? "Skipped" : `Skipped · keeps ${money(old)}`}
+
+ ) : (
+ <>
+ void decide([l.id], "reject")} className="rounded-md px-2 py-1 text-[12.5px] text-muted-foreground hover:bg-card disabled:opacity-50">
+ Skip
+
+ void decide([l.id], "approve")} className="rounded-md border border-border bg-card px-2 py-1 text-[12.5px] hover:border-foreground/30 disabled:opacity-50">
+ Approve
+
+ >
+ )}
+
+
+
+ );
+ })}
+
+ {replaced && (
+
+ This draft was replaced. Draft again.
+
+ )}
+
+ {pending.length > 0 && (
+ void decide(pending.map((l) => l.id), "approve")}
+ className="rounded-full bg-foreground px-3.5 py-1.5 text-[13px] text-background disabled:opacity-50"
+ >
+ Approve remaining {pending.length}
+
+ )}
+ {budget?.monthly_budget ? (
+
+ Categories would add up to {money(after)} of {money(budget.monthly_budget)}
+
+ ) : null}
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/app/(app)/budget/_components/hero.tsx b/frontend/src/app/(app)/budget/_components/hero.tsx
new file mode 100644
index 0000000..051c655
--- /dev/null
+++ b/frontend/src/app/(app)/budget/_components/hero.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { CumulativeChart } from "@/components/page/cumulative-chart";
+import { PageCard } from "@/components/page/page-card";
+import { evenLine, pace } from "@/lib/budget-math";
+import { useMoney } from "@/lib/format";
+import { cumulative, dayIndex, period, type Txn } from "@/lib/home-math";
+import { cn } from "@/lib/utils";
+
+// Budget's hero (spec 2026-10-22 §4.4): spent of total, a bar with the even-pace tick, what's left and safe per day,
+// and the running total against a straight line to the budget. `editing` swaps the total for an input in place.
+export function BudgetHero({
+ month,
+ monthName,
+ today,
+ total,
+ spent,
+ txns,
+ editing,
+ onSave,
+ onCancel,
+ loading,
+}: {
+ month: Date;
+ monthName: string;
+ today: Date;
+ total: number | null;
+ spent: number;
+ txns: Txn[];
+ editing: boolean;
+ onSave: (total: number) => void;
+ onCancel: () => void;
+ loading: boolean;
+}) {
+ const money = useMoney();
+ const p = pace(total ?? 0, spent, today, month);
+ const days = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
+ const span = period(month, new Date(month.getFullYear(), month.getMonth(), days));
+ const t = p.evenPct === null ? days - 1 : dayIndex(span, today);
+ const over = total !== null && spent > total;
+ const tone = over ? "bad" : p.vsPace !== null && p.vsPace > 0 ? "warn" : "good";
+ return (
+
+ {over ? "Over budget" : p.evenPct === null ? "Within budget" : tone === "warn" ? "Ahead of pace" : "On track"}
+
+ )
+ }
+ loading={loading}
+ skeletonHeight={250}
+ data-testid="budget-hero"
+ >
+
+
+
+ {money(Math.round(spent))}
+
+ {editing ? : total !== null ? `of ${money(total)}` : "no budget set"}
+
+
+ {total !== null && (
+ <>
+
+
+ {p.evenPct !== null && (
+
+ )}
+
+
+ {Math.round(p.pct * 100)}% spent
+ {p.evenPct !== null && Even pace today: {Math.round(p.evenPct * 100)}% }
+
+
+
+ {p.evenPct !== null && }
+ {p.perDay !== null && }
+
+ >
+ )}
+
+
+
+ Pace
+ Actual
+ {total !== null && (
+ Even pace to {money(total)}
+ )}
+
+
+
+
+
+ );
+}
+
+function Stat({ label, value, testId, bad }: { label: string; value: string; testId: string; bad?: boolean }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function TotalInput({ initial, onSave, onCancel }: { initial: number | null; onSave: (v: number) => void; onCancel: () => void }) {
+ const [v, setV] = useState(initial ? String(initial) : "");
+ const ref = useRef(null);
+ useEffect(() => ref.current?.focus(), []);
+ return (
+
+ of
+ setV(e.target.value)}
+ onBlur={onCancel}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") onCancel();
+ if (e.key === "Enter" && Number(v) > 0) onSave(Number(v));
+ }}
+ className="h-7 w-28 rounded-md border border-border bg-card px-2 text-[13px] text-foreground tabular-nums"
+ />
+
+ );
+}
diff --git a/frontend/src/app/(app)/budget/_components/history.tsx b/frontend/src/app/(app)/budget/_components/history.tsx
new file mode 100644
index 0000000..ff8021f
--- /dev/null
+++ b/frontend/src/app/(app)/budget/_components/history.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { PageCard } from "@/components/page/page-card";
+import type { BudgetMonth } from "@/lib/api/client";
+import { runRate } from "@/lib/budget-math";
+import { useMoney } from "@/lib/format";
+import { cn } from "@/lib/utils";
+
+const H = 130;
+const short = (m: string) => new Date(Number(m.slice(0, 4)), Number(m.slice(5)) - 1, 1).toLocaleString("en-US", { month: "short" });
+
+// The last months (spec 2026-10-22 §4.4): each month's budget as a dashed outline (only when it had one) and what was
+// spent as a bar inside it, green under and red over. This month is "so far", with a pale bar for where it's heading
+// at the pace so far. Beside it the track record over the past months that had a budget.
+export function BudgetHistory({ months, today, loading }: { months: BudgetMonth[]; today: Date; loading: boolean }) {
+ const money = useMoney();
+ const now = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
+ const projected = (m: BudgetMonth) => (m.month === now ? runRate(m.spent, today) : m.spent);
+ const max = Math.max(...months.map((m) => Math.max(m.budget ?? 0, projected(m))), 1);
+ const h = (v: number) => Math.max(2, (v / max) * H);
+ const judged = months.filter((m) => m.month !== now && m.budget !== null);
+ const under = judged.filter((m) => m.spent <= m.budget!).length;
+ const overMonths = judged.filter((m) => m.spent > m.budget!).map((m) => new Date(Number(m.month.slice(0, 4)), Number(m.month.slice(5)) - 1, 1).toLocaleString("en-US", { month: "long" }));
+
+ return (
+
+ Within budget
+ Over
+ Budget
+
+ }
+ loading={loading}
+ skeletonHeight={176}
+ data-testid="budget-history"
+ >
+
+
+ {months.map((m) => {
+ const cur = m.month === now;
+ const over = m.budget !== null && m.spent > m.budget;
+ return (
+
+ {/* amounts don't fit six across a phone; the bar's title still has them */}
+
{money(Math.round(m.spent))}
+
+ {m.budget !== null && (
+
+ )}
+ {cur && }
+
+
+
+ {short(m.month)}
+ {cur && · so far }
+
+
+ );
+ })}
+
+ {judged.length > 0 && (
+
+
Track record
+
+ {under} of {judged.length} months under
+
+
+ {overMonths.length ? `Over in ${overMonths.join(", ")}.` : "Under budget every month."}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/budget/budget-content.tsx b/frontend/src/app/(app)/budget/budget-content.tsx
new file mode 100644
index 0000000..343cd62
--- /dev/null
+++ b/frontend/src/app/(app)/budget/budget-content.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { ChevronLeft, ChevronRight, Pencil, Sparkles } from "lucide-react";
+import { PageHeader } from "@/components/page/page-header";
+import { Button } from "@/components/ui/button";
+import { budgetApi } from "@/lib/api/client";
+import { pace, usedTone } from "@/lib/budget-math";
+import { useMoney } from "@/lib/format";
+import { toast } from "@/lib/toast";
+import { Brief } from "../home/_components/brief";
+import { BudgetCategories } from "./_components/categories";
+import { DraftPanel, type DraftLine } from "./_components/draft-panel";
+import { BudgetHero } from "./_components/hero";
+import { BudgetHistory } from "./_components/history";
+import { useBudgetData, ym } from "./use-budget-data";
+
+const monthYear = (d: Date) => d.toLocaleString("en-US", { month: "long", year: "numeric" });
+const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" });
+
+// Budget (UI revamp Phase 5d, spec 2026-10-22 §4.4): am I on track, and what should my plan be? Budgets are saved per
+// month, so past months are shown as they were and are read-only; edits and the draft apply from this month on.
+// History sits above By category so the cards whose height depends on the data come last (no layout shift).
+export default function BudgetContent() {
+ const money = useMoney();
+ const today = useMemo(() => {
+ const n = new Date();
+ return new Date(n.getFullYear(), n.getMonth(), n.getDate());
+ }, []);
+ const [month, setMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1));
+ const current = ym(month) === ym(today);
+ const data = useBudgetData(month, today);
+ const loading = data.status === "loading";
+ const [editingTotal, setEditingTotal] = useState(false);
+ const [lines, setLines] = useState(null);
+ const [draftKey, setDraftKey] = useState(0);
+ const [drafting, setDrafting] = useState(false);
+ const router = useRouter();
+ const params = useSearchParams();
+ const autoDraft = useRef(params.get("draft") === "1");
+
+ // pending lines from an earlier draft show once loaded; ?draft=1 (Home's promo) files a fresh one on arrival
+ useEffect(() => {
+ if (loading || lines !== null) return;
+ if (autoDraft.current) {
+ autoDraft.current = false;
+ router.replace("/budget", { scroll: false });
+ void draft();
+ } else if (data.pending.length) setLines(data.pending);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loading]);
+
+ async function draft() {
+ setDrafting(true);
+ try {
+ setLines(await budgetApi.draft());
+ setDraftKey((k) => k + 1);
+ } catch {
+ toast.error("Couldn't draft a budget right now.");
+ } finally {
+ setDrafting(false);
+ }
+ }
+
+ async function save(body: Omit[0], "month">) {
+ try {
+ data.setBudget(await budgetApi.put({ month: ym(today), ...body }));
+ data.reload();
+ } catch {
+ toast.error("Couldn't save the budget.");
+ }
+ }
+
+ const b = data.budget;
+ const catSum = (b?.categories ?? []).reduce((s, c) => s + c.limit, 0);
+ const total = b?.monthly_budget || catSum || null;
+ const spent = b?.spent ?? 0;
+ const brief = (() => {
+ if (loading) return null;
+ const overCats = (b?.categories ?? []).filter((c) => usedTone(c.spent, c.limit) === "bad").map((c) => c.category);
+ const overText = overCats.length === 1 ? ` ${overCats[0]} is the one line over.` : overCats.length > 1 ? ` ${overCats.length} categories are over.` : "";
+ if (total === null) return current ? `No budget yet. You've spent ${money(Math.round(spent))} this month; set one below or let Lumen draft it.` : `No budget for ${monthLong(month)}. You spent ${money(Math.round(spent))}.`;
+ if (!current) return spent > total ? `${monthLong(month)} went ${money(Math.round(spent - total))} over its ${money(total)} budget.${overText}` : `${monthLong(month)} came in ${money(Math.round(total - spent))} under its ${money(total)} budget.${overText}`;
+ if (spent > total) return `You're ${money(Math.round(spent - total))} over this month's budget.${overText}`;
+ const p = pace(total, spent, today, month);
+ return p.vsPace! <= 0 ? `You're on track: ${money(Math.round(-p.vsPace!))} under where an even pace would put you.${overText}` : `You're ${money(Math.round(p.vsPace!))} ahead of an even pace, with ${p.daysLeft} days to go.${overText}`;
+ })();
+
+ const step = (d: number) => {
+ setEditingTotal(false);
+ setMonth((m) => new Date(m.getFullYear(), m.getMonth() + d, 1));
+ };
+ const actions = (
+ <>
+
+ step(-1)} className="p-1.5 text-muted-foreground hover:text-foreground">
+
+
+ {monthYear(month)}
+ step(1)} className="p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30">
+
+
+
+ {current && (
+ <>
+ setEditingTotal(true)} disabled={loading}>
+ Edit budget
+
+ void draft()} disabled={loading || drafting}>
+ Let Lumen draft
+
+ >
+ )}
+ >
+ );
+
+ return (
+
+
+
+
} actions={actions} />
+
{
+ setEditingTotal(false);
+ void save({ monthly_budget: v });
+ }}
+ onCancel={() => setEditingTotal(false)}
+ loading={loading}
+ />
+
+
+
+
+ save({ categories: [{ category, limit }] })}
+ loading={loading}
+ />
+ {lines && current && (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/budget/page.tsx b/frontend/src/app/(app)/budget/page.tsx
new file mode 100644
index 0000000..d83d3b4
--- /dev/null
+++ b/frontend/src/app/(app)/budget/page.tsx
@@ -0,0 +1,14 @@
+import type { Metadata } from "next";
+import { Suspense } from "react";
+import BudgetContent from "./budget-content";
+
+export const metadata: Metadata = { title: "Budget" };
+
+// useSearchParams (?draft=1) needs a Suspense boundary for the static build
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/budget/use-budget-data.ts b/frontend/src/app/(app)/budget/use-budget-data.ts
new file mode 100644
index 0000000..6b19957
--- /dev/null
+++ b/frontend/src/app/(app)/budget/use-budget-data.ts
@@ -0,0 +1,75 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { agentApi, budgetApi, type BudgetMonth, type BudgetState, type Proposal } from "@/lib/api/client";
+import type { Txn } from "@/lib/home-math";
+import { fetchTxns } from "../home/use-home-data";
+
+export const ym = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
+const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fulfilled" ? r.value : fallback);
+
+export type BudgetData = {
+ status: "loading" | "ready";
+ budget: BudgetState | null;
+ txns: Txn[];
+ history: BudgetMonth[];
+ /** set_budget proposals still waiting for a decision when the page opened */
+ pending: Proposal[];
+ /** after a save: replace the shown month's state */
+ setBudget: (b: BudgetState) => void;
+ /** after budget decisions: refetch this month's budget and the history */
+ reload: () => void;
+};
+
+// The Budget page's data (spec 2026-10-22 §4.4). The shown month's budget and transactions reload when the month
+// changes, keeping the old numbers until the new ones arrive (like Spending); history and pending draft lines load once.
+export function useBudgetData(month: Date, today: Date): BudgetData {
+ const [shown, setShown] = useState<{ budget: BudgetState | null; txns: Txn[] } | null>(null);
+ const [once, setOnce] = useState<{ history: BudgetMonth[]; pending: Proposal[] } | null>(null);
+ const key = ym(month);
+ const day = today.toDateString();
+
+ useEffect(() => {
+ let live = true;
+ const end = new Date(month.getFullYear(), month.getMonth() + 1, 0);
+ Promise.allSettled([budgetApi.get(key), fetchTxns(month, end < today ? end : today)]).then(([b, t]) => {
+ if (live) setShown({ budget: value(b, null), txns: value(t, []) });
+ });
+ return () => {
+ live = false;
+ };
+ // month is a fresh Date each render; its key is what matters
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, day]);
+
+ useEffect(() => {
+ let live = true;
+ Promise.allSettled([budgetApi.history(6, ym(today)), agentApi.proposals()]).then(([h, p]) => {
+ if (live) setOnce({ history: value(h, []), pending: value(p, []).filter((x) => x.type === "set_budget") });
+ });
+ return () => {
+ live = false;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [day]);
+
+ const setBudget = useCallback((budget: BudgetState) => setShown((s) => (s ? { ...s, budget } : s)), []);
+ const reload = useCallback(() => {
+ Promise.allSettled([budgetApi.get(ym(today)), budgetApi.history(6, ym(today))]).then(([b, h]) => {
+ if (b.status === "fulfilled") setShown((s) => (s ? { ...s, budget: b.value } : s));
+ if (h.status === "fulfilled") setOnce((o) => (o ? { ...o, history: h.value } : o));
+ });
+ // today is fixed for the page's life; its day is what matters
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [day]);
+
+ return {
+ status: shown && once ? "ready" : "loading",
+ budget: shown?.budget ?? null,
+ txns: shown?.txns ?? [],
+ history: once?.history ?? [],
+ pending: once?.pending ?? [],
+ setBudget,
+ reload,
+ };
+}
diff --git a/frontend/src/app/(app)/documents/documentsContent.tsx b/frontend/src/app/(app)/documents/documentsContent.tsx
deleted file mode 100644
index 5118239..0000000
--- a/frontend/src/app/(app)/documents/documentsContent.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import { FileText, Loader2, Trash2, Upload } from "lucide-react";
-import { DashboardShell } from "@/components/dashboard-shell";
-import { Button } from "@/components/ui/button";
-import { Card } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import {
- documentsApi,
- filesApi,
- type DocumentAnswer,
- type LumenDocument,
-} from "@/lib/api/client";
-import { toast } from "@/lib/toast";
-
-const DOC_TYPES = [
- { value: "purchase_order", label: "Purchase order" },
- { value: "contract", label: "Contract" },
- { value: "policy", label: "Policy" },
- { value: "invoice", label: "Invoice" },
- { value: "other", label: "Other" },
-];
-
-function errorMessage(error: unknown, fallback: string): string {
- const data = (error as { response?: { data?: { error?: string } } })?.response?.data;
- return data?.error ?? fallback;
-}
-
-export default function DocumentsContent() {
- const [documents, setDocuments] = useState([]);
- const [loading, setLoading] = useState(true);
- const [uploading, setUploading] = useState(false);
- const [docType, setDocType] = useState("contract");
- const [question, setQuestion] = useState("");
- const [asking, setAsking] = useState(false);
- const [answer, setAnswer] = useState(null);
- const fileInput = useRef(null);
-
- const refresh = useCallback(async () => {
- try {
- setDocuments(await documentsApi.list());
- } catch (error) {
- toast.error(errorMessage(error, "Couldn't load your documents."));
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- void refresh();
- }, [refresh]);
-
- async function onUpload(file: File | undefined) {
- if (!file) return;
- setUploading(true);
- try {
- const doc = await documentsApi.upload(file, docType);
- toast.success(`Indexed "${doc.title}" (${doc.chunk_count} sections)`);
- await refresh();
- } catch (error) {
- toast.error(errorMessage(error, "Upload failed."));
- } finally {
- setUploading(false);
- if (fileInput.current) fileInput.current.value = "";
- }
- }
-
- async function onDelete(doc: LumenDocument) {
- try {
- await documentsApi.remove(doc.id);
- setDocuments((docs) => docs.filter((d) => d.id !== doc.id));
- } catch (error) {
- toast.error(errorMessage(error, "Couldn't delete the document."));
- }
- }
-
- async function onAsk(event: React.FormEvent) {
- event.preventDefault();
- if (!question.trim()) return;
- setAsking(true);
- setAnswer(null);
- try {
- setAnswer(await documentsApi.ask(question.trim()));
- } catch (error) {
- const code = (error as { response?: { data?: { code?: string } } })?.response?.data?.code;
- const hint = code === "needs_key" || code === "key_rejected" ? " Add or fix your key in Settings." : "";
- toast.error(errorMessage(error, "Couldn't answer right now.") + hint);
- } finally {
- setAsking(false);
- }
- }
-
- return (
-
-
-
- {answer && (
-
-
{answer.answer}
- {answer.sources.length > 0 && (
-
-
Sources
- {answer.sources.map((s) => (
-
-
- {s.title}
- {s.section ? ` · ${s.section}` : ""}
-
-
{s.text}
-
- ))}
-
- )}
-
- )}
-
-
-
-
-
Your documents
-
- setDocType(e.target.value)}
- className="h-9 rounded-md border border-input bg-background px-2 text-sm"
- aria-label="Document type"
- >
- {DOC_TYPES.map((t) => (
-
- {t.label}
-
- ))}
-
- void onUpload(e.target.files?.[0])}
- />
- fileInput.current?.click()} disabled={uploading}>
- {uploading ? : }
- Upload PDF
-
-
-
- {loading ? (
- Loading…
- ) : documents.length === 0 ? (
- No documents yet. Upload a PDF to start asking questions about it.
- ) : (
-
- {documents.map((doc) => (
-
-
-
-
-
{doc.title}
-
- {DOC_TYPES.find((t) => t.value === doc.doc_type)?.label ?? doc.doc_type} · {doc.chunk_count} sections
-
-
-
- {doc.file_key && (
- void filesApi.open(doc.file_key!).catch(() => toast.error("Couldn't open the original."))}>
- Open original
-
- )}
- void onDelete(doc)}>
-
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx
index c6fb675..278c785 100644
--- a/frontend/src/app/(app)/home/_components/activity-card.tsx
+++ b/frontend/src/app/(app)/home/_components/activity-card.tsx
@@ -2,17 +2,17 @@ import Link from "next/link";
import type { ActivityItem } from "@/lib/api/client";
import { useMoney } from "@/lib/format";
import { fill, relativeTime } from "@/lib/home-math";
-import { HomeCard } from "./card";
+import { PageCard } from "@/components/page/page-card";
// What happened lately: statement imports, receipts read, your decisions (GET /api/home/summary).
export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; now: Date; loading: boolean }) {
const money = useMoney();
return (
-
+
{items.length === 0 ? (
Nothing imported yet.{" "}
-
+
Upload a receipt or a statement
{" "}
to get started.
@@ -21,10 +21,10 @@ export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; n
items.map((a, i) => (
{fill(a.text, a.amounts, money)}
- {relativeTime(a.at, now)}
+ {relativeTime(a.at, now)}
))
)}
-
+
);
}
diff --git a/frontend/src/app/(app)/home/_components/bills-card.tsx b/frontend/src/app/(app)/home/_components/bills-card.tsx
index 6e0192c..4efad3a 100644
--- a/frontend/src/app/(app)/home/_components/bills-card.tsx
+++ b/frontend/src/app/(app)/home/_components/bills-card.tsx
@@ -1,34 +1,34 @@
import { useMoney } from "@/lib/format";
import { parseDay } from "@/lib/home-math";
import type { Reminder } from "../use-home-data";
-import { HomeCard } from "./card";
+import { PageCard } from "@/components/page/page-card";
-// The next three recurring charges Lumen expects.
+// "Coming up": the next four recurring charges Lumen expects, each with a little date chip.
export function BillsCard({ reminders, today, loading }: { reminders: Reminder[]; today: Date; loading: boolean }) {
const money = useMoney();
const next = reminders
.filter((r) => r.type === "upcoming" && parseDay(r.date) >= today)
.sort((a, b) => a.date.localeCompare(b.date))
- .slice(0, 3);
+ .slice(0, 4);
return (
-
+
{next.length === 0 ? (
No bills coming up in the next month.
) : (
next.map((r) => {
const d = parseDay(r.date);
return (
-
-
- {d.toLocaleString("en-GB", { weekday: "short" })}
- {String(d.getDate()).padStart(2, "0")}
+
+
+ {d.toLocaleString("en-GB", { weekday: "short" })}
+ {d.getDate()}
{r.vendor}
- {r.amount == null ? "—" : money(r.amount)}
+ {r.amount == null ? "—" : money(r.amount)}
);
})
)}
-
+
);
}
diff --git a/frontend/src/app/(app)/home/_components/brief.tsx b/frontend/src/app/(app)/home/_components/brief.tsx
index 190c266..a7cf827 100644
--- a/frontend/src/app/(app)/home/_components/brief.tsx
+++ b/frontend/src/app/(app)/home/_components/brief.tsx
@@ -3,11 +3,11 @@ import { Skeleton } from "@/components/ui/skeleton";
// The one- or two-sentence brief, in the serif. Reserved height = two lines, so it never pushes the cards down.
export function Brief({ text }: { text: string | null }) {
return (
-
+
{text === null ? (
-
+
) : (
-
+
{text}
)}
diff --git a/frontend/src/app/(app)/home/_components/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx
index 777b64b..1e9c483 100644
--- a/frontend/src/app/(app)/home/_components/budget-card.tsx
+++ b/frontend/src/app/(app)/home/_components/budget-card.tsx
@@ -1,33 +1,36 @@
"use client";
-import { useRef, useState } from "react";
-import { agentApi, budgetApi, type BudgetState, type Proposal } from "@/lib/api/client";
+import Link from "next/link";
+import type { BudgetState } from "@/lib/api/client";
import { useMoney } from "@/lib/format";
-import { toast } from "@/lib/toast";
-import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
-import { HomeCard } from "./card";
+import { PageCard } from "@/components/page/page-card";
// Overall budget = the profile's monthly budget (onboarding); per-category limits come from approved set_budget
-// proposals or PUT /api/budget. Nothing here is invented: with neither, the card says so.
+// proposals or PUT /api/budget. Nothing here is invented: with neither, the card says so. The top four categories
+// by spend show as bars: green, amber from 90% of the limit, red when over.
export function BudgetCard({ budget, monthName, today, loading }: { budget: BudgetState | null; monthName: string; today: Date; loading: boolean }) {
const money = useMoney();
const total = budget?.monthly_budget ?? null;
- const cats = (budget?.categories ?? []).slice(0, 3);
+ const cats = [...(budget?.categories ?? [])].sort((a, b) => b.spent - a.spent).slice(0, 4);
const spent = budget?.spent ?? 0;
const daysLeft = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() - today.getDate() + 1;
+ const pct = total ? Math.round((spent / total) * 100) : 0;
return (
-
+
{total ? (
<>
-
- {money(spent)}
of {money(total)}
+
+ {money(spent)} of {money(total)}
-
-
total ? "bg-bad" : "bg-good")} style={{ width: `${Math.min(100, (spent / total) * 100)}%` }} />
+
+
+ total ? "bg-bad" : "bg-good")} style={{ width: `${Math.max(0, Math.min(100, pct))}%` }} />
+
+
{pct}%
-
+
{spent > total
? `${money(spent - total)} over`
: `${money(total - spent)} left · ${daysLeft} ${daysLeft === 1 ? "day" : "days"} · ~${money((total - spent) / daysLeft)}/day`}
@@ -40,141 +43,44 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg
>
) : null}
{cats.length > 0 && (
-
- {cats.map((c) => (
-
- {c.category}
- c.limit && "text-bad")}>
- {money(c.spent)} / {money(c.limit)}
-
-
- ))}
+
+ {cats.map((c) => {
+ const used = c.limit > 0 ? c.spent / c.limit : 0;
+ const over = c.spent > c.limit;
+ return (
+
+
+ {c.category}
+
+ {money(c.spent)} / {money(c.limit)}
+
+
+
+ = 0.9 ? "bg-amber-500" : "bg-primary")}
+ style={{ width: `${Math.max(0, Math.min(100, used * 100))}%` }}
+ />
+
+
+ );
+ })}
)}
-
+
);
}
-type Line = Proposal & { decided?: "approve" | "reject" };
-
-// "Let Lumen draft your budget": files set_budget proposals (POST /api/budget/draft) and lets you approve each line.
-export function DraftBudgetPromo({ onChanged }: { onChanged: () => void }) {
- const money = useMoney();
- const [open, setOpen] = useState(false);
- const [busy, setBusy] = useState(false);
- const [lines, setLines] = useState
(null);
- const [deciding, setDeciding] = useState(false);
- const [replaced, setReplaced] = useState(false);
- const inFlight = useRef(false); // a ref, so a double click is blocked before React re-renders
-
- async function draft() {
- setBusy(true);
- try {
- setLines(await budgetApi.draft());
- setReplaced(false);
- setOpen(true);
- } catch {
- toast.error("Couldn't draft a budget right now.");
- } finally {
- setBusy(false);
- }
- }
-
- async function decide(ids: string[], decision: "approve" | "reject") {
- if (inFlight.current) return;
- inFlight.current = true;
- setDeciding(true);
- try {
- for (const id of ids) {
- await agentApi.decide(id, decision);
- setLines((ls) => ls?.map((l) => (l.id === id ? { ...l, decided: decision } : l)) ?? ls);
- }
- } catch (error) {
- // a newer draft (from here or from Ask Lumen) retired these lines
- if ((error as { response?: { status?: number } })?.response?.status === 409) setReplaced(true);
- else toast.error("Couldn't save that decision.");
- } finally {
- inFlight.current = false;
- setDeciding(false);
- }
- }
-
- const pending = (lines ?? []).filter((l) => !l.decided).map((l) => l.id);
-
+// "Let Lumen draft your budget": the draft itself lives on the Budget page (Phase 5d); ?draft=1 files it on arrival.
+export function DraftBudgetPromo() {
return (
Let Lumen draft your budget
Built from 3 months of your actual spending. You approve every line before it's saved.
- void draft()}
- disabled={busy}
- className="label-mono mt-2.5 rounded-full bg-foreground px-3.5 py-1.5 text-background transition-opacity disabled:opacity-60"
- >
+
Draft budget
-
- {
- setOpen(o);
- if (!o) onChanged();
- }}
- >
-
- Your draft budget
- Each line is a monthly limit. Nothing is saved until you approve it.
- {lines && lines.length === 0 ? (
-
- Not enough history yet. Lumen needs about two months of spending to draft a budget.
-
- ) : (
- <>
-
- {lines?.map((l) => (
-
-
-
{l.target}
-
{l.reason}
-
-
{money(Number(l.payload.limit))}
-
- {l.decided ? (
-
- {l.decided === "approve" ? "Approved" : "Skipped"}
-
- ) : (
- <>
- void decide([l.id], "reject")}>
- Skip
-
- void decide([l.id], "approve")}>
- Approve
-
- >
- )}
-
-
- ))}
-
- {replaced && (
-
- This draft was replaced. Draft again.
-
- )}
- void decide(pending, "approve")}
- className="mt-1 w-full rounded-lg bg-foreground py-2 text-sm text-background disabled:opacity-50"
- >
- Approve all
-
- >
- )}
-
-
+
);
}
diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx
index f4da6f4..b808960 100644
--- a/frontend/src/app/(app)/home/_components/calendar-card.tsx
+++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx
@@ -1,61 +1,122 @@
+import Link from "next/link";
+import { PageCard } from "@/components/page/page-card";
+import { VendorChip } from "@/components/page/vendor-chip";
import { useMoney } from "@/lib/format";
-import { calendarBuckets, heat, parseDay, type Period, type Txn } from "@/lib/home-math";
-import { HomeCard } from "./card";
+import { calendarBuckets, dayIndex, heat, parseDay, type Period, type Txn } from "@/lib/home-math";
+import { cn } from "@/lib/utils";
+import type { Reminder } from "../use-home-data";
-// "₹2.2k" in the user's own currency: take the symbol from how useMoney prints zero
-const kShort = (n: number, symbol: string) => (n >= 1000 ? `${symbol}${(n / 1000).toFixed(1)}k` : `${symbol}${Math.round(n)}`);
+// "2.2k"; the user's currency symbol (from how useMoney prints zero) goes in front, except on phones, where a cell
+// is too narrow for "₹25.6k" and the card's total already says the currency
+const kShort = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n)}`);
const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" });
+const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" });
-// This month as a heat grid (darker = more spent) beside the latest transactions.
-export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: Period; loading: boolean }) {
+// This month day by day (spec 2026-10-22 §4.1): what each day cost, darker = more, today outlined, and the bills
+// still to come named on their days; the latest transactions beside it. The card is always as tall as a six-week
+// month, so it never changes height between loading and loaded, or from one month to the next.
+export function CalendarCard({
+ txns,
+ reminders,
+ period,
+ today,
+ loading,
+}: {
+ txns: Txn[];
+ reminders: Reminder[];
+ period: Period;
+ today: Date;
+ loading: boolean;
+}) {
const money = useMoney();
const symbol = money(0).replace(/[\d.,\s]/g, "");
const days = calendarBuckets(txns, period);
const max = Math.max(...days.map((d) => d.amount), 0);
+ const total = days.reduce((s, d) => s + d.amount, 0);
+ const spentDays = days.filter((d) => d.amount > 0).length;
+ const t = dayIndex(period, today);
const lead = (period.start.getDay() + 6) % 7; // Monday first
const recent = [...txns].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 5);
+ const billOn = new Map();
+ for (const r of reminders) {
+ const i = dayIndex(period, parseDay(r.date));
+ if (r.type === "upcoming" && i > t && i < period.days && !billOn.has(i)) billOn.set(i, r.vendor);
+ }
return (
-
-
-
- {Array.from({ length: lead }, (_, i) => (
-
- ))}
- {days.map(({ day, amount }) => {
- const h = heat(amount, max);
- return (
-
0.5 ? "#fff" : undefined } : undefined}
- >
- {day}
- {amount > 0 &&
{kShort(amount, symbol)} }
+
+
+
+
+ {money(Math.round(total))}{" "}
+
+ across {spentDays} {spentDays === 1 ? "day" : "days"}
+
+
+
+ {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
+
+ {d}
- );
- })}
+ ))}
+ {Array.from({ length: lead }, (_, i) => (
+
+ ))}
+ {days.map(({ day, amount }, i) => {
+ const h = heat(amount, max);
+ const future = i > t;
+ const bill = billOn.get(i);
+ return (
+
0.5 ? "#fff" : undefined } : undefined}
+ >
+ {day}
+ {amount > 0 && (
+
+ {symbol}
+ {kShort(amount)}
+
+ )}
+ {future && bill && {bill} }
+
+ );
+ })}
+
-
Recent transactions
+
+ Recent transactions
+
+ See all
+
+
{recent.length === 0 ? (
No transactions this month yet.
) : (
recent.map((t) => (
-
+
+
{t.vendor_name ?? "Unknown"}
- {t.category ?? "Uncategorised"} · {dayMonth(t.date)}
+ {dayMonth(t.date)} · {t.category ?? "Uncategorised"}
- {money(t.total_amount ?? 0)}
+ {money(t.total_amount ?? 0)}
))
)}
-
+
);
}
diff --git a/frontend/src/app/(app)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx
index 8c1e987..d8ab839 100644
--- a/frontend/src/app/(app)/home/_components/lumen-found.tsx
+++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx
@@ -1,43 +1,71 @@
import Link from "next/link";
-import type { HomeSummary } from "@/lib/api/client";
+import { Copy, ReceiptText, RefreshCw, TrendingUp, type LucideIcon } from "lucide-react";
+import type { FoundItem, HomeSummary } from "@/lib/api/client";
import { useMoney } from "@/lib/format";
import { fill } from "@/lib/home-math";
-import { HomeCard } from "./card";
+import { PageCard } from "@/components/page/page-card";
+
+// How each kind looks and what its link does. A row only links somewhere it can be acted on (spec 2026-10-22 §4.1).
+const KIND: Record
= {
+ duplicate: { icon: Copy, tint: "bg-bad/10 text-bad", action: "Review duplicate" },
+ review: { icon: ReceiptText, tint: "bg-primary/10 text-primary", action: "Review receipts" },
+ spike: { icon: TrendingUp, tint: "bg-amber-500/10 text-amber-700", action: "See in Spending" },
+ price_rise: { icon: RefreshCw, tint: "bg-primary/10 text-primary", action: "See the charges" },
+};
+
+// What "Ask Lumen" types into Ask for you (not sent: you can edit it first).
+export const askAbout = (item: FoundItem, money: (n: number) => string) =>
+ `Tell me more about this: ${fill(item.headline, item.amounts, money).replace(/\.$/, "")}`;
// "Lumen found": duplicates, receipts to review, spending spikes and price rises (GET /api/home/summary, rule-based).
// A money "worth" is only claimed for things you may get back (duplicates).
-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 && (
+
+ {total} to look at
+ {summary!.worth_total > 0 && ` · ${money(summary!.worth_total)} you may get back`}
+
+ )
+ }
+ loading={loading}
+ skeletonHeight={132}
+ data-testid="lumen-found"
+ >
{found.length === 0 ? (
Nothing needs you right now.
) : (
- <>
-
- {total} {total === 1 ? "thing" : "things"} worth a look
- {summary!.worth_total > 0 && (
- · {money(summary!.worth_total)} you may get back
- )}
-
-
- {found.map((f, i) => (
-
-
{f.tag}
-
{fill(f.headline, f.amounts, money)}
-
{fill(f.detail, f.amounts, money)}
- {f.href && (
-
- {f.kind === "review" ? "Review →" : "See →"}
+ found.map((f, i) => {
+ const { icon: Icon, tint, action } = KIND[f.kind] ?? KIND.review;
+ return (
+
+
+
+
+
+
{fill(f.headline, f.amounts, money)}
+
{fill(f.detail, f.amounts, money)}
+
+ {f.href && (
+
+ {action}
+
+ )}
+
+ Ask Lumen
- )}
+
- ))}
-
- >
+
+ );
+ })
)}
-
+
);
}
diff --git a/frontend/src/app/(app)/home/_components/spend-card.tsx b/frontend/src/app/(app)/home/_components/spend-card.tsx
index e56885a..2743b8e 100644
--- a/frontend/src/app/(app)/home/_components/spend-card.tsx
+++ b/frontend/src/app/(app)/home/_components/spend-card.tsx
@@ -1,19 +1,29 @@
"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 { useMoney } from "@/lib/format";
-import { dayIndex, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math";
+import { dayIndex, ordinal, parseDay, sameDayDelta, type Period, type Range } from "@/lib/home-math";
import { cn } from "@/lib/utils";
import type { Forecast, Reminder } from "../use-home-data";
-import { HomeCard } from "./card";
const RANGES: Range[] = ["1W", "1M", "3M", "YTD"];
-const TITLE: Record
= { "1W": "Spent this week", "1M": "Spent this month", "3M": "Spent this quarter", YTD: "Spent this year" };
-const W = 860;
-const H = 160;
-const short = (d: Date) => d.toLocaleString("en-US", { month: "short" }); // "Sep", not en-GB's "Sept"
-const tick = (d: Date) => d.toLocaleString("en-GB", { day: "2-digit", month: "short" });
+const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" });
+
+function title(range: Range, period: Period) {
+ if (range === "1M") return `Spent in ${monthLong(period.start)}`;
+ if (range === "YTD") return `Spent in ${period.start.getFullYear()}`;
+ return range === "1W" ? "Spent this week" : "Spent this quarter";
+}
+
+function compared(range: Range, previous: Period, today: Date) {
+ if (range === "1M") return `vs ${monthLong(previous.start)} by the ${ordinal(today.getDate())}`;
+ if (range === "YTD") return `vs ${previous.start.getFullYear()} at this point`;
+ return range === "1W" ? "vs last week at this point" : "vs last quarter at this point";
+}
// Counts up once per browser session, then shows the number straight away (motion rule: no replay on revisits).
function CountUp({ value, format }: { value: number; format: (n: number) => string }) {
@@ -32,9 +42,12 @@ function CountUp({ value, format }: { value: number; format: (n: number) => stri
return <>{format(shown)}>;
}
+// Home's hero (spec 2026-10-22 §4.1, Origin style): the period's total, how it compares, where it's heading, and the
+// running total as a smooth line against the previous period, with upcoming bills pinned on the axis.
export function SpendCard({
range,
onRange,
+ onUpload,
period,
previous,
cur,
@@ -46,6 +59,7 @@ export function SpendCard({
}: {
range: Range;
onRange: (r: Range) => void;
+ onUpload: () => void;
period: Period;
previous: Period;
cur: number[];
@@ -76,95 +90,89 @@ 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 - 20);
- const line = (vals: number[], from = 0) => vals.map((v, i) => `${i ? "L" : "M"}${x(i + from).toFixed(1)} ${y(v).toFixed(1)}`).join(" ");
- const actual = cur.slice(0, t + 1);
-
const pins = reminders.filter((r) => {
if (r.type !== "upcoming") return false;
const i = dayIndex(period, parseDay(r.date));
return i > t && i < period.days;
});
- const ticks = [0, Math.round((period.days - 1) * 0.25), Math.round((period.days - 1) * 0.5), t, period.days - 1]
- .filter((v, i, a) => a.indexOf(v) === i)
- .sort((a, b) => a - b);
- const vsLabel =
- range === "1M" ? `vs ${short(previous.start)} at this point` : range === "YTD" ? `vs ${previous.start.getFullYear()} at this point` : "vs last period at this point";
+ const legend = (
+
+
+
+ Now
+
+
+
+ Before
+
+ {fc.length > 0 && (
+
+
+ Forecast
+
+ )}
+
+ );
return (
-
-
-
-
- money(n)} />
-
-
- {delta && Math.round(delta.abs) !== 0 && (
-
- {delta.abs < 0 ? "↓" : "↑"} {money(Math.abs(Math.round(delta.abs)))} {vsLabel}
-
- )}
-
-
-
- — now
- ┄ before
- {fc.length > 0 && · forecast }
-
+
+
+
+ }
+ loading={loading}
+ skeletonHeight={348}
+ className="relative"
+ data-testid="spend-card"
+ >
+
+ money(n)} />
+
+
+ {delta && Math.round(delta.abs) !== 0 && (
+
+
+ {delta.abs < 0 ? "−" : "+"}
+ {money(Math.abs(Math.round(delta.abs)))} ({Math.abs(Math.round(delta.pct))}%)
+ {" "}
+ {compared(range, previous, today)}
+
+ )}
+ {fc.length > 0 && (
+
+ {delta && Math.round(delta.abs) !== 0 && " · "}heading for{" "}
+ {money(Math.round(fc[fc.length - 1]))}
+
+ )}
{empty ? (
-
+
Nothing spent yet this {range === "1W" ? "week" : range === "1M" ? "month" : range === "3M" ? "quarter" : "year"}.
Upload a receipt or import a statement and your spending will show up here.
) : (
- <>
-
-
-
-
-
-
-
-
- {prev.length > 1 && (
-
- )}
-
-
- {fc.length > 0 && (
-
- )}
-
-
- {/* HTML overlays keep text and dots crisp while the svg stretches */}
-
- {pins.map((p) => {
- const i = dayIndex(period, parseDay(p.date));
- return (
-
-
- {p.vendor}
-
-
-
- );
- })}
-
-
- {ticks.map((i) => (
-
- {i === t ? "today" : tick(new Date(period.start.getFullYear(), period.start.getMonth(), period.start.getDate() + i))}
-
- ))}
-
- >
+
)}
{RANGES.map((r) => (
@@ -174,14 +182,14 @@ export function SpendCard({
aria-pressed={r === range}
onClick={() => onRange(r)}
className={cn(
- "label-mono rounded-md px-2.5 py-1 transition-colors",
- r === range ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
+ "label-ui rounded-lg px-3.5 py-1 transition-colors",
+ r === range ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{r}
))}
-
+
);
}
diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx
index 0d98a48..ff4c2a1 100644
--- a/frontend/src/app/(app)/home/homeContent.tsx
+++ b/frontend/src/app/(app)/home/homeContent.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useProfile } from "@/components/profile/profile-provider";
import { DialogDemo } from "@/components/uploadDialog";
@@ -8,6 +8,7 @@ import { ExportDialog } from "@/components/exportDialog";
import type { FoundItem } from "@/lib/api/client";
import { useMoney } from "@/lib/format";
import { briefSentence, cumulative, dayIndex, fill, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math";
+import { PageHeader } from "@/components/page/page-header";
import { Brief } from "./_components/brief";
import { ActivityCard } from "./_components/activity-card";
import { BillsCard } from "./_components/bills-card";
@@ -37,6 +38,10 @@ export default function HomeContent() {
const { profile } = useProfile();
const money = useMoney();
const [range, setRange] = useState
("1M");
+ const [uploadOpen, setUploadOpen] = useState(openUpload);
+ useEffect(() => {
+ if (openUpload) setUploadOpen(true);
+ }, [openUpload]);
const today = useMemo(() => {
const n = new Date();
return new Date(n.getFullYear(), n.getMonth(), n.getDate());
@@ -68,40 +73,39 @@ export default function HomeContent() {
-
-
- {firstName ? `Good ${partOfDay(new Date().getHours())}, ${firstName}` : "Welcome back"}
-
-
-
-
-
-
-
-
-
-
+
}
+ actions={
+ <>
+
+
+ >
+ }
+ />
+
setUploadOpen(true)}
+ period={chart.current}
+ previous={chart.previous}
+ cur={cumulative(data.txns, chart.current)}
+ prev={cumulative(data.prevTxns, chart.previous)}
+ forecast={data.forecast}
+ reminders={data.reminders}
+ today={today}
+ loading={data.status === "loading"}
+ />
+
-
+
-
{/* once you have category budgets, the promo has done its job */}
- {!data.budget?.categories.length &&
}
+ {!data.budget?.categories.length &&
}
diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts
index 054cd53..1934f9f 100644
--- a/frontend/src/app/(app)/home/use-home-data.ts
+++ b/frontend/src/app/(app)/home/use-home-data.ts
@@ -1,10 +1,11 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
-import { aiAnalyticsApi, budgetApi, homeApi, transactionApi, type BudgetState, type HomeSummary } from "@/lib/api/client";
-import { periods, type Range, type Txn } from "@/lib/home-math";
+import { useEffect, useState } from "react";
+import { aiAnalyticsApi, billsApi, budgetApi, homeApi, transactionApi, type BudgetState, type HomeSummary, type RecurringItem } from "@/lib/api/client";
+import { parseDay, periods, type Range, type Txn } from "@/lib/home-math";
export type Reminder = { id: string | number; vendor: string; amount: number | null; date: string; type: string; category?: string };
+import { toBills } from "@/lib/bills-math";
export type Forecast = { dates: string[]; values: number[] } | null;
export type HomeData = {
status: "loading" | "ready";
@@ -16,15 +17,13 @@ export type HomeData = {
forecast: Forecast;
summary: HomeSummary | null;
budget: BudgetState | null;
- /** refetch the summary and the budget (after budget decisions) */
- reload: () => void;
};
const ymd = (d: Date) =>
`${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({
@@ -83,9 +82,9 @@ export function useHomeData(range: Range, today: Date): HomeData {
useEffect(() => {
let live = true;
Promise.allSettled([
- aiAnalyticsApi.getReminders(31).then((r: { reminders?: Reminder[] }) => r.reminders ?? []),
+ billsApi.recurring().then((r) => toBills(r.items, today)),
homeApi.summary(),
- budgetApi.get(),
+ budgetApi.get(`${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`),
]).then(([reminders, summary, budget]) => {
if (live) setExtras({ reminders: value(reminders, []), summary: value(summary, null), budget: value(budget, null) });
});
@@ -98,15 +97,10 @@ export function useHomeData(range: Range, today: Date): HomeData {
return () => {
live = false;
};
+ // today is a fresh Date each render; the day is what matters
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [day]);
- const reload = useCallback(() => {
- // decisions are POSTs, which already cleared the cached summary, so this refetches it
- Promise.allSettled([homeApi.summary(), budgetApi.get()]).then(([summary, budget]) =>
- setExtras((e) => (e ? { ...e, summary: value(summary, e.summary), budget: value(budget, e.budget) } : e)),
- );
- }, []);
-
return {
status: txns && extras ? "ready" : "loading",
txns: txns?.txns ?? [],
@@ -116,7 +110,6 @@ export function useHomeData(range: Range, today: Date): HomeData {
reminders: extras?.reminders ?? [],
summary: extras?.summary ?? null,
budget: extras?.budget ?? null,
- reload,
forecast,
};
}
diff --git a/frontend/src/app/(app)/receipts/_components/library.tsx b/frontend/src/app/(app)/receipts/_components/library.tsx
new file mode 100644
index 0000000..04096eb
--- /dev/null
+++ b/frontend/src/app/(app)/receipts/_components/library.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { useState } from "react";
+import { FileSpreadsheet, FileText, Receipt, Search, Trash2 } from "lucide-react";
+import { PageCard } from "@/components/page/page-card";
+import { filesApi, type LumenDocument, type ReviewItem, type StatementImport } from "@/lib/api/client";
+import { toast } from "@/lib/toast";
+import { useMoney } from "@/lib/format";
+import { parseUtc, type Txn } from "@/lib/home-math";
+import { cn } from "@/lib/utils";
+
+type Kind = "receipt" | "document" | "statement";
+export type LibraryRow = {
+ key: string;
+ kind: Kind;
+ title: string;
+ sub: string;
+ from: "Upload" | "Gmail" | "Import";
+ date: string | null;
+ amount: number | null;
+ review: boolean;
+ /** the original upload, when one was kept */
+ fileKey?: string | null;
+ onDelete?: () => void;
+};
+const CHIPS: { label: string; kind: Kind | null }[] = [
+ { label: "All", kind: null },
+ { label: "Receipts & invoices", kind: "receipt" },
+ { label: "Statements", kind: "statement" },
+ { label: "Documents", kind: "document" },
+];
+const ICON = { receipt: Receipt, document: FileText, statement: FileSpreadsheet };
+const DOC_TYPE: Record = { purchase_order: "Purchase order", contract: "Contract", policy: "Policy", invoice: "Invoice", other: "Document" };
+const dayMonth = (s: string | null) => (s ? parseUtc(s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : "—");
+const range = (a: string | null, b: string | null) => (a && b ? `${dayMonth(a)} – ${dayMonth(b)}` : "");
+const PAGE = 10;
+
+// Everything the user has given Lumen in one list, newest first (spec 2026-10-22 §4.5): receipts and invoices (with
+// the ones still in review), documents, and statement imports. Statement lines themselves are transactions, not files,
+// so a statement shows once, as its import. Reading/Failed states aren't stored yet, so every file is Done or in review.
+export function toRows(d: { review: ReviewItem[]; receipts: Txn[]; documents: LumenDocument[]; imports: StatementImport[] }, remove: (id: string) => void): LibraryRow[] {
+ const rows: LibraryRow[] = [
+ ...d.review.map((r) => ({
+ key: `r-${r.id}`, kind: "receipt" as const, title: String(r.invoice.vendor_name ?? "Unknown vendor"),
+ sub: `Receipt · ${String(r.invoice.invoice_number ?? "no bill number")}`, from: r.invoice.source === "email" ? ("Gmail" as const) : ("Upload" as const),
+ date: r.created_at, amount: typeof r.invoice.total_amount === "number" ? r.invoice.total_amount : null, review: true,
+ fileKey: typeof r.invoice.file_key === "string" ? r.invoice.file_key : null,
+ })),
+ ...d.receipts.map((t) => ({
+ key: `t-${t.id}`, kind: "receipt" as const, title: t.vendor_name ?? "Unknown vendor",
+ sub: `Receipt${t.category ? ` · ${t.category}` : ""}`, from: t.source === "email" ? ("Gmail" as const) : ("Upload" as const),
+ date: t.date, amount: t.total_amount, review: false, fileKey: (t as Txn & { file_key?: string | null }).file_key,
+ })),
+ ...d.documents.map((doc) => ({
+ key: `d-${doc.id}`, kind: "document" as const, title: doc.title, sub: `${DOC_TYPE[doc.doc_type] ?? "Document"} · ${doc.filename ?? `${doc.chunk_count} sections`}`,
+ from: "Upload" as const, date: doc.created_at, amount: null, review: false, fileKey: doc.file_key, onDelete: () => remove(doc.id),
+ })),
+ ...d.imports.map((i) => ({
+ key: `i-${i.id}`, kind: "statement" as const, title: i.label ?? i.file_name ?? "Statement",
+ sub: `Statement · ${i.lines} lines${i.period_from ? ` · ${range(i.period_from, i.period_to)}` : ""}`, from: "Import" as const,
+ date: i.created_at, amount: null, review: false,
+ })),
+ ];
+ return rows.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? ""));
+}
+
+export function Library({ rows }: { rows: LibraryRow[] }) {
+ const money = useMoney();
+ const [kind, setKind] = useState(null);
+ const [q, setQ] = useState("");
+ const [shown, setShown] = useState(PAGE);
+ const needle = q.trim().toLowerCase();
+ const list = rows.filter((r) => (!kind || r.kind === kind) && (!needle || `${r.title} ${r.sub}`.toLowerCase().includes(needle)));
+
+ return (
+ {rows.length} {rows.length === 1 ? "file" : "files"}} skeletonHeight={0} data-testid="library">
+
+
+ {CHIPS.filter((c) => !c.kind || rows.some((r) => r.kind === c.kind)).map((c) => (
+ (setKind(c.kind), setShown(PAGE))}
+ className={cn("label-ui rounded-full px-2.5 py-1", kind === c.kind ? "bg-foreground text-background" : "text-muted-foreground hover:bg-muted hover:text-foreground")}
+ >
+ {c.label}
+
+ ))}
+
+
+
+ (setQ(e.target.value), setShown(PAGE))} className="min-w-0 flex-1 bg-transparent text-[13px] outline-none" />
+
+
+
+ File
+ Came from
+ Date
+ Amount
+ Status
+
+ {list.length === 0 && {rows.length ? "Nothing matches." : "Nothing here yet. Upload a receipt or import a statement."}
}
+ {list.slice(0, shown).map((r) => {
+ const Icon = ICON[r.kind];
+ return (
+
+
+
+
+ {r.fileKey ? (
+ void filesApi.open(r.fileKey!).catch(() => toast.error("Couldn't open the original."))}
+ className="block max-w-full truncate text-left font-medium hover:underline"
+ >
+ {r.title}
+
+ ) : (
+ {r.title}
+ )}
+
+ {r.sub}
+ · {r.from} · {dayMonth(r.date)}
+
+
+
+ {r.from}
+ {dayMonth(r.date)}
+ {r.amount === null ? — : money(r.amount)}
+
+
+ {r.review ? "Needs review" : "Done"}
+
+ {r.onDelete && (
+
+
+
+ )}
+
+
+ );
+ })}
+ {list.length > shown && (
+ setShown((n) => n + 20)} className="label-ui mt-3 text-foreground/80 hover:text-foreground">
+ Show {Math.min(20, list.length - shown)} more
+
+ )}
+
+ );
+}
diff --git a/frontend/src/app/(app)/receipts/_components/review-cards.tsx b/frontend/src/app/(app)/receipts/_components/review-cards.tsx
new file mode 100644
index 0000000..9b52a1b
--- /dev/null
+++ b/frontend/src/app/(app)/receipts/_components/review-cards.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import { useState } from "react";
+import { PageCard } from "@/components/page/page-card";
+import { VendorChip } from "@/components/page/vendor-chip";
+import { filesApi, reviewApi, type ReviewItem } from "@/lib/api/client";
+import { eventBus, EVENTS } from "@/lib/events";
+import { useMoney } from "@/lib/format";
+import { toast } from "@/lib/toast";
+import { parseUtc } from "@/lib/home-math";
+import { cn } from "@/lib/utils";
+
+type Field = { key: "vendor_name" | "date" | "invoice_number" | "total_amount"; label: string };
+const FIELDS: Field[] = [
+ { key: "vendor_name", label: "Vendor" },
+ { key: "date", label: "Date" },
+ { key: "invoice_number", label: "Bill no." },
+ { key: "total_amount", label: "Total" },
+];
+// which field each validation flag (backend extract/validate.py) is about, and the reason in plain words
+const DOUBT: Record = { total_mismatch: "total_amount", duplicate: "invoice_number", bad_date: "date", unknown_vendor: "vendor_name", no_currency: "total_amount" };
+const REASON: Record = {
+ total_mismatch: "Totals don't add up.",
+ duplicate: "Looks like a duplicate.",
+ bad_date: "Date unclear.",
+ unknown_vendor: "New to you.",
+ no_currency: "No currency printed.",
+ unknown_po: "Purchase order not found.",
+ po_mismatch: "Doesn't match its purchase order.",
+ possible_injection: "Unusual text on the bill.",
+};
+const dayMonth = (s: string | null) => (s ? parseUtc(s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : "");
+// the checks' details are terse ("line items 80 + tax 20 != total 110"); the arithmetic one gets a sentence instead
+const plain = (rule: string, detail: string) =>
+ rule === "total_mismatch" ? "The items and tax don't add up to the printed total." : `${detail.charAt(0).toUpperCase()}${detail.slice(1)}.`;
+// API errors are {error} (api/errors.py); FastAPI's own validation errors are {detail}
+const errorText = (e: unknown) => {
+ const data = (e as { response?: { data?: { error?: string; detail?: unknown } } })?.response?.data;
+ return data?.error ?? (typeof data?.detail === "string" ? data.detail : undefined);
+};
+// a total someone typed: digits with an optional decimal part, more than zero (no "1,234", no blank)
+const validTotal = (v: string) => /^\d+(\.\d+)?$/.test(v.trim()) && Number(v) > 0;
+
+// Needs your review (spec 2026-10-22 §4.5; replaces the old Review tab): one card per flagged receipt with the fields
+// the checks doubted highlighted, the reason in plain words, and Approve / Edit / Reject. A duplicate offers "Keep both"
+// instead of Approve. Compare is left out: the item doesn't carry the record it duplicates.
+export function ReviewCards({ items, onDecided }: { items: ReviewItem[]; onDecided: (id: string) => void }) {
+ return (
+
+ {items.length}
+ Highlighted fields are the ones Lumen wasn't sure about
+ >
+ }
+ skeletonHeight={0}
+ data-testid="review"
+ >
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: string) => void }) {
+ const money = useMoney();
+ const inv = item.invoice;
+ const vendor = String(inv.vendor_name ?? "Unknown vendor");
+ const rules = item.flags.map((f) => f.rule);
+ const doubtful = new Set(rules.map((r) => DOUBT[r]).filter(Boolean));
+ const duplicate = rules.includes("duplicate");
+ const first = item.flags.find((f) => f.severity !== "note") ?? item.flags[0];
+ const [editing, setEditing] = useState(false);
+ const [edits, setEdits] = useState>({});
+ const [busy, setBusy] = useState(false);
+ const show = (k: Field["key"]) => {
+ const v = inv[k];
+ if (k === "total_amount" && typeof v === "number") return money(v, inv.currency as string | undefined);
+ // an ISO date reads as "12 Oct 2026"; anything else is shown as printed, since that's what is in doubt
+ if (k === "date" && typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v)) return new Date(`${v}T00:00`).toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric" });
+ return String(v ?? "—");
+ };
+
+ async function decide(decision: "approve" | "reject") {
+ setBusy(true);
+ const changed = Object.fromEntries(
+ Object.entries(edits)
+ .filter(([k, v]) => v.trim() !== "" && v.trim() !== String(inv[k] ?? ""))
+ .map(([k, v]) => [k, k === "total_amount" ? Number(v) : v.trim()]),
+ );
+ try {
+ await reviewApi.decide(item.id, decision, decision === "approve" && Object.keys(changed).length ? { edits: changed } : {});
+ onDecided(item.id);
+ eventBus.emit(EVENTS.REVIEW_DECIDED);
+ } catch (e) {
+ toast.error(errorText(e) ?? "Couldn't save that decision.");
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+
+ {vendor}
+
+ Receipt · {inv.source === "email" ? "from email" : "uploaded"} {dayMonth(item.created_at)}
+
+
+ {typeof inv.total_amount === "number" && {money(inv.total_amount, inv.currency as string | undefined)} }
+
+
+ {FIELDS.map((f) => (
+
+
{f.label}
+
+ {editing ? (
+ setEdits((x) => ({ ...x, [f.key]: e.target.value }))}
+ className="h-6 w-full rounded border border-border bg-card px-1.5"
+ />
+ ) : (
+ show(f.key)
+ )}
+
+
+ ))}
+
+ {typeof inv.file_key === "string" && (
+
void filesApi.open(inv.file_key as string).catch(() => toast.error("Couldn't open the original."))}
+ className="mt-2 self-start text-[12.5px] text-foreground/80 underline underline-offset-4 hover:text-foreground"
+ >
+ Open original
+
+ )}
+ {first && (
+
+ {REASON[first.rule] ?? "Lumen wasn't sure."} {plain(first.rule, first.detail)}
+
+ )}
+
+ {editing ? (
+ <>
+ void decide("approve")}
+ className="rounded-full bg-foreground px-3 py-1 text-[12.5px] text-background disabled:opacity-50"
+ >
+ Save & approve
+
+ (setEditing(false), setEdits({}))} className="rounded-full px-3 py-1 text-[12.5px] text-muted-foreground hover:text-foreground">
+ Cancel
+
+ >
+ ) : (
+ <>
+ void decide("approve")} className="rounded-full border border-good/40 bg-good/10 px-3 py-1 text-[12.5px] text-good disabled:opacity-50">
+ {duplicate ? "Keep both" : "Approve"}
+
+ {!duplicate && (
+ setEditing(true)} className="rounded-full border border-border bg-card px-3 py-1 text-[12.5px]">
+ Edit
+
+ )}
+ void decide("reject")} className="ml-auto rounded-full px-3 py-1 text-[12.5px] text-bad hover:bg-bad/10 disabled:opacity-50">
+ Reject
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/receipts/_components/side-cards.tsx b/frontend/src/app/(app)/receipts/_components/side-cards.tsx
new file mode 100644
index 0000000..35ac221
--- /dev/null
+++ b/frontend/src/app/(app)/receipts/_components/side-cards.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { PageCard } from "@/components/page/page-card";
+import { VendorChip } from "@/components/page/vendor-chip";
+import { GmailOAuthButton } from "@/components/gmailOAuthButton";
+import { emailConfigApi, type StatementImport } from "@/lib/api/client";
+import { toast } from "@/lib/toast";
+import { parseUtc } from "@/lib/home-math";
+import type { EmailStatus } from "../use-receipts-data";
+
+const dayMonth = (s: string | null) => (s ? parseUtc(s).toLocaleString("en-GB", { day: "numeric", month: "short" }) : "");
+const ago = (s: string) => {
+ const m = Math.round((Date.now() - parseUtc(s).getTime()) / 60_000);
+ return m < 1 ? "just now" : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
+};
+const PARTS = [
+ { key: "imported", label: "new", className: "bg-primary" },
+ { key: "matched", label: "matched", className: "bg-primary/45" },
+ { key: "duplicates", label: "duplicates", className: "bg-amber-500" },
+ { key: "credits", label: "credits skipped", className: "bg-muted-foreground/25" },
+] as const;
+
+// Statement imports (spec 2026-10-22 §4.5): one entry per import with what became of its lines. Counts are as the
+// import returned them; "matched" lines were already recorded from a receipt.
+export function ImportsCard({ imports }: { imports: StatementImport[] }) {
+ return (
+ Import statement}
+ skeletonHeight={0}
+ data-testid="imports"
+ >
+ {imports.length === 0 ? (
+ No statements yet. Import a CSV from your bank or card and its spending lines become transactions.
+ ) : (
+
+ {imports.slice(0, 4).map((i) => {
+ const total = PARTS.reduce((s, p) => s + i[p.key], 0) || 1;
+ const name = i.label ?? i.file_name ?? "Statement";
+ return (
+
+
+
+
+ {name}
+
+ {i.period_from ? `${dayMonth(i.period_from)} – ${dayMonth(i.period_to)} · ` : ""}imported {dayMonth(i.created_at)}
+
+
+ {i.lines} lines
+
+
+ {PARTS.map((p) => i[p.key] > 0 && )}
+
+
+ {PARTS.map((p) => i[p.key] > 0 && {i[p.key]} {p.key === "duplicates" && i[p.key] === 1 ? "duplicate" : p.label} )}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+// From your inbox: Gmail (or IMAP) receipt collection. Connected: what it's done and Check now / Pause. Not connected:
+// connect Gmail here (it had no home in the app since the old dashboard went).
+export function InboxCard({ email, onChanged }: { email: EmailStatus | null; onChanged: () => void }) {
+ const [busy, setBusy] = useState(false);
+ async function act(f: () => Promise, done?: string) {
+ setBusy(true);
+ try {
+ await f();
+ if (done) toast.success(done);
+ onChanged();
+ } catch {
+ toast.error("Couldn't reach your inbox right now.");
+ } finally {
+ setBusy(false);
+ }
+ }
+ const on = email?.configured;
+ return (
+ {email.polling_enabled ? "Connected" : "Paused"}}
+ skeletonHeight={0}
+ data-testid="inbox"
+ >
+ {on ? (
+ <>
+
+
+
+ {email.email_address}
+
+ {email.last_successful_poll ? `Checked ${ago(email.last_successful_poll)} · ` : ""}
+ {email.transactions_added ?? 0} receipts added
+
+
+
+ {email.last_error && {email.last_error}
}
+
+ void act(() => emailConfigApi.pollNow(), "Checking your inbox")} className="rounded-full border border-border bg-card px-3 py-1 text-[12.5px] disabled:opacity-50">
+ Check now
+
+ void act(() => (email.polling_enabled ? emailConfigApi.pausePolling() : emailConfigApi.resumePolling()))}
+ className="rounded-full px-3 py-1 text-[12.5px] text-muted-foreground hover:text-foreground disabled:opacity-50"
+ >
+ {email.polling_enabled ? "Pause" : "Resume"}
+
+
+ >
+ ) : (
+ <>
+ Connect Gmail and Lumen files receipts and invoices from your inbox. You still approve anything it isn't sure about.
+ onChanged()} />
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/app/(app)/import/importContent.tsx b/frontend/src/app/(app)/receipts/import/import-content.tsx
similarity index 84%
rename from frontend/src/app/(app)/import/importContent.tsx
rename to frontend/src/app/(app)/receipts/import/import-content.tsx
index 804140c..c51b70b 100644
--- a/frontend/src/app/(app)/import/importContent.tsx
+++ b/frontend/src/app/(app)/receipts/import/import-content.tsx
@@ -1,10 +1,11 @@
"use client";
-// Bare-bones CSV statement import (SPEC-STATEMENTS). The real screen comes with the UI refactor.
+// CSV statement import (SPEC-STATEMENTS), reached from Receipts & statements. Each import is kept in its history
+// (UI revamp Phase 5e) under the optional account name.
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
-import { DashboardShell } from "@/components/dashboard-shell";
+import { PageHeader } from "@/components/page/page-header";
import { useProfile } from "@/components/profile/profile-provider";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -35,6 +36,7 @@ export default function ImportContent() {
const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState(null);
const [currency, setCurrency] = useState(profile?.currency ?? "USD");
+ const [label, setLabel] = useState("");
const [busy, setBusy] = useState<"preview" | "import" | null>(null);
const [error, setError] = useState(null);
const [raw, setRaw] = useState(null);
@@ -72,7 +74,7 @@ export default function ImportContent() {
setError(null);
setRaw(null);
try {
- setResult(await statementApi.import(file, mapping, currency, preview.header_line));
+ setResult(await statementApi.import(file, mapping, currency, preview.header_line, label));
} catch (e) {
fail(e, "Couldn't import that statement.");
} finally {
@@ -83,10 +85,12 @@ export default function ImportContent() {
const money = (n: number | null) => (n == null ? "—" : n.toFixed(2));
return (
-
+
+
A CSV from your bank or card. Spending lines become transactions; credits are skipped for now.}
+ actions={ ‹ Receipts & statements}
+ />
@@ -131,6 +135,12 @@ export default function ImportContent() {
))}
+
+
+ Account name (optional)
+
+ setLabel(e.target.value)} className={selectClass} />
+
Currency
@@ -208,12 +218,12 @@ export default function ImportContent() {
Imported {result.imported} · {result.duplicates} duplicates · {result.matched} already recorded ·{" "}
{result.credits} credits skipped
{result.unreadable.length > 0 && ` · unreadable lines: ${result.unreadable.join(", ")}`}.{" "}
-
- See the dashboard
+
+ Back to Receipts & statements
)}
-
+
);
}
diff --git a/frontend/src/app/(app)/receipts/import/page.tsx b/frontend/src/app/(app)/receipts/import/page.tsx
new file mode 100644
index 0000000..a4c593e
--- /dev/null
+++ b/frontend/src/app/(app)/receipts/import/page.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from "next";
+import ImportContent from "./import-content";
+
+export const metadata: Metadata = { title: "Import a statement" };
+
+export default function Page() {
+ return ;
+}
diff --git a/frontend/src/app/(app)/receipts/page.tsx b/frontend/src/app/(app)/receipts/page.tsx
index 66fb472..ad60095 100644
--- a/frontend/src/app/(app)/receipts/page.tsx
+++ b/frontend/src/app/(app)/receipts/page.tsx
@@ -1,9 +1,8 @@
import type { Metadata } from "next";
-import { ReceiptsTabs } from "./receipts-tabs";
+import ReceiptsContent from "./receipts-content";
export const metadata: Metadata = { title: "Receipts & statements" };
-// Interim (UI revamp Phase 1): the old Documents, Import and Review pages as tabs until Phase 5 redesigns this page.
export default function Page() {
- return ;
+ return ;
}
diff --git a/frontend/src/app/(app)/receipts/receipts-content.tsx b/frontend/src/app/(app)/receipts/receipts-content.tsx
new file mode 100644
index 0000000..6c4a0ca
--- /dev/null
+++ b/frontend/src/app/(app)/receipts/receipts-content.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import Link from "next/link";
+import { FileSpreadsheet, Mail } from "lucide-react";
+import { PageHeader } from "@/components/page/page-header";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DialogDemo as UploadDialog } from "@/components/uploadDialog";
+import { documentsApi } from "@/lib/api/client";
+import { toast } from "@/lib/toast";
+import { Brief } from "../home/_components/brief";
+import { Library, toRows } from "./_components/library";
+import { ReviewCards } from "./_components/review-cards";
+import { ImportsCard, InboxCard } from "./_components/side-cards";
+import { useReceiptsData } from "./use-receipts-data";
+
+// Receipts & statements (UI revamp Phase 5e, spec 2026-10-22 §4.5): what have I given Lumen, and what needs my OK?
+// Replaces the Documents / Import / Review tabs. Everything loads together and appears in one step, so the review
+// row (only there when something is flagged) never pushes the rest down.
+export default function ReceiptsContent() {
+ const { data, reload, update } = useReceiptsData();
+
+ async function remove(id: string) {
+ try {
+ await documentsApi.remove(id);
+ update((d) => ({ documents: d.documents.filter((x) => x.id !== id) }));
+ } catch {
+ toast.error("Couldn't delete that document.");
+ }
+ }
+
+ const n = data?.review.length ?? 0;
+ const rows = data ? toRows(data, (id) => void remove(id)) : [];
+ // with the review queue unknown, say only what is known
+ const brief = !data ? null : data.reviewFailed ? `${rows.length} ${rows.length === 1 ? "file" : "files"} with Lumen.` : n ? `${n} ${n === 1 ? "receipt needs" : "receipts need"} your OK before ${n === 1 ? "it counts" : "they count"}.` : "Everything you've given Lumen is filed.";
+
+ return (
+
+
+
+
}
+ actions={
+ <>
+
+
+
+ Import statement
+
+
+ {data && !data.email?.configured && (
+
+
+ Connect email
+
+
+ )}
+ >
+ }
+ />
+ {!data ? (
+
+ ) : (
+ <>
+ {n > 0 && (
+
+ update((d) => ({ review: d.review.filter((r) => r.id !== id) }))} />
+
+ )}
+
+
+
+
+ void reload()} />
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/receipts/receipts-tabs.tsx b/frontend/src/app/(app)/receipts/receipts-tabs.tsx
deleted file mode 100644
index 783edcb..0000000
--- a/frontend/src/app/(app)/receipts/receipts-tabs.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { useSearchParams } from "next/navigation";
-import { cn } from "@/lib/utils";
-import DocumentsContent from "../documents/documentsContent";
-import ImportContent from "../import/importContent";
-import ReviewContent from "../review/reviewContent";
-
-const TABS = [
- { id: "documents", label: "Documents", Content: DocumentsContent },
- { id: "import", label: "Import", Content: ImportContent },
- { id: "review", label: "Review", Content: ReviewContent },
-] as const;
-
-// Reads ?tab= on the client so /receipts stays a static page (no server render per visit).
-export function ReceiptsTabs() {
- const tab = useSearchParams().get("tab");
- const active = TABS.find((t) => t.id === tab) ?? TABS[0];
- return (
- <>
-
- {TABS.map((t) => (
-
- {t.label}
-
- ))}
-
-
- >
- );
-}
diff --git a/frontend/src/app/(app)/receipts/use-receipts-data.ts b/frontend/src/app/(app)/receipts/use-receipts-data.ts
new file mode 100644
index 0000000..e9da088
--- /dev/null
+++ b/frontend/src/app/(app)/receipts/use-receipts-data.ts
@@ -0,0 +1,54 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { documentsApi, emailConfigApi, reviewApi, statementApi, transactionApi, type LumenDocument, type ReviewItem, type StatementImport } from "@/lib/api/client";
+import { eventBus, EVENTS } from "@/lib/events";
+import type { Txn } from "@/lib/home-math";
+
+export type EmailStatus = {
+ configured: boolean;
+ email_address?: string;
+ provider?: string;
+ polling_enabled?: boolean;
+ transactions_added?: number;
+ last_successful_poll?: string | null;
+ last_error?: string | null;
+};
+
+type Data = { review: ReviewItem[]; reviewFailed: boolean; documents: LumenDocument[]; receipts: Txn[]; imports: StatementImport[]; email: EmailStatus | null };
+const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fulfilled" ? r.value : fallback);
+
+// Everything the Receipts & statements page shows, loaded together so the page appears in one step (no layout shift
+// from the review row arriving late). A failed call empties only its own part. Uploads elsewhere refresh it.
+// ponytail: the library lists the latest 100 receipts (statement lines left out server-side); page through
+// /transactions if people need older ones here.
+export function useReceiptsData() {
+ const [data, setData] = useState(null);
+
+ const load = useCallback(async () => {
+ const [review, documents, txns, imports, email] = await Promise.allSettled([
+ reviewApi.list(),
+ documentsApi.list(),
+ transactionApi.getTransactions({ page: 1, page_size: 100, exclude_source: "statement" }),
+ statementApi.imports(),
+ emailConfigApi.getStatus() as Promise,
+ ]);
+ const rows = (value(txns, { data: [] }) as { data?: Txn[] }).data ?? [];
+ setData({
+ review: value(review, []) ?? [],
+ reviewFailed: review.status === "rejected",
+ documents: value(documents, []) ?? [],
+ receipts: rows,
+ imports: value(imports, []) ?? [],
+ email: value(email, null),
+ });
+ }, []);
+
+ useEffect(() => {
+ void load();
+ return eventBus.on(EVENTS.INVOICE_UPDATED, () => void load());
+ }, [load]);
+
+ const update = useCallback((change: (d: Data) => Partial) => setData((d) => (d ? { ...d, ...change(d) } : d)), []);
+ return { data, reload: load, update };
+}
diff --git a/frontend/src/app/(app)/review/reviewContent.tsx b/frontend/src/app/(app)/review/reviewContent.tsx
deleted file mode 100644
index 6806ff2..0000000
--- a/frontend/src/app/(app)/review/reviewContent.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-"use client";
-
-// Minimal page to exercise the review queue API. The real review screen comes with the UI refactor.
-import { useCallback, useEffect, useState } from "react";
-import { DashboardShell } from "@/components/dashboard-shell";
-import { Button } from "@/components/ui/button";
-import { Card } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { filesApi, reviewApi, type ReviewItem } from "@/lib/api/client";
-import { eventBus, EVENTS } from "@/lib/events";
-import { toast } from "@/lib/toast";
-
-function errorMessage(error: unknown, fallback: string): string {
- return (error as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback;
-}
-
-export default function ReviewContent() {
- const [items, setItems] = useState([]);
- const [totals, setTotals] = useState>({});
-
- const load = useCallback(async () => {
- try {
- setItems(await reviewApi.list());
- } catch (error) {
- toast.error(errorMessage(error, "Couldn't load the review queue."));
- }
- }, []);
-
- useEffect(() => {
- void load();
- }, [load]);
-
- async function decide(item: ReviewItem, decision: "approve" | "reject") {
- const total = totals[item.id];
- const edits = decision === "approve" && total ? { total_amount: Number(total) } : {};
- try {
- const done = await reviewApi.decide(item.id, decision, { edits });
- toast.success(decision === "approve" ? `Approved${done.flags.length ? " (with warnings)" : ""}` : "Rejected");
- setItems((list) => list.filter((i) => i.id !== item.id));
- eventBus.emit(EVENTS.REVIEW_DECIDED);
- } catch (error) {
- toast.error(errorMessage(error, "Couldn't save the decision."));
- }
- }
-
- return (
-
-
- {items.length === 0 ? (
- Nothing to review. Upload an invoice whose total doesn't match its line items, or the same invoice twice.
- ) : items.map((item) => (
-
-
- {String(item.invoice.vendor_name ?? "Unknown vendor")} · {String(item.invoice.invoice_number ?? "no number")} ·{" "}
- {String(item.invoice.date ?? "no date")} · total {String(item.invoice.total_amount ?? "?")} · confidence {item.confidence}
-
-
- {item.flags.map((f) => {f.severity === "fail" ? "Fail" : "Warning"}: {f.detail} )}
-
-
- setTotals((t) => ({ ...t, [item.id]: e.target.value }))} />
- {typeof item.invoice.file_key === "string" && (
- void filesApi.open(item.invoice.file_key as string).catch(() => toast.error("Couldn't open the original."))}>
- Open original
-
- )}
- void decide(item, "approve")}>Approve
- void decide(item, "reject")}>Reject
-
-
- ))}
-
-
- );
-}
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..591fc4b
--- /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?.toLowerCase() === r.category.toLowerCase() ? null : r.category)}
+ className={cn(
+ "-mx-2 grid grid-cols-[minmax(0,140px)_1fr_auto] items-center gap-3 rounded-lg px-2 py-2 text-left text-[13px] transition-colors sm:grid-cols-[minmax(0,160px)_1fr_90px_48px_64px]",
+ selected === r.category ? "bg-primary/8" : "hover:bg-muted/60",
+ )}
+ >
+
+
+ {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..cab82ec
--- /dev/null
+++ b/frontend/src/app/(app)/spending/_components/transactions.tsx
@@ -0,0 +1,133 @@
+"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 { inCategory, sourceOf, toCsv } from "@/lib/spending-math";
+import { toast } from "@/lib/toast";
+
+const PAGE = 25;
+const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" });
+
+// 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..1397c44
--- /dev/null
+++ b/frontend/src/app/(app)/spending/spending-content.tsx
@@ -0,0 +1,175 @@
+"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) {
+ // name a payment only if it explains the rise: inside it ("one ₹9,999 payment" can't be most of a ₹2,000
+ // rise) and at least half of it (a ₹1 payment explains nothing)
+ const biggest = data.txns
+ .filter((x) => (x.category || "Uncategorised") === top.category && (x.total_amount ?? 0) <= top.change && (x.total_amount ?? 0) >= top.change / 2)
+ .sort((a, b) => (b.total_amount ?? 0) - (a.total_amount ?? 0))[0];
+ return `${top.category} is most of the jump${biggest?.vendor_name ? `, and one ${biggest.vendor_name} payment is ${money(biggest.total_amount ?? 0)} of it` : ""}.`;
+ }
+ return `You've spent ${money(Math.round(total))} in ${p.label}${preset === "this-month" ? " so far" : ""}.`;
+ })();
+
+ 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/app/beautiful-ui.css b/frontend/src/app/beautiful-ui.css
index a4b0385..9a4c71f 100644
--- a/frontend/src/app/beautiful-ui.css
+++ b/frontend/src/app/beautiful-ui.css
@@ -149,4 +149,4 @@
}
/* uppercase mono micro-label (eyebrows, badges, data annotations) */
-.bui-label { font-family: var(--font-label); font-size: 11px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; }
+.bui-label { font-family: var(--font-label); font-size: 12.5px; font-weight: 500; letter-spacing: 0.01em; }
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index a95d3f5..b0cbd07 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -122,7 +122,7 @@
--color-bad: var(--bad);
--color-accent-soft: var(--accent-soft);
--font-serif: var(--font-newsreader), Georgia, serif;
- --font-label: var(--font-plex-mono), ui-monospace, monospace;
+ --font-label: var(--font-ui), system-ui, sans-serif;
--font-sans: "Inter", "Inter Fallback", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, monospace;
--font-script: "Dancing Script", cursive;
@@ -232,11 +232,11 @@
animation: slide-out-to-right-full 0.2s ease-in;
}
- .label-mono {
- font-family: var(--font-plex-mono), ui-monospace, monospace;
- font-size: 10.5px;
- letter-spacing: 0.12em;
- text-transform: uppercase;
+ /* card titles, tags and axis ticks: the UI font, sentence case, quiet (owner: no all-caps mono labels) */
+ .label-ui {
+ font-family: var(--font-ui), system-ui, sans-serif;
+ font-size: 12.5px;
+ letter-spacing: 0.01em;
font-weight: 500;
}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 7e92c84..8630b62 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next";
-import { IBM_Plex_Mono, Inter, JetBrains_Mono, Newsreader } from "next/font/google";
+import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";
import { AuthProvider } from "@/components/auth/auth-provider";
@@ -26,13 +26,6 @@ const newsreader = Newsreader({
variable: "--font-newsreader",
});
-// In-app card titles, tags and axis ticks (the `.label-mono` / `font-label` style).
-const plexMono = IBM_Plex_Mono({
- subsets: ["latin"],
- weight: ["400", "500"],
- variable: "--font-plex-mono",
-});
-
// Funnel Sans (OFL, self-hosted from @fontsource-variable/funnel-sans): text, buttons and labels on the landing,
// sign-in and onboarding pages, and the label style everywhere (it replaced Hanken Grotesk and Roboto Mono).
const funnelSans = localFont({
@@ -141,7 +134,7 @@ export default function RootLayout({
{/* every signed-in page talks to these two right away */}
diff --git a/frontend/src/components/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/paymentCalendarCard.tsx b/frontend/src/components/ai-analytics/paymentCalendarCard.tsx
deleted file mode 100644
index f562382..0000000
--- a/frontend/src/components/ai-analytics/paymentCalendarCard.tsx
+++ /dev/null
@@ -1,208 +0,0 @@
-"use client";
-
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import {
- Calendar as CalendarIcon,
- ChevronLeft,
- ChevronRight,
-} from "lucide-react";
-import { motion } from "motion/react";
-import { useState, useEffect } from "react";
-import { analyticsApi } from "@/lib/api/client";
-
-const cardVariants = {
- hidden: { opacity: 0, y: 20 },
- visible: {
- opacity: 1,
- y: 0,
- transition: {
- duration: 0.5,
- ease: [0.4, 0, 0.2, 1] as any,
- delay: 0.1,
- },
- },
-};
-
-export default function PaymentCalendarCard() {
- const [currentDate, setCurrentDate] = useState(new Date());
- const [paymentDates, setPaymentDates] = useState<{
- upcoming: number[];
- forecast: number[];
- }>({
- upcoming: [],
- forecast: [],
- });
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- const fetchPaymentDates = async () => {
- try {
- setError(null);
- const response = await analyticsApi.getAllTimeSummary();
- if (response.success && response.payment_calendar) {
- setPaymentDates(response.payment_calendar);
- } else {
- setPaymentDates({ upcoming: [], forecast: [] });
- setError("Could not load payment calendar.");
- }
- } catch (err) {
- console.error("Failed to load payment calendar", err);
- setPaymentDates({ upcoming: [], forecast: [] });
- setError("Could not load payment calendar.");
- } finally {
- setLoading(false);
- }
- };
- fetchPaymentDates();
- }, [currentDate]);
-
- const getDaysInMonth = (date: Date) => {
- const year = date.getFullYear();
- const month = date.getMonth();
- const firstDay = new Date(year, month, 1).getDay();
- const daysInMonth = new Date(year, month + 1, 0).getDate();
- return { firstDay, daysInMonth };
- };
-
- const { firstDay, daysInMonth } = getDaysInMonth(currentDate);
- const monthName = currentDate.toLocaleDateString("en-US", {
- month: "long",
- year: "numeric",
- });
-
- const previousMonth = () => {
- setCurrentDate(
- new Date(currentDate.getFullYear(), currentDate.getMonth() - 1)
- );
- };
-
- const nextMonth = () => {
- setCurrentDate(
- new Date(currentDate.getFullYear(), currentDate.getMonth() + 1)
- );
- };
-
- const isUpcoming = (day: number) => paymentDates.upcoming.includes(day);
- const isForecast = (day: number) => paymentDates.forecast.includes(day);
-
- if (loading) {
- return (
-
-
- Loading calendar...
-
-
- );
- }
-
- if (error) {
- return (
-
-
- {error}
-
-
- );
- }
-
- return (
-
-
-
-
-
-
- Payment Calendar
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Month/Year */}
-
- {monthName}
-
-
- {/* Calendar Grid */}
-
- {/* Day Headers */}
- {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(
- (day) => (
-
- {day}
-
- )
- )}
-
- {/* Empty cells for days before month starts */}
- {Array.from({ length: firstDay }).map(
- (_, index) => (
-
- )
- )}
-
- {/* Days of the month */}
- {Array.from({ length: daysInMonth }).map(
- (_, index) => {
- const day = index + 1;
- const isUpcomingDay = isUpcoming(day);
- const isForecastDay = isForecast(day);
-
- return (
-
- {day}
-
- );
- }
- )}
-
-
- {/* Legend */}
-
-
-
-
-
- );
-}
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/ai-analytics/reminderDetailsCard.tsx b/frontend/src/components/ai-analytics/reminderDetailsCard.tsx
deleted file mode 100644
index 4d14355..0000000
--- a/frontend/src/components/ai-analytics/reminderDetailsCard.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-"use client";
-
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Bell, TrendingUp, Calendar } from "lucide-react";
-import { motion } from "motion/react";
-import { Badge } from "@/components/ui/badge";
-import { useState, useEffect } from "react";
-import { useMoney } from "@/lib/format";
-import { analyticsApi } from "@/lib/api/client";
-
-const cardVariants = {
- hidden: { opacity: 0, y: 20 },
- visible: {
- opacity: 1,
- y: 0,
- transition: {
- duration: 0.5,
- ease: "easeOut" as const,
- delay: 0.2,
- },
- },
-};
-
-export default function ReminderDetailsCard() {
- const money = useMoney();
- const [reminders, setReminders] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- const fetchReminders = async () => {
- try {
- setError(null);
- const response = await analyticsApi.getAllTimeSummary();
- if (response.success && response.upcoming_payments) {
- setReminders(response.upcoming_payments);
- } else {
- setReminders([]);
- setError("Could not load reminders.");
- }
- } catch (err) {
- console.error("Failed to load reminders", err);
- setReminders([]);
- setError("Could not load reminders.");
- } finally {
- setLoading(false);
- }
- };
- fetchReminders();
- }, []);
-
- if (loading) {
- return (
-
-
- Loading reminders...
-
-
- );
- }
-
- return (
-
-
-
-
-
- Reminders & Forecasts
-
-
-
- {error ? (
-
- {error}
-
- ) : reminders.length === 0 ? (
-
- No reminders or forecasts available
-
- ) : (
-
- {reminders.map((reminder, index) => (
-
-
-
-
- {reminder.vendor}
-
-
-
- {reminder.type ===
- "upcoming" ? (
-
- ) : (
-
- )}
- {reminder.type ===
- "upcoming"
- ? "Upcoming"
- : "Forecast"}
-
-
- {reminder.category}
-
-
-
-
-
- {money(reminder.amount ?? 0, null, { decimals: 2 })}
-
-
- {reminder.date}
-
-
-
-
- {reminder.description ||
- reminder.reason}
-
-
- ))}
-
- )}
-
-
-
-
-
- );
-}
diff --git a/frontend/src/components/ai-analytics/spendingTrendGraph.tsx b/frontend/src/components/ai-analytics/spendingTrendGraph.tsx
deleted file mode 100644
index fa5ed04..0000000
--- a/frontend/src/components/ai-analytics/spendingTrendGraph.tsx
+++ /dev/null
@@ -1,254 +0,0 @@
-"use client";
-
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { TrendingUp, Activity } from "lucide-react";
-import { motion } from "motion/react";
-import { useState, useEffect } from "react";
-import { useMoney } from "@/lib/format";
-import { useProfile } from "@/components/profile/profile-provider";
-import { analyticsApi } from "@/lib/api/client";
-import {
- LineChart,
- Line,
- XAxis,
- YAxis,
- CartesianGrid,
- Tooltip,
- ResponsiveContainer,
- Legend,
-} from "recharts";
-
-const cardVariants = {
- hidden: { opacity: 0, y: 20 },
- visible: {
- opacity: 1,
- y: 0,
- transition: {
- duration: 0.5,
- ease: [0.4, 0, 0.2, 1] as any,
- delay: 0.5,
- },
- },
-};
-
-const CustomTooltip = ({ active, payload, label }: any) => {
- const money = useMoney();
- if (active && payload && payload.length) {
- return (
-
-
{label}
- {payload.map((entry: any, index: number) => (
-
- {entry.name}: {money(entry.value ?? 0)}
-
- ))}
-
- );
- }
- return null;
-};
-
-export default function SpendingTrendGraph() {
- const money = useMoney();
- const { profile } = useProfile();
- // Axis ticks: short amounts in the profile currency, e.g. "₹8K" / "CHF 8K".
- const compact = (v: number) =>
- new Intl.NumberFormat(undefined, { style: "currency", currency: profile?.currency || "INR", notation: "compact", maximumFractionDigits: 1 }).format(v);
- const [spendingData, setSpendingData] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- const fetchSpendingTrends = async () => {
- try {
- const response = await analyticsApi.getAllTimeSummary();
- if (response.success && response.monthly_trends) {
- setSpendingData(response.monthly_trends);
- } else {
- setSpendingData([]);
- }
- } catch (err) {
- setError("Failed to load spending trends");
- setSpendingData([]);
- } finally {
- setLoading(false);
- }
- };
- fetchSpendingTrends();
- }, []);
-
- if (loading) {
- return (
-
-
- Loading spending trends...
-
-
- );
- }
-
- if (error) {
- return (
-
-
- {error}
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- compact(value)}
- />
- } />
-
-
-
-
-
-
-
-
- AI Insights
-
-
-
-
-
-
- November Spending Anomaly Detected
-
-
- Your November spending of {money(8316)} is 454%
- above your monthly average of {money(1500)}.
- This represents an unusual spike with 37
- transactions (48% more than usual).
-
-
-
-
-
-
-
-
-
- Seasonal Pattern Recognition
-
-
- Historical data shows spending increases
- in Q3 (Aug-Oct). Your 2024 Q3 averaged
- {money(1973)}/month. Plan for similar patterns
- in 2026.
-
-
-
-
-
-
-
-
-
- Spending Discipline Observed
-
-
- April-July 2025 maintained excellent
- spending control, averaging just
- {money(645)}/month. This shows strong budget
- adherence during low-activity periods.
-
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/components/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/app-shell.tsx b/frontend/src/components/app-shell.tsx
index f6d215d..6c6a085 100644
--- a/frontend/src/components/app-shell.tsx
+++ b/frontend/src/components/app-shell.tsx
@@ -1,16 +1,32 @@
"use client";
-import { Suspense, type ReactNode } from "react";
+import { Suspense, useEffect, useState, type ReactNode } from "react";
import { MotionConfig } from "motion/react";
import { AppSidebar } from "@/components/app-sidebar";
+import { AskPalette } from "@/components/ask-palette";
import { AuthGuard } from "@/components/auth/auth-guard";
import { ProfileProvider } from "@/components/profile/profile-provider";
import { AvatarMenu } from "@/components/avatar-menu";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
+import { isTypingTarget } from "@/lib/utils";
// The signed-in app's frame: auth check, sidebar and top bar are built once and stay mounted while you move between
// pages, so only the content area swaps, like tabs on X. Rendered by app/(app)/layout.tsx.
export function AppShell({ children }: { children: ReactNode }) {
+ // the Ask palette lives here, not in the sidebar: below md the sidebar is a Sheet that isn't mounted while closed
+ const [paletteOpen, setPaletteOpen] = useState(false);
+ useEffect(() => {
+ // e.code, not e.key: Caps Lock or Shift turns the key into "K"
+ const onKey = (e: KeyboardEvent) => {
+ if (e.code === "KeyK" && (e.metaKey || e.ctrlKey) && !isTypingTarget(e.target)) {
+ e.preventDefault();
+ setPaletteOpen(true);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, []);
+
return (
// Sky (globals.css) wraps everything, auth loader included, so the first server paint is already light.
@@ -21,16 +37,20 @@ export function AppShell({ children }: { children: ReactNode }) {
{/* Only ever rendered client-side (the auth guard shows a loader on the server), so reading the remembered
state straight from the cookie gives the right width on the sidebar's first paint: no jump, no SSR read. */}
-
-
-
-
-
-
+
setPaletteOpen(true)} />
+
+
+ {/* no top bar on desktop: just the account menu, floating top-right over the page (owner, 2026-10-22).
+ Phones keep a slim row, since the sidebar there is a sheet that needs a button to open. */}
+
+
{children}
diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx
index c73ff27..0dc6e33 100644
--- a/frontend/src/components/app-sidebar.tsx
+++ b/frontend/src/components/app-sidebar.tsx
@@ -4,7 +4,7 @@ import * as React from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { CalendarClock, ChartLine, House, Receipt, Sparkles, type LucideIcon } from "lucide-react";
+import { CalendarClock, ChartLine, House, PieChart, Receipt, Sparkles, type LucideIcon } from "lucide-react";
import {
Sidebar,
@@ -17,23 +17,22 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
- SidebarSeparator,
+ SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
-import { AskPalette } from "@/components/ask-palette";
import { reviewApi } from "@/lib/api/client";
import { eventBus, EVENTS } from "@/lib/events";
-import { isTypingTarget } from "@/lib/utils";
type Item = { label: string; url: string; icon: LucideIcon };
-// Spec section 3. Group labels say what you do, not what the backend is. Budget and Forecast (PLAN) join once
-// their pages exist.
+// Spec section 3. Every group is labelled (owner, 2026-10-22); labels say what you do, not what the backend is.
+// Forecast (Plan) joins once its page exists.
const HOME: Item = { label: "Home", url: "/home", icon: House };
const TRACK: Item[] = [
{ label: "Spending", url: "/spending", icon: ChartLine },
{ label: "Bills & subscriptions", url: "/bills", icon: CalendarClock },
];
+const BUDGET: Item = { label: "Budget", url: "/budget", icon: PieChart };
const RECEIPTS: Item = { label: "Receipts & statements", url: "/receipts", icon: Receipt };
const ASK: Item = { label: "Ask Lumen", url: "/ask", icon: Sparkles };
@@ -55,11 +54,12 @@ function NavItem({ item, children }: { item: Item; children?: React.ReactNode })
);
}
-export function AppSidebar(props: React.ComponentProps) {
+// `onAsk` opens the Ask palette, which app-shell.tsx owns: below md this sidebar is a Sheet that isn't mounted while
+// closed, so a palette (and its Ctrl+K) living in here would do nothing there.
+export function AppSidebar({ onAsk, ...props }: React.ComponentProps & { onAsk: () => void }) {
const pathname = usePathname();
const [toReview, setToReview] = React.useState(0);
const [mac, setMac] = React.useState(false);
- const [paletteOpen, setPaletteOpen] = React.useState(false);
// refreshed on navigation and after each review decision (which happens without leaving /receipts)
React.useEffect(() => {
@@ -68,49 +68,62 @@ export function AppSidebar(props: React.ComponentProps) {
return eventBus.on(EVENTS.REVIEW_DECIDED, refresh);
}, [pathname]);
- React.useEffect(() => {
- setMac(/Mac|iPhone|iPad/.test(navigator.platform));
- // e.code, not e.key: Caps Lock or Shift turns the key into "K"
- const onKey = (e: KeyboardEvent) => {
- if (e.code === "KeyK" && (e.metaKey || e.ctrlKey) && !isTypingTarget(e.target)) {
- e.preventDefault();
- setPaletteOpen(true);
- }
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, []);
+ React.useEffect(() => setMac(/Mac|iPhone|iPad/.test(navigator.platform)), []);
return (
-
-
-
-
- Lumen
-
-
+ {/* the collapse control lives here, not in a top bar (owner, 2026-10-22). On the icon rail the logo turns into
+ the expand button on hover, so the rail stays 56px wide. */}
+
+
+
+
+
+ Lumen
+
+
+
+
+ Overview
- Track
+ Track
{TRACK.map((item) => (
))}
-
+ Plan
+
+
+
+
+
+ Records
{toReview > 0 && {toReview} }
+
+
+
+ Assistant
+
@@ -119,21 +132,20 @@ export function AppSidebar(props: React.ComponentProps) {
setPaletteOpen(true)}
+ onClick={onAsk}
className="border border-sidebar-border bg-card text-muted-foreground hover:text-foreground"
>
Ask anything
{/* fixed width: the label swaps to ⌘K on a Mac after mount without moving anything */}
-
+
{mac ? "⌘K" : "Ctrl K"}
-
);
diff --git a/frontend/src/components/ask-palette.tsx b/frontend/src/components/ask-palette.tsx
index 42f529a..358e2c0 100644
--- a/frontend/src/components/ask-palette.tsx
+++ b/frontend/src/components/ask-palette.tsx
@@ -42,7 +42,7 @@ export function AskPalette({ open, onOpenChange }: { open: boolean; onOpenChange
className="h-11 w-full bg-transparent text-[16px] outline-none placeholder:text-muted-foreground"
/>
-
+
Enter to ask · Esc to close
diff --git a/frontend/src/components/ask/answer.tsx b/frontend/src/components/ask/answer.tsx
index 6229d52..07513d8 100644
--- a/frontend/src/components/ask/answer.tsx
+++ b/frontend/src/components/ask/answer.tsx
@@ -3,17 +3,25 @@
// An Ask Lumen answer: markdown with inline citation chips, an action row and the cited passages.
// Visuals adapted from Beautiful UI's Streaming Text and Context Cards (beautifului.dev, MIT).
import { useState, type ReactNode } from "react";
+import Link from "next/link";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
-import { ArrowUpRight, Check, Copy, FileText, Globe, Info, RotateCcw } from "lucide-react";
+import { ArrowUpRight, Check, Copy, FileText, Globe, Info, RotateCcw, Upload } from "lucide-react";
import { filesApi, type ChatAttachment, type ChatSource } from "@/lib/api/client";
import { InvoiceCard } from "./invoice-card";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
+const CHIP =
+ "self-start rounded-full border border-line px-3.5 py-1.5 text-left text-[13px] text-ink-2 transition-colors duration-200 hover:border-line-strong hover:bg-hover hover:text-ink";
+
// Same shape the agent cites with (backend agent/graph.py _CITATION).
const CITATION = /[[【]\s*([^[\]【】\s]+#s\d{2}(?:-\d+)?|web:\d+)\s*[\]】]/g;
const CITE_PREFIX = "#cite-";
+// A suggested next step that is an upload is something you do, not something to ask: the chip opens the upload dialog
+// instead of sending the sentence to Lumen (owner report, 2026-10-22). Other do-it-yourself steps never become chips
+// (backend agent/graph.py _USER_STEP).
+const UPLOAD_STEP = /^(upload|import|add|attach|scan)\b.*\b(receipt|invoice|statement|document|bill|file)s?\b/i;
function openOriginal(key: string) {
filesApi.open(key).catch(() => toast.error("Couldn't open the original document."));
@@ -166,15 +174,16 @@ export function Answer({
{children}
- {followUp && onFollowUp && (
- onFollowUp(followUp)}
- className="self-start rounded-full border border-line px-3.5 py-1.5 text-left text-[13px] text-ink-2 transition-colors duration-200 hover:border-line-strong hover:bg-hover hover:text-ink"
- >
+ {followUp && onFollowUp && (UPLOAD_STEP.test(followUp) ? (
+
+
+ {followUp.replace(/[.!]$/, "")}
+
+ ) : (
+ onFollowUp(followUp)} className={CHIP}>
{followUp}
- )}
+ ))}
void;
busy: boolean;
/** the large, centred version shown before the first question */
hero?: boolean;
+ /** text to start with (Ask's ?draft=); remount with a new key to change it */
+ initial?: string;
}) {
- const [draft, setDraft] = useState("");
+ const [draft, setDraft] = useState(initial);
const [file, setFile] = useState(null);
const inputRef = useRef(null);
const fileRef = useRef(null);
diff --git a/frontend/src/components/exportDialog.tsx b/frontend/src/components/exportDialog.tsx
index 40af6cc..f6349bc 100644
--- a/frontend/src/components/exportDialog.tsx
+++ b/frontend/src/components/exportDialog.tsx
@@ -15,17 +15,8 @@ import { FileDown, Filter } from "lucide-react";
import { invoiceApi } from "@/lib/api/client";
import { toast } from "@/lib/toast";
-interface ExportDialogProps {
- variant?: "default" | "secondary";
- size?: "default" | "sm" | "lg" | "icon";
- className?: string;
-}
-
-export function ExportDialog({
- variant = "default",
- size = "sm",
- className = "",
-}: ExportDialogProps) {
+// Opened from a quiet round icon button (Phase 5 pages: no loud blue actions).
+export function ExportDialog() {
const [open, setOpen] = useState(false);
const [format, setFormat] = useState<"csv" | "json">("csv");
const [exporting, setExporting] = useState(false);
@@ -91,14 +82,13 @@ export function ExportDialog({
return (
-
-
- Export
+
+
-
+
-
+
Export Invoices
@@ -107,13 +97,13 @@ export function ExportDialog({
{/* Format Selection */}
-
+
Export Format
setFormat("csv")}
className="flex-1"
>
@@ -121,7 +111,7 @@ export function ExportDialog({
setFormat("json")}
className="flex-1"
>
@@ -131,10 +121,10 @@ export function ExportDialog({
{/* Filters Section */}
-
+
-
-
+
+
Filters (Optional)
@@ -142,11 +132,11 @@ export function ExportDialog({
{/* Action Buttons */}
-
+
@@ -230,6 +220,7 @@ export function ExportDialog({
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/app/(app)/home/_components/card.tsx b/frontend/src/components/page/page-card.tsx
similarity index 52%
rename from frontend/src/app/(app)/home/_components/card.tsx
rename to frontend/src/components/page/page-card.tsx
index c8bd384..6dbe25a 100644
--- a/frontend/src/app/(app)/home/_components/card.tsx
+++ b/frontend/src/components/page/page-card.tsx
@@ -3,36 +3,47 @@ import type { ReactNode } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
-// Shared Home card: mono title row, and a skeleton at the card's final height while loading (no shift on arrival).
-export function HomeCard({
+// The card every Phase 5 page is built from (spec 2026-10-22 §3): a quiet title row (title, › when it links,
+// `extra` such as a legend or pill, `action` on the right) and a skeleton at the card's final height while loading,
+// so nothing shifts when the data arrives.
+export function PageCard({
title,
href,
+ extra,
+ action,
loading,
skeletonHeight,
className,
- action,
children,
...rest
}: {
title: ReactNode;
href?: string;
+ extra?: ReactNode;
+ action?: ReactNode;
loading?: boolean;
skeletonHeight: number;
className?: string;
- action?: ReactNode;
children: ReactNode;
"data-testid"?: string;
}) {
return (
-
-
+
+
{href ? (
- {title} ›
+ {title}
›
) : (
{title}
)}
+ {extra}
{action &&
{action}
}
{loading ? : {children}
}
diff --git a/frontend/src/components/page/page-header.tsx b/frontend/src/components/page/page-header.tsx
new file mode 100644
index 0000000..b98fe2b
--- /dev/null
+++ b/frontend/src/components/page/page-header.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from "react";
+
+// Every Phase 5 page opens the same way (spec 2026-10-22 §3): title and quiet actions on one row, the serif brief
+// under it, then an optional control row. md:pr-12 leaves room for the floating account menu (app-shell.tsx).
+export function PageHeader({
+ title,
+ brief,
+ actions,
+ children,
+}: {
+ title: ReactNode;
+ brief?: ReactNode;
+ actions?: ReactNode;
+ children?: ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {actions &&
{actions}
}
+
+ {brief && {brief}
}
+ {children && {children}
}
+
+ );
+}
diff --git a/frontend/src/components/page/vendor-chip.tsx b/frontend/src/components/page/vendor-chip.tsx
new file mode 100644
index 0000000..b8eeedd
--- /dev/null
+++ b/frontend/src/components/page/vendor-chip.tsx
@@ -0,0 +1,16 @@
+import { vendorChip } from "@/lib/home-math";
+import { cn } from "@/lib/utils";
+
+// A vendor's initials on a soft tint of its own stable hue (lib/home-math vendorChip).
+export function VendorChip({ name, className }: { name: string; className?: string }) {
+ const { initials, hue } = vendorChip(name);
+ return (
+
+ {initials}
+
+ );
+}
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
index 24e29f1..0968e6d 100644
--- a/frontend/src/components/ui/button.tsx
+++ b/frontend/src/components/ui/button.tsx
@@ -18,6 +18,9 @@ const buttonVariants = cva(
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
+ // Phase 5 pages: light hairline actions, and one dark primary per page (no loud blue)
+ quiet: "border border-border bg-card/70 text-foreground/80 backdrop-blur hover:bg-card hover:text-foreground",
+ ink: "bg-foreground text-background hover:bg-foreground/90",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
@@ -26,6 +29,7 @@ const buttonVariants = cva(
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
+ "pill-icon": "size-[34px] rounded-full",
},
},
defaultVariants: {
diff --git a/frontend/src/components/uploadDialog.tsx b/frontend/src/components/uploadDialog.tsx
index 1bed992..4985d9c 100644
--- a/frontend/src/components/uploadDialog.tsx
+++ b/frontend/src/components/uploadDialog.tsx
@@ -2,10 +2,12 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
-import { X, Upload, CheckCircle, AlertCircle } from "lucide-react";
+import { Upload, CheckCircle, AlertCircle } from "lucide-react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DialogTitle } from "@radix-ui/react-dialog";
-import { ocrApi } from "@/lib/api/client";
+import Link from "next/link";
+import { documentsApi, ocrApi } from "@/lib/api/client";
+import { cn } from "@/lib/utils";
import { eventBus, EVENTS } from "@/lib/events";
import { logger } from "@/lib/logger";
@@ -17,14 +19,40 @@ interface FileWithStatus {
extractedData?: any;
}
-export function DialogDemo({ defaultOpen = false }: { defaultOpen?: boolean }) {
+const DOC_TYPES = [
+ { value: "contract", label: "Contract" },
+ { value: "policy", label: "Policy" },
+ { value: "purchase_order", label: "Purchase order" },
+ { value: "invoice", label: "Invoice" },
+ { value: "other", label: "Other" },
+];
+
+// Pass `open`/`onOpenChange` to open it from elsewhere too (Home's hero +); otherwise it manages itself.
+// Receipts & invoices are read into transactions (/extract); Documents (PDF contracts, policies…) are indexed for
+// Ask Lumen's document search (/api/documents). Statements have their own import.
+export function DialogDemo({
+ defaultOpen = false,
+ open: openProp,
+ onOpenChange,
+}: {
+ defaultOpen?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
const [files, setFiles] = useState([]);
const [uploading, setUploading] = useState(false);
- const [open, setOpen] = useState(defaultOpen);
+ const [kind, setKind] = useState<"receipts" | "documents">("receipts");
+ const [docType, setDocType] = useState("contract");
+ const [ownOpen, setOwnOpen] = useState(defaultOpen);
+ const open = openProp ?? ownOpen;
+ const setOpen = (next: boolean) => {
+ setOwnOpen(next);
+ onOpenChange?.(next);
+ };
useEffect(() => {
if (defaultOpen) {
- setOpen(true);
+ setOwnOpen(true);
}
}, [defaultOpen]);
@@ -73,7 +101,10 @@ export function DialogDemo({ defaultOpen = false }: { defaultOpen?: boolean }) {
try {
logger.debug("📤 Uploading file:", fileItem.file.name);
// Use OCR API to extract data - this saves to database automatically
- const result = await ocrApi.extractInvoice(fileItem.file);
+ const result =
+ kind === "documents"
+ ? { success: true, data: await documentsApi.upload(fileItem.file, docType), duplicate: false }
+ : await ocrApi.extractInvoice(fileItem.file);
logger.debug("📥 OCR API Response:", result);
@@ -154,9 +185,9 @@ export function DialogDemo({ defaultOpen = false }: { defaultOpen?: boolean }) {
);
case "success":
- return ;
+ return ;
case "error":
- return ;
+ return ;
default:
return null;
}
@@ -165,44 +196,77 @@ export function DialogDemo({ defaultOpen = false }: { defaultOpen?: boolean }) {
return (
- Upload Documents
+
+
+ Upload
+
-
- Upload documents
+
+ Upload
-
-
+
+
-
- Upload documents
+
+ Upload
-
- Drag & drop files here or click to browse.
- Supported: PDF, PNG, JPG
+
+ {kind === "receipts"
+ ? "Lumen reads each one into a transaction. PDF, PNG or JPG."
+ : "Contracts, policies and other PDFs, searchable from Ask Lumen."}
-
setOpen(false)}
- className="p-2 rounded-lg hover:bg-neutral-800/50 transition-colors"
- >
-
-
+
+
+ {([["receipts", "Receipts & invoices"], ["documents", "Documents"]] as const).map(([value, label]) => (
+ setKind(value)}
+ className={cn("label-ui rounded-[7px] px-3 py-1", kind === value ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
+ >
+ {label}
+
+ ))}
+
+ {kind === "documents" && (
+
+ Type
+ setDocType(e.target.value)} className="h-8 rounded-md border border-border bg-card px-2 text-foreground">
+ {DOC_TYPES.map((t) => (
+ {t.label}
+ ))}
+
+
+ )}
+
+
+ Statements are CSV files:{" "}
+ setOpen(false)} className="text-foreground underline underline-offset-4">
+ import a statement
+ {" "}
+ instead.
+
+
e.preventDefault()}
onDrop={handleDrop}
- className="mt-6 rounded-xl border-2 border-dashed border-neutral-700 p-6 flex flex-col items-center justify-center text-center bg-transparent"
+ className="mt-4 rounded-xl border-2 border-dashed border-border p-6 flex flex-col items-center justify-center text-center bg-transparent"
>
@@ -210,18 +274,18 @@ export function DialogDemo({ defaultOpen = false }: { defaultOpen?: boolean }) {
htmlFor="file-input-dashboard"
className="cursor-pointer w-full"
>
-
+
Drop files here
-
+
or click to select files
-
+
Browse
-
+
Files
{files.length === 0 && (
-
+
No files selected
)}
{files.map((fileItem, i) => (
{getStatusIcon(fileItem.status)}
-
-
+
{(fileItem.file.size / 1024).toFixed(1)}{" "}
KB
{fileItem.status === "error" &&
fileItem.error && (
{fileItem.error}
)}
{fileItem.note && (
-
+
{fileItem.note}
)}
diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts
index ca3afe8..dbd3b92 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;
@@ -505,8 +482,11 @@ export const transactionApi = {
max_amount?: number;
sort_by?: string;
sort_order?: "asc" | "desc";
+ /** e.g. "statement": leave out imported bank lines */
+ exclude_source?: string;
}) => {
const queryParams = new URLSearchParams();
+ if (params?.exclude_source) queryParams.append("exclude_source", params.exclude_source);
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.page_size)
queryParams.append("page_size", params.page_size.toString());
@@ -581,84 +561,14 @@ export const transactionApi = {
},
};
+// Analytics the redesigned pages still read: the forecast line on 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 },
- });
- 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 => {
@@ -759,13 +669,49 @@ export interface BudgetState {
categories: { category: string; limit: number; spent: number }[];
}
+/** One recurring charge (backend ai/recurring.find_recurring) with the user's saved choice (api/bills.py). */
+export interface RecurringItem {
+ vendor: string;
+ category: string | null;
+ cadence: "weekly" | "monthly" | "yearly";
+ typical_amount: number;
+ last_charge_date: string;
+ next_due_date: string;
+ charges_count: number;
+ price_change: { previous: number; latest: number; percent: number } | null;
+ kind: "subscription" | "bill" | "recurring";
+ possibly_stopped: boolean;
+ state: "active" | "cancelled" | "charged_again";
+}
+export interface RecurringState {
+ items: RecurringItem[];
+ /** vendors the user marked "not a bill" */
+ hidden: { vendor: string; vendor_key: string }[];
+}
+
+export const billsApi = {
+ recurring: (): Promise => cached("/api/bills/recurring", async () => (await apiClient.get("/api/bills/recurring")).data),
+ setState: async (vendor: string, state: "cancelled" | "not_a_bill" | "active"): Promise =>
+ (await apiClient.post("/api/bills/recurring/state", { vendor, state })).data,
+};
+
export const homeApi = {
summary: (): Promise => cached("/api/home/summary", async () => (await apiClient.get("/api/home/summary")).data),
};
+/** One month of GET /api/budget/history: `budget` is null for a past month that had none. */
+export interface BudgetMonth {
+ month: string;
+ budget: number | null;
+ spent: number;
+}
+
export const budgetApi = {
- get: async (): Promise => (await apiClient.get("/api/budget")).data,
- put: async (body: { monthly_budget?: number | null; categories?: { category: string; limit: number | null }[] }): Promise =>
+ /** `month` "YYYY-MM"; default the current month. Limits are the ones in force that month. */
+ // Every call names the browser's month: around midnight on the 1st the server's (UTC) date can be a month off.
+ get: async (month: string): Promise => (await apiClient.get("/api/budget", { params: { month } })).data,
+ history: async (months: number, to: string): Promise => (await apiClient.get("/api/budget/history", { params: { months, to } })).data.months,
+ put: async (body: { month: string; monthly_budget?: number | null; categories?: { category: string; limit: number | null }[] }): Promise =>
(await apiClient.put("/api/budget", body)).data,
/** Files set_budget proposals (nothing is saved until each is approved); [] = not enough history. */
draft: async (): Promise => (await apiClient.post("/api/budget/draft")).data.proposals,
@@ -787,7 +733,7 @@ export const agentApi = {
export interface ReviewFlag {
rule: string;
- severity: "warn" | "fail";
+ severity: "warn" | "fail" | "note"; // note: a warning not raised because you kept approving it
detail: string;
}
@@ -890,14 +836,31 @@ const upload = (file: File, extra: Record = {}) => {
return form;
};
+/** One statement import (GET /api/statements/imports); counts as the import returned them. */
+export interface StatementImport {
+ id: string;
+ file_name: string | null;
+ label: string | null;
+ period_from: string | null;
+ period_to: string | null;
+ lines: number;
+ imported: number;
+ matched: number;
+ duplicates: number;
+ credits: number;
+ unreadable: number;
+ created_at: string;
+}
+
export const statementApi = {
+ imports: async (): Promise => (await apiClient.get("/api/statements/imports")).data.imports,
preview: async (file: File): Promise =>
(await apiClient.post("/api/statements/preview", upload(file), { headers: { "Content-Type": undefined } })).data,
- import: async (file: File, mapping: StatementMapping, currency: string, headerLine: number): Promise =>
+ import: async (file: File, mapping: StatementMapping, currency: string, headerLine: number, label = ""): Promise =>
(
await apiClient.post(
"/api/statements/import",
- upload(file, { mapping: JSON.stringify(mapping), currency, header_line: String(headerLine) }),
+ upload(file, { mapping: JSON.stringify(mapping), currency, header_line: String(headerLine), ...(label.trim() ? { label: label.trim() } : {}) }),
{ headers: { "Content-Type": undefined } },
)
).data,
diff --git a/frontend/src/lib/bills-math.ts b/frontend/src/lib/bills-math.ts
new file mode 100644
index 0000000..f0f5266
--- /dev/null
+++ b/frontend/src/lib/bills-math.ts
@@ -0,0 +1,95 @@
+// Pure calculations behind the Bills page (UI revamp Phase 5c). No React, no fetching; unit tests in
+// e2e/bills-math.spec.ts. Input is GET /api/bills/recurring's items.
+import type { RecurringItem } from "@/lib/api/client";
+import { parseDay } from "@/lib/home-math";
+
+const DAY = 86_400_000;
+const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
+// still being charged: not cancelled (a "charged again" one is) and not looking stopped
+const live = (i: RecurringItem) => i.state !== "cancelled" && !i.possibly_stopped;
+
+export function monthlyEquivalent(i: RecurringItem): number {
+ return i.cadence === "weekly" ? (i.typical_amount * 52) / 12 : i.cadence === "yearly" ? i.typical_amount / 12 : i.typical_amount;
+}
+
+// The days of `month` (0-based) a charge is expected on, projected from its next due date by its cadence.
+export function dueDays(i: RecurringItem, year: number, month: number): number[] {
+ const next = parseDay(i.next_due_date);
+ const last = new Date(year, month + 1, 0).getDate();
+ if (i.cadence === "monthly") return [Math.min(next.getDate(), last)];
+ if (i.cadence === "yearly") return next.getMonth() === month ? [next.getDate()] : [];
+ const first = new Date(year, month, 1);
+ const offset = ((Math.round((next.getTime() - first.getTime()) / DAY) % 7) + 7) % 7;
+ const out: number[] = [];
+ for (let d = 1 + offset; d <= last; d += 7) out.push(d);
+ return out;
+}
+
+type Due = { vendor: string; date: Date; amount: number; item: RecurringItem; late?: boolean };
+
+// Every expected charge from today to 30 days out (weekly ones more than once), soonest first. A charge whose expected
+// date has passed without being seen is still coming (bills drift, statements arrive in batches), so it's kept, marked
+// late, until the detection calls it stopped.
+function dueSoon(items: RecurringItem[], today: Date): Due[] {
+ const until = addDays(today, 30);
+ const out: Due[] = [];
+ for (const i of items.filter(live)) {
+ const first = parseDay(i.next_due_date);
+ if (first < today) out.push({ vendor: i.vendor, date: first, amount: i.typical_amount, item: i, late: true });
+ const step = i.cadence === "weekly" ? 7 : null;
+ for (let d = first; d <= until; d = step ? addDays(d, step) : addDays(until, 1))
+ if (d >= today) out.push({ vendor: i.vendor, date: d, amount: i.typical_amount, item: i });
+ }
+ return out.sort((a, b) => a.date.getTime() - b.date.getTime());
+}
+
+// Home's "Coming up": the same charges for the next month, late ones first, in Home's reminder shape.
+export function toBills(items: RecurringItem[], today: Date) {
+ return dueSoon(items, today).map((d) => ({
+ id: `${d.vendor}-${d.date.toDateString()}`, vendor: d.vendor, amount: d.amount, date: ymd(d.date),
+ type: d.late ? "late" : "upcoming", category: d.item.category ?? undefined,
+ }));
+}
+
+// Not seen yet (late), then the next 30 days as This week (to Sunday), Next week and Later, each with its total;
+// empty groups are left out.
+export function upcoming(items: RecurringItem[], today: Date): { label: string; rows: Due[]; total: number }[] {
+ const sunday = addDays(today, 6 - ((today.getDay() + 6) % 7));
+ const nextSunday = addDays(sunday, 7);
+ const groups = [
+ { label: "Not seen yet", rows: [] as Due[] },
+ { label: "This week", rows: [] as Due[] },
+ { label: "Next week", rows: [] as Due[] },
+ { label: "Later", rows: [] as Due[] },
+ ];
+ for (const d of dueSoon(items, today)) groups[d.late ? 0 : d.date <= sunday ? 1 : d.date <= nextSunday ? 2 : 3].rows.push(d);
+ return groups.filter((g) => g.rows.length).map((g) => ({ ...g, total: g.rows.reduce((s, r) => s + r.amount, 0) }));
+}
+
+export function tiles(items: RecurringItem[], today: Date) {
+ const soon = dueSoon(items, today);
+ const stopped = items.filter((i) => i.possibly_stopped && i.kind === "subscription" && i.state !== "cancelled");
+ return {
+ dueSoon: { total: soon.reduce((s, d) => s + d.amount, 0), count: soon.length },
+ perMonth: Math.round(items.filter(live).reduce((s, i) => s + monthlyEquivalent(i), 0)),
+ stopped: { count: stopped.length, perMonth: Math.round(stopped.reduce((s, i) => s + monthlyEquivalent(i), 0)) },
+ };
+}
+
+// The calendar's bills for a month: an item shows from its first known charge on (estimated back from the last one by
+// its cadence and count), and one that's cancelled or looks stopped only up to the month of its last charge.
+export function billsOnCalendar(items: RecurringItem[], year: number, month: number): Map {
+ const out = new Map();
+ const shown = year * 12 + month;
+ for (const i of items) {
+ const last = parseDay(i.last_charge_date);
+ const back = i.charges_count - 1;
+ const first =
+ i.cadence === "weekly" ? addDays(last, -7 * back) : i.cadence === "yearly" ? new Date(last.getFullYear() - back, last.getMonth(), 1) : new Date(last.getFullYear(), last.getMonth() - back, 1);
+ const ended = i.state === "cancelled" || i.possibly_stopped;
+ if (shown < first.getFullYear() * 12 + first.getMonth() || (ended && shown > last.getFullYear() * 12 + last.getMonth())) continue;
+ for (const d of dueDays(i, year, month)) out.set(d, [...(out.get(d) ?? []), i]);
+ }
+ return out;
+}
diff --git a/frontend/src/lib/budget-math.ts b/frontend/src/lib/budget-math.ts
new file mode 100644
index 0000000..781f031
--- /dev/null
+++ b/frontend/src/lib/budget-math.ts
@@ -0,0 +1,33 @@
+// Pure calculations behind the Budget page (UI revamp Phase 5d). No React, no fetching; unit tests in
+// e2e/budget-math.spec.ts.
+
+const daysIn = (d: Date) => new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
+const sameMonth = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
+
+// Where a month stands against its budget. The even-pace mark, days left, safe-per-day and the gap to an even pace
+// only exist for the current month (null / 0 otherwise); safe-per-day is null once the budget is gone.
+export function pace(total: number, spent: number, today: Date, month: Date) {
+ const days = daysIn(month);
+ const current = sameMonth(today, month);
+ const evenPct = current ? today.getDate() / days : null;
+ const left = total - spent;
+ const daysLeft = current ? days - today.getDate() + 1 : 0;
+ return {
+ pct: total > 0 ? spent / total : 0,
+ evenPct,
+ left,
+ daysLeft,
+ perDay: current && left > 0 ? left / daysLeft : null,
+ vsPace: evenPct === null ? null : spent - total * evenPct,
+ };
+}
+
+export function usedTone(spent: number, limit: number): "good" | "warn" | "bad" {
+ return spent > limit ? "bad" : spent >= limit * 0.9 ? "warn" : "good";
+}
+
+// The straight line from nothing to the whole budget on the last day, one value per day.
+export const evenLine = (total: number, days: number) => Array.from({ length: days }, (_, i) => (total * (i + 1)) / days);
+
+// This month's total if the rest of it goes like it has so far.
+export const runRate = (spent: number, today: Date) => (spent / today.getDate()) * daysIn(today);
diff --git a/frontend/src/lib/chart.ts b/frontend/src/lib/chart.ts
new file mode 100644
index 0000000..03eba7b
--- /dev/null
+++ b/frontend/src/lib/chart.ts
@@ -0,0 +1,56 @@
+// Small chart helpers shared by the Phase 5 pages (spec 2026-10-22 §3).
+
+const n = (v: number) => +v.toFixed(2);
+
+// A smooth SVG path through every point: monotone cubic (Fritsch–Carlson), so a running total never dips or
+// overshoots between two days the way a plain Catmull-Rom curve does.
+export function smoothPath(points: [number, number][]): string {
+ if (!points.length) return "";
+ const [x0, y0] = points[0];
+ if (points.length === 1) return `M${n(x0)} ${n(y0)}`;
+ const last = points.length - 1;
+ const m = points.slice(0, last).map(([x, y], k) => (points[k + 1][1] - y) / (points[k + 1][0] - x || 1));
+ const t = points.map((_, k) => (k === 0 ? m[0] : k === last ? m[last - 1] : m[k - 1] * m[k] <= 0 ? 0 : (m[k - 1] + m[k]) / 2));
+ for (let k = 0; k < last; k++) {
+ if (m[k] === 0) {
+ t[k] = t[k + 1] = 0;
+ continue;
+ }
+ const a = t[k] / m[k];
+ const b = t[k + 1] / m[k];
+ const s = a * a + b * b;
+ if (s > 9) {
+ const tau = 3 / Math.sqrt(s);
+ t[k] = tau * a * m[k];
+ t[k + 1] = tau * b * m[k];
+ }
+ }
+ let d = `M${n(x0)} ${n(y0)}`;
+ for (let k = 0; k < last; k++) {
+ const [xa, ya] = points[k];
+ const [xb, yb] = points[k + 1];
+ const h = (xb - xa) / 3;
+ d += ` C${n(xa + h)} ${n(ya + t[k] * h)} ${n(xb - h)} ${n(yb - t[k + 1] * h)} ${n(xb)} ${n(yb)}`;
+ }
+ return d;
+}
+
+// Axis labels that never overlap. Each label knows its position, width and how it is anchored (the first label hangs
+// right of its point, the last left of it, the rest are centred), so the check compares real edges. Labels are taken
+// by rank (0 = Today, 1 = the two ends, 2 = the dates in between); one that would come within `gap` px of a label
+// already taken is dropped. Returned in their original order.
+export type Tick = { x: number; w: number; anchor: "start" | "middle" | "end"; rank: number };
+const edges = (t: Tick) => (t.anchor === "start" ? [t.x, t.x + t.w] : t.anchor === "end" ? [t.x - t.w, t.x] : [t.x - t.w / 2, t.x + t.w / 2]);
+
+export function pickTicks(candidates: T[], gap: number): T[] {
+ const taken: T[] = [];
+ for (const c of [...candidates].sort((a, b) => a.rank - b.rank)) {
+ const [l, r] = edges(c);
+ if (taken.every((t) => {
+ const [tl, tr] = edges(t);
+ return r + gap <= tl || tr + gap <= l;
+ }))
+ taken.push(c);
+ }
+ return candidates.filter((c) => taken.includes(c));
+}
diff --git a/frontend/src/lib/home-math.ts b/frontend/src/lib/home-math.ts
index 5a22fe8..2a0c051 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 } {
@@ -137,3 +139,23 @@ export function suggestedQuestions(found: { kind: string; subject: string | null
const fromFound = found.slice(0, 3).map((f) => ASK_ABOUT[f.kind]?.(f.subject)).filter((q): q is string => !!q);
return [...new Set([...fromFound, ...server])].slice(0, 4);
}
+
+// "1st", "22nd", "13th"
+export const ordinal = (n: number) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th")}`;
+
+// The little initials chip shown for a vendor: two letters ("Swiggy Instamart" → "SI", "Swiggy" → "SW") and a hue
+// that's the same every time for the same name, so a vendor keeps its colour across pages.
+export function vendorChip(name: string): { initials: string; hue: number } {
+ const words = name.trim().split(/\s+/).filter(Boolean);
+ const initials = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] ?? "").slice(0, 2)).toUpperCase() || "?";
+ let hash = 0;
+ for (const ch of name.toLowerCase()) hash = (hash * 31 + ch.charCodeAt(0)) % 360;
+ return { initials, hue: hash };
+}
+
+// The backend sends naive UTC timestamps ("2026-10-22T10:00:00"): read them as UTC, not local time. Plain dates stay
+// local calendar days; a timestamp that names its zone is read as it says.
+export function parseUtc(s: string): Date {
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return parseDay(s);
+ return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : `${s}Z`);
+}
diff --git a/frontend/src/lib/spending-math.ts b/frontend/src/lib/spending-math.ts
new file mode 100644
index 0000000..9e15efc
--- /dev/null
+++ b/frontend/src/lib/spending-math.ts
@@ -0,0 +1,98 @@
+// 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;
+
+export const sourceOf = (t: Txn) => (t.source === "statement" ? "Statement" : "Receipt");
+
+// One CSV cell. Vendor names come from bank lines and emailed invoices, so text a spreadsheet would run as a formula
+// (=, +, -, @, tab or CR first) gets a leading ' to stay text; amounts are numbers and stay as they are.
+const cell = (v: string | number) => {
+ const s = typeof v === "string" && /^[=+\-@\t\r]/.test(v) ? `'${v}` : String(v);
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+};
+
+// CSV of exactly the rows shown (spec 2026-10-22 §4.2): date, merchant, category, source, amount.
+export const toCsv = (rows: Txn[]) =>
+ ["date,merchant,category,source,amount", ...rows.map((t) => [t.date, t.vendor_name ?? "", t.category ?? "", sourceOf(t), t.total_amount ?? 0].map(cell).join(","))].join("\n");
+
+const SORT_LATER = new Set(["", "other", "uncategorised"]);
+// Whether a row belongs to a category, ignoring case and spaces: links (Home's spike, ?category=) carry the most common
+// spelling, rows may use another. "Uncategorised" also covers blank and "Other".
+export const inCategory = (t: Txn, c: string) => {
+ const mine = (t.category ?? "").trim().toLowerCase();
+ const want = c.trim().toLowerCase();
+ return want === "uncategorised" ? SORT_LATER.has(mine) : mine === want;
+};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index c3fdd48..b2b45c0 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -22,6 +22,6 @@
"@/*": ["./src/*"]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"],
"exclude": ["node_modules"]
}