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: ``) 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