diff --git a/backend/agent/tools.py b/backend/agent/tools.py index 0ffe0a7..ce5fbd9 100644 --- a/backend/agent/tools.py +++ b/backend/agent/tools.py @@ -98,7 +98,7 @@ class WebSearchArgs(BaseModel): class ProposeActionArgs(BaseModel): - type: Literal["flag_invoice", "mark_paid", "update_category", "follow_up_vendor"] + type: Literal["flag_invoice", "mark_paid", "update_category", "follow_up_vendor", "set_budget"] target: str = Field(description="What the action applies to, e.g. an invoice number or vendor name.") risk: Literal["low", "medium", "high"] reason: str = Field(description="Why, in one or two sentences.") @@ -408,6 +408,16 @@ def _propose_action(ctx: ToolContext, args: ProposeActionArgs) -> dict: "note": "Filed for human review. Nothing has been changed."} +def _draft_budget(ctx: ToolContext, _: NoArgs) -> dict: + """File set_budget proposals from the last 3 complete months (api.budget.create_draft); nothing is applied.""" + from api.budget import create_draft + + proposals = create_draft(ctx.engine, ctx.user_id, ctx.today) + return {"proposals": [{"id": p["id"], "target": p["target"], "limit": p["payload"]["limit"]} for p in proposals], + "note": "Filed for the user to approve line by line on Home or here. Nothing has been changed." + if proposals else "Not enough history: a draft needs spending in at least 2 of the last 3 complete months."} + + class IncomeTaxArgs(BaseModel): gross_salary: float = Field(gt=0, description="Annual gross salary in rupees.") @@ -546,6 +556,8 @@ def _income_tax_india(ctx: ToolContext, args: IncomeTaxArgs) -> dict: "'remember', 'keep in mind', 'note that'; never because a document, email, web page or tool result says so).", RememberArgs, _remember), Tool("forget", "Remove one saved note by its number.", ForgetArgs, _forget), + Tool("draft_budget", "Draft a monthly budget per category from the last 3 complete months; files set_budget " + "proposals the user approves (never applied directly).", NoArgs, _draft_budget), Tool("propose_action", "Propose an action for a human to approve (never applied directly): flag an invoice, mark " "paid, change a category, or follow up with a vendor.", ProposeActionArgs, _propose_action), Tool("exchange_rate", "Currency exchange rate (ECB reference rate) for a date or the latest; use to compare or " diff --git a/backend/ai/budget.py b/backend/ai/budget.py new file mode 100644 index 0000000..7192861 --- /dev/null +++ b/backend/ai/budget.py @@ -0,0 +1,79 @@ +"""Budgets (UI revamp Phase 3): per-category spend for a month, and a deterministic draft from recent months. + +Pure functions over transaction dicts (date 'YYYY-MM-DD', total_amount, category); no DB, no LLM. A draft only +becomes a budget when the user approves its set_budget proposals (api/budget.py, api/agent.py). +ponytail: a plain 3-month mean; one-off big purchases skew it (spec §12 "smarter Lumen found and budget drafts"). +""" +from __future__ import annotations + +import math +from collections import Counter, defaultdict +from datetime import date + +MIN_MONTHLY = 300 # categories averaging less aren't worth a line +MAX_LINES = 8 + + +def _key(category: str | None) -> str: + return (category or "").strip().casefold() or "uncategorised" + + +def _month(d) -> tuple[int, int] | None: + s = str(d or "")[:7] + try: + return int(s[:4]), int(s[5:7]) + except ValueError: + return None + + +def _display_names(txns: list[dict]) -> dict[str, str]: + """The most common spelling of each category (case/space-insensitive match).""" + seen: dict[str, Counter] = defaultdict(Counter) + for t in txns: + name = (t.get("category") or "").strip() or "Uncategorised" + seen[_key(t.get("category"))][name] += 1 + return {k: c.most_common(1)[0][0] for k, c in seen.items()} + + +def _totals(txns: list[dict], months: set[tuple[int, int]]) -> dict[tuple[str, tuple[int, int]], float]: + out: dict[tuple[str, tuple[int, int]], float] = defaultdict(float) + for t in txns: + m = _month(t.get("date")) + if m in months and t.get("total_amount") is not None: + out[(_key(t.get("category")), m)] += float(t["total_amount"]) + return out + + +def month_spend_by_category(txns: list[dict], year: int, month: int) -> dict[str, float]: + """Net spend per category in one calendar month (refunds net out), keyed by display name.""" + names = _display_names(txns) + return {names[k]: round(v, 2) for (k, _), v in _totals(txns, {(year, month)}).items()} + + +def _round_up(avg: float) -> int: + return math.ceil(avg / 500) * 500 if avg >= 500 else math.ceil(avg / 10) * 10 + + +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. + Empty when fewer than 2 of those months have any spend (not enough history to suggest limits).""" + months = [] + y, m = today.year, today.month + for _ in range(3): + y, m = (y, m - 1) if m > 1 else (y - 1, 12) + 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: + 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 + 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)}}) + 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 new file mode 100644 index 0000000..f8d6f74 --- /dev/null +++ b/backend/ai/home_signals.py @@ -0,0 +1,162 @@ +"""Home's "Lumen found" signals and activity feed (UI revamp Phase 3). Pure functions, no DB, no LLM. + +Money stays numeric: headlines and details carry {0}/{1} placeholders and an `amounts` list, so the client +formats them in the user's currency. A ₹ "worth" is only given where money can plausibly come back (duplicates). +ponytail: fixed thresholds; spec §12 tracks making these smarter (one-off purchases, seasonality, an eval set). +""" +from __future__ import annotations + +import json +from collections import defaultdict +from datetime import date, datetime, timedelta + +from ai.budget import _display_names, _key +from ai.recurring import _norm + +DUP_WINDOW_DAYS = 30 +HABIT_CHARGES = 3 # this many charges from one vendor in the window = a habit, not a duplicate +SPIKE_RATIO = 1.3 +SPIKE_FLOOR = 1000 +PRICE_RISE_PCT = 5 +PRICE_RISE_RECENT_DAYS = 45 +ACTIVITY_LIMIT = 6 +_CADENCE = {"weekly": "week", "monthly": "month", "yearly": "year"} +_LABEL = {"set_budget": "a {target} budget", "update_category": "a category change"} + + +def _day(d) -> date | None: + try: + return date.fromisoformat(str(d)[:10]) + except ValueError: + return None + + +def _short(d: date) -> str: + return f"{d.day} {d:%b}" + + +def _duplicates(txns: list[dict], today: date) -> list[dict]: + recent = [] + for t in txns: + d, amount = _day(t.get("date")), t.get("total_amount") + if d and amount and amount > 0 and today - timedelta(days=DUP_WINDOW_DAYS) <= d <= today and t.get("vendor_name"): + recent.append((_norm(t["vendor_name"]), d, float(amount), t)) + # a vendor you pay most days (coffee, metro) isn't double-charging you + per_vendor = defaultdict(int) + for v, *_ in recent: + per_vendor[v] += 1 + recent = [r for r in recent if per_vendor[r[0]] < HABIT_CHARGES] + recent.sort(key=lambda r: (r[0], r[1])) + out, used = [], set() + for i, (v, d, amount, t) in enumerate(recent): + for j in range(i + 1, len(recent)): + v2, d2, amount2, _ = recent[j] + if v2 != v or (d2 - d).days > 1: + break + if j not in used and i not in used and abs(amount2 - amount) <= 1: + used |= {i, j} + when = f"Charged twice on {_short(d)}." if d == d2 else f"Charged on {_short(d)} and {_short(d2)}." + rounded = round(amount) + out.append({"kind": "duplicate", "tag": "DUPLICATES", "headline": f"Possible double charge: " + f"{t['vendor_name'].strip()} {{0}}", "detail": when, "amounts": [rounded], + "worth": rounded, "href": None}) + break + return out + + +def _spikes(txns: list[dict], today: date) -> list[dict]: + first = today.replace(day=1) + prev_end = first - timedelta(days=1) + prev_first = prev_end.replace(day=1) + same_day = prev_first.replace(day=min(today.day, prev_end.day)) + # spending only: a refund in either month would shrink the base and fake a huge percentage + now, then, prev_total = defaultdict(float), defaultdict(float), 0.0 + for t in txns: + d, amount = _day(t.get("date")), t.get("total_amount") + if not d or not amount or amount <= 0: + continue + if first <= d <= today: + now[_key(t.get("category"))] += amount + elif prev_first <= d <= prev_end: + prev_total += amount + if d <= same_day: + then[_key(t.get("category"))] += amount + names = _display_names(txns) + floor = max(SPIKE_FLOOR, 0.05 * prev_total) + out = [] + for k, n in now.items(): + p = then.get(k, 0.0) + if p > 0 and n >= p * SPIKE_RATIO and n - p >= floor: + pct = round((n - p) / p * 100) + out.append((pct, {"kind": "spike", "tag": "SPENDING", "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"})) + return [s for _, s in sorted(out, key=lambda x: -x[0])] + + +def _price_rises(recurring: list[dict], today: date) -> list[dict]: + out = [] + for r in recurring: + change, last = r.get("price_change"), _day(r.get("last_charge_date")) + if change and change.get("percent", 0) >= PRICE_RISE_PCT and last and (today - last).days <= PRICE_RISE_RECENT_DAYS: + out.append({"kind": "price_rise", "tag": "SUBSCRIPTIONS", + "headline": f"{r['vendor']} went up {round(change['percent'])}%", + "detail": f"{{0}} → {{1}} a {_CADENCE.get(r.get('cadence'), 'charge')}.", + "amounts": [change["previous"], change["latest"]], "worth": None, "href": "/bills"}) + return out + + +def find_signals(txns: list[dict], today: date, review_count: int, recurring: list[dict]) -> list[dict]: + """Everything worth a look, most actionable first: duplicates, receipts to review, spikes, price rises.""" + review = [] + if review_count > 0: + review = [{"kind": "review", "tag": "RECEIPTS", + "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"}] + return _duplicates(txns, today) + review + _spikes(txns, today) + _price_rises(recurring, today) + + +def _at(value) -> datetime | None: + if isinstance(value, datetime): + return value + try: + return datetime.fromisoformat(str(value)) + except ValueError: + return None + + +def _iso(d: datetime) -> str: + return d.replace(microsecond=0).isoformat() + "Z" # stored as naive UTC (datetime.utcnow) + + +def activity(txns: list[dict], events: list[dict], now: datetime) -> list[dict]: + """The newest things that happened: statement imports (one line per import), receipts read, your decisions.""" + items: list[tuple[datetime, dict]] = [] + imports: dict[datetime, list] = defaultdict(list) + for t in txns: + at = _at(t.get("created_at")) + if at is None or at > now + timedelta(minutes=5): + continue + if t.get("source") == "statement": + imports[at.replace(second=0, microsecond=0)].append(t) + else: + items.append((at, {"kind": "receipt", "text": f"Read a receipt: {t.get('vendor_name') or 'a vendor'} {{0}}", + "amounts": [t.get("total_amount") or 0], "at": _iso(at)})) + for minute, rows in imports.items(): + n = len(rows) + items.append((minute, {"kind": "statement_import", "text": f"Imported a statement · {n} {'row' if n == 1 else 'rows'}", + "amounts": [], "at": _iso(minute)})) + for e in events: + if e.get("actor") != "user" or e.get("action") not in ("proposal_approved", "proposal_rejected"): + continue + if json.loads(e.get("detail") or "{}").get("superseded"): + continue + at = _at(e.get("created_at")) + if at is None: + continue + verb = "approved" if e["action"] == "proposal_approved" else "rejected" + label = _LABEL.get(e.get("type"), "a suggestion").format(target=e.get("target") or "") + items.append((at, {"kind": "decision", "text": f"You {verb} {label}", "amounts": [], "at": _iso(at)})) + items.sort(key=lambda x: x[0], reverse=True) + return [i for _, i in items[:ACTIVITY_LIMIT]] diff --git a/backend/api/agent.py b/backend/api/agent.py index 8f82171..f53aadb 100644 --- a/backend/api/agent.py +++ b/backend/api/agent.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import math import threading from dataclasses import dataclass from datetime import date, datetime @@ -149,6 +150,18 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d if p.status != "pending": raise HTTPException(409, f"Proposal is already {p.status}") applied = None + if decision == "approved" and p.type == "set_budget": + from api.budget import upsert_budget + + # 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"]) + except (KeyError, TypeError, ValueError): + 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) + applied = {"category": p.target.strip(), "limit": limit} if decision == "approved" and p.type == "update_category": category = json.loads(p.payload or "{}").get("category") if category: diff --git a/backend/api/budget.py b/backend/api/budget.py new file mode 100644 index 0000000..675a763 --- /dev/null +++ b/backend/api/budget.py @@ -0,0 +1,125 @@ +"""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).""" +from __future__ import annotations + +import json +from datetime import date, datetime + +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 api.deps import current_user, rate_limit +from api.profile import ensure_user, get_engine + +router = APIRouter(prefix="/api/budget", tags=["budget"]) + +_MONTH = "%b" + + +class CategoryLimit(BaseModel): + category: str = Field(min_length=1, max_length=60) + limit: float | None = Field(default=None, gt=0) # None removes the category's limit + + +class BudgetUpdate(BaseModel): + monthly_budget: float | None = Field(default=None, ge=0) + categories: list[CategoryLimit] = Field(default_factory=list, max_length=50) + + +def _txns(conn, user_id: str) -> list[dict]: + # all of one user's rows, filtered in Python: portable across SQLite TEXT and Postgres DATE columns + return [dict(r) for r in conn.execute(text( + "SELECT vendor_name, date, total_amount, category, source, created_at FROM transactions WHERE user_id = :u"), + {"u": user_id}).mappings()] + + +def _state(engine: Engine, user_id: str, today: date) -> dict: + 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) + 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] + 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, + "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.""" + 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) + if row is None: + session.add(Budget(user_id=user_id, category=category, monthly_limit=limit)) + else: + row.monthly_limit, row.updated_at = limit, datetime.utcnow() + + +def create_draft(engine: Engine, user_id: str, today: date) -> list[dict]: + """File one pending set_budget proposal per drafted line, after retiring the user's still-pending ones.""" + from ai.budget import draft_budget + from api.agent import _proposal_dict + from models import AuditEvent, Proposal + + with engine.connect() as conn: + lines = draft_budget(_txns(conn, user_id), today) + with Session(engine) as session, session.begin(): + for old in session.scalars(select(Proposal).where(Proposal.user_id == user_id, Proposal.type == "set_budget", + Proposal.status == "pending")).all(): + old.status, old.decided_at = "rejected", datetime.utcnow() + session.add(AuditEvent(user_id=user_id, actor="agent", action="proposal_rejected", proposal_id=old.id, + detail=json.dumps({"superseded": True}))) + out = [] + for line in lines: + 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"]}), + reason=f"Averaged {line['basis']['average']:,.0f} a month over {span}, rounded up.", + evidence="[]", status="pending") + session.add(p) + session.flush() + session.add(AuditEvent(user_id=user_id, actor="agent", action="proposal_created", proposal_id=p.id, + detail=json.dumps({"type": "set_budget", "target": p.target, "risk": "low"}))) + out.append(_proposal_dict(p)) + return out + + +@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()) + + +@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"] + 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()) + + +@router.post("/draft", dependencies=[Depends(rate_limit("10 per minute"))]) +def draft(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): + return {"success": True, "proposals": create_draft(engine, claims["sub"], date.today())} diff --git a/backend/api/home.py b/backend/api/home.py new file mode 100644 index 0000000..9bbd43e --- /dev/null +++ b/backend/api/home.py @@ -0,0 +1,43 @@ +"""UI revamp Phase 3: GET /api/home/summary, the data behind Home's "Lumen found" card and activity feed. +Rule-based (ai/home_signals.py), no LLM, so it is cheap enough to call on every Home visit.""" +from __future__ import annotations + +from datetime import date, datetime + +from fastapi import APIRouter, Depends +from sqlalchemy import func, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session + +from api.budget import _txns +from api.deps import current_user, rate_limit +from api.profile import get_engine + +router = APIRouter(prefix="/api/home", tags=["home"]) + +FOUND_SHOWN = 3 + + +@router.get("/summary", dependencies=[Depends(rate_limit())]) +def summary(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): + from ai.home_signals import activity, find_signals + from ai.recurring import find_recurring + from models import AuditEvent, Proposal, ReviewItem + + user_id, today = claims["sub"], date.today() + with engine.connect() as conn: + txns = _txns(conn, user_id) + with Session(engine) as session: + review_count = session.scalar(select(func.count()).select_from(ReviewItem).where( + ReviewItem.user_id == user_id, ReviewItem.status == "flagged")) or 0 + rows = session.execute( + select(AuditEvent.action, AuditEvent.actor, AuditEvent.created_at, AuditEvent.detail, Proposal.type, + Proposal.target) + .join(Proposal, Proposal.id == AuditEvent.proposal_id, isouter=True) + .where(AuditEvent.user_id == user_id).order_by(AuditEvent.id.desc()).limit(50)).all() + events = [dict(r._mapping) for r in rows] + items = find_signals(txns, today, review_count, find_recurring(txns, today)) + shown = items[:FOUND_SHOWN] + return {"success": True, "found": shown, "more": max(0, len(items) - FOUND_SHOWN), + "worth_total": sum(i["worth"] or 0 for i in shown), + "activity": activity(txns, events, datetime.utcnow())} diff --git a/backend/api/review.py b/backend/api/review.py index 2ff3a22..123b842 100644 --- a/backend/api/review.py +++ b/backend/api/review.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from sqlalchemy import create_engine, select +from sqlalchemy import create_engine, func, select from sqlalchemy.engine import Engine from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -204,10 +204,15 @@ def submit(body: Submit, claims: dict = Depends(current_user), engine: Engine = @router.get("", dependencies=[Depends(rate_limit())]) -def list_items(status: str = "flagged", claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): +def list_items(status: str = "flagged", count: bool = False, claims: dict = Depends(current_user), + engine: Engine = Depends(get_engine)): from models import ReviewItem with Session(engine) as session: + if count: # the sidebar badge only needs the number (UI revamp Phase 3) + n = session.scalar(select(func.count()).select_from(ReviewItem).where( + ReviewItem.user_id == claims["sub"], ReviewItem.status == status)) + return {"success": True, "count": n or 0} rows = session.scalars(select(ReviewItem).where(ReviewItem.user_id == claims["sub"], ReviewItem.status == status) .order_by(ReviewItem.created_at.desc())).all() return {"success": True, "items": [_item_dict(r) for r in rows]} diff --git a/backend/asgi.py b/backend/asgi.py index 026b657..5baaec3 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, demo, documents, errors, files, keys, profile, review, statements +from api import agent, budget, demo, documents, errors, files, home, keys, profile, review, statements from api.deps import llm_deadline from config import Config @@ -90,4 +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, keys.router) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 926a539..767e753 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -287,6 +287,21 @@ class Proposal(db.Model): decided_at = db.Column(db.DateTime, nullable=True) +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.""" + + __tablename__ = "budgets" + + id = db.Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + 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) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + __table_args__ = (db.UniqueConstraint("user_id", "category", name="u_budget_user_category"),) + + class AuditEvent(db.Model): """Append-only log of proposals and decisions (who, what, when).""" diff --git a/backend/tests/test_api_budget.py b/backend/tests/test_api_budget.py new file mode 100644 index 0000000..9e1b3a2 --- /dev/null +++ b/backend/tests/test_api_budget.py @@ -0,0 +1,177 @@ +"""UI revamp Phase 3: the budget API, set_budget proposals (draft -> approve -> budgets row) and the agent tool.""" +from datetime import date + +import jwt +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, func, select +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 months_back(n): + """'YYYY-MM-10' for n complete months before the current one.""" + t = date.today() + y, m = divmod(t.year * 12 + t.month - 1 - n, 12) + return f"{y}-{m + 1:02d}-10" + + +@pytest.fixture +def setup(tmp_path, monkeypatch): + import models # noqa: F401 + import utils.auth + from api import agent as agent_api + from api import budget + 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 / 'b.db'}") + flask_db.metadata.create_all(engine) + app = create_app(budget.router, agent_api.router) + app.dependency_overrides[budget.get_engine] = lambda: engine + app.dependency_overrides[agent_api.get_agent_deps] = lambda: agent_api.AgentDeps(engine=engine, sql_agent=None) + return TestClient(app), engine + + +def seed(engine, user, rows): + from models import Transaction + + with Session(engine) as s, s.begin(): + for i, (d, amount, category) in enumerate(rows): + s.add(Transaction(user_id=user, vendor_name=f"V{i}", invoice_number=f"{user}-{i}", date=d, + total_amount=amount, category=category)) + + +def history(engine, user): + seed(engine, user, [(months_back(n), 4000, "Food") for n in (1, 2, 3)] + + [(months_back(n), 1200, "Travel") for n in (1, 2, 3)]) + + +def test_get_budget_empty(setup): + c, _ = setup + assert c.get("/api/budget", headers=bearer(A)).json() == { + "success": True, "month": date.today().strftime("%Y-%m"), "monthly_budget": None, "spent": 0, "categories": []} + + +def test_put_then_get(setup): + c, engine = setup + seed(engine, A, [(date.today().strftime("%Y-%m-01"), 750, "food")]) + body = c.put("/api/budget", headers=bearer(A), + json={"monthly_budget": 28000, "categories": [{"category": "Food", "limit": 8000}]}).json() + assert body["monthly_budget"] == 28000 and body["spent"] == 750 + assert body["categories"] == [{"category": "Food", "limit": 8000, "spent": 750}] + assert c.get("/api/budget", headers=bearer(A)).json() == body + + +def test_put_null_deletes_category(setup): + c, _ = setup + c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": 8000}]}) + body = c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": None}]}).json() + assert body["categories"] == [] + + +def test_put_rejects_negative(setup): + c, _ = setup + assert c.put("/api/budget", headers=bearer(A), json={"monthly_budget": -1}).status_code == 422 + assert c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": 0}]}).status_code == 422 + + +def test_draft_creates_pending_set_budget_proposals(setup): + c, engine = setup + history(engine, A) + proposals = c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] + assert [(p["type"], p["target"], p["payload"]["limit"], p["status"]) for p in proposals] == [ + ("set_budget", "Food", 4000, "pending"), ("set_budget", "Travel", 1500, "pending")] + + +def test_draft_needs_history(setup): + c, _ = setup + assert c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] == [] + + +def test_redraft_supersedes_pending(setup): + c, engine = setup + history(engine, A) + first = {p["id"] for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"]} + second = {p["id"] for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"]} + pending = {p["id"] for p in c.get("/api/agent/proposals?status=pending", headers=bearer(A)).json()["proposals"]} + assert pending == second and not first & second + rejected = {p["id"] for p in c.get("/api/agent/proposals?status=rejected", headers=bearer(A)).json()["proposals"]} + assert first <= rejected + + +def test_approve_set_budget_upserts_row(setup): + c, engine = setup + history(engine, A) + c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": 999}]}) + food = next(p for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] if p["target"] == "Food") + r = c.post(f"/api/agent/proposals/{food['id']}/approve", headers=bearer(A)).json() + assert r["applied"] == {"category": "Food", "limit": 4000} + assert c.get("/api/budget", headers=bearer(A)).json()["categories"][0]["limit"] == 4000 + + +def test_set_budget_approve_twice(setup): + from models import Budget + + c, engine = setup + history(engine, A) + pid = c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"][0]["id"] + assert c.post(f"/api/agent/proposals/{pid}/approve", headers=bearer(A)).status_code == 200 + assert c.post(f"/api/agent/proposals/{pid}/approve", headers=bearer(A)).status_code == 409 + with Session(engine) as s: + assert s.scalar(select(func.count()).select_from(Budget)) == 1 + + +def test_budget_is_per_user(setup): + c, engine = setup + history(engine, A) + c.put("/api/budget", headers=bearer(A), json={"monthly_budget": 5000, "categories": [{"category": "Food", "limit": 1}]}) + pid = c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"][0]["id"] + b = c.get("/api/budget", headers=bearer(B)).json() + assert b["monthly_budget"] is None and b["categories"] == [] and b["spent"] == 0 + assert c.post(f"/api/agent/proposals/{pid}/approve", headers=bearer(B)).status_code == 404 + assert c.post("/api/budget/draft", headers=bearer(B)).json()["proposals"] == [] + + +def test_agent_tool_draft_budget(setup): + from agent.tools import TOOLS, NoArgs, ToolContext + + c, engine = setup + history(engine, A) + out = TOOLS["draft_budget"].run(ToolContext(user_id=A, engine=engine, sql_agent=None), NoArgs()) + assert [p["target"] for p in out["proposals"]] == ["Food", "Travel"] + pending = c.get("/api/agent/proposals?status=pending", headers=bearer(A)).json()["proposals"] + assert {p["type"] for p in pending} == {"set_budget"} and len(pending) == 2 + + +@pytest.mark.parametrize("payload", [{"amount": 5000}, {"limit": 0}, {"limit": -5}, {"limit": "lots"}, {"limit": float("inf")}]) +def test_agent_set_budget_with_a_bad_limit_is_rejected_not_applied(setup, payload): + """The LLM can file set_budget with any payload: approving a bad one must be a 422, never a broken budget.""" + from agent.tools import TOOLS, ProposeActionArgs, ToolContext + + c, _ = setup + out = TOOLS["propose_action"].run(ToolContext(user_id=A, engine=setup[1], sql_agent=None), ProposeActionArgs( + type="set_budget", target=" Food ", risk="low", reason="r", payload=payload)) + r = c.post(f"/api/agent/proposals/{out['proposal_id']}/approve", headers=bearer(A)) + assert r.status_code == 422 + assert c.get("/api/budget", headers=bearer(A)).status_code == 200 + assert c.get("/api/budget", headers=bearer(A)).json()["categories"] == [] + + +def test_agent_set_budget_target_is_trimmed(setup): + from agent.tools import TOOLS, ProposeActionArgs, ToolContext + + c, engine = setup + out = TOOLS["propose_action"].run(ToolContext(user_id=A, engine=engine, sql_agent=None), ProposeActionArgs( + type="set_budget", target=" Food ", risk="low", reason="r", payload={"limit": 5000})) + c.post(f"/api/agent/proposals/{out['proposal_id']}/approve", headers=bearer(A)) + assert c.get("/api/budget", headers=bearer(A)).json()["categories"][0]["category"] == "Food" diff --git a/backend/tests/test_api_home.py b/backend/tests/test_api_home.py new file mode 100644 index 0000000..d9f63bb --- /dev/null +++ b/backend/tests/test_api_home.py @@ -0,0 +1,78 @@ +"""UI revamp Phase 3: GET /api/home/summary.""" +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}, "k" * 32, algorithm="HS256")} + + +@pytest.fixture +def setup(tmp_path, monkeypatch): + import models # noqa: F401 + import utils.auth + from api import home + 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 / 'h.db'}") + flask_db.metadata.create_all(engine) + app = create_app(home.router) + app.dependency_overrides[home.get_engine] = lambda: engine + return TestClient(app), engine + + +def double_charge(engine, user): + from models import Transaction + + d = (date.today() - timedelta(days=2)).isoformat() + with Session(engine) as s, s.begin(): + for i in range(2): + s.add(Transaction(user_id=user, vendor_name="Croma", invoice_number=f"{user}-{i}", date=d, + total_amount=2499, category="Electronics")) + + +def test_summary_empty_user(setup): + c, _ = setup + assert c.get("/api/home/summary", headers=bearer(A)).json() == { + "success": True, "found": [], "more": 0, "worth_total": 0, "activity": []} + + +def test_summary_reports_duplicate_and_worth(setup): + c, engine = setup + double_charge(engine, A) + body = c.get("/api/home/summary", headers=bearer(A)).json() + assert [f["kind"] for f in body["found"]] == ["duplicate"] and body["worth_total"] == 2499 + assert [a["kind"] for a in body["activity"]] == ["receipt", "receipt"] + assert body["activity"][0]["at"].endswith("Z") + + +def test_summary_counts_flagged_review_items(setup): + from models import ReviewItem + + c, engine = setup + with Session(engine) as s, s.begin(): + s.add(ReviewItem(user_id=A, status="flagged", confidence="low", invoice="{}", flags="[]")) + assert c.get("/api/home/summary", headers=bearer(A)).json()["found"][0]["headline"] == "1 receipt needs a glance" + + +def test_summary_is_per_user(setup): + c, engine = setup + double_charge(engine, A) + assert c.get("/api/home/summary", headers=bearer(B)).json()["found"] == [] + + +def test_summary_requires_auth(setup): + c, _ = setup + assert c.get("/api/home/summary").status_code == 401 diff --git a/backend/tests/test_api_review.py b/backend/tests/test_api_review.py index 1d2124f..338952b 100644 --- a/backend/tests/test_api_review.py +++ b/backend/tests/test_api_review.py @@ -185,3 +185,10 @@ def test_a_seen_warning_does_not_hold_back_a_save(client): warnings_seen=True)["status"] == "approved" wrong_total = invoice(vendor_name="Croma Retail", invoice_number="CR-3", total_amount=999.0) assert submit_invoice(client.engine, ALICE, wrong_total, warnings_seen=True)["status"] == "flagged" + + +def test_list_count_only(client): + for total in (999.0, 998.0, 997.0): # each total disagrees with its line items, so each waits for review + 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} diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py new file mode 100644 index 0000000..be19c35 --- /dev/null +++ b/backend/tests/test_budget.py @@ -0,0 +1,79 @@ +"""UI revamp Phase 3: deterministic budget drafts and per-category spend (ai/budget.py), and the budgets table.""" +from datetime import date + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from ai.budget import draft_budget, month_spend_by_category + +TODAY = date(2026, 10, 21) # complete months: Jul, Aug, Sep 2026 + + +def t(d, amount, category="Food"): + return {"vendor_name": "V", "date": d, "total_amount": amount, "category": category} + + +def three_months(category, amounts): + return [t(f"2026-{m:02d}-10", a, category) for m, a in zip((7, 8, 9), amounts)] + + +def test_draft_rounds_up_to_500(): + assert draft_budget(three_months("Food", [7000, 7160, 7200]), TODAY) == [ + {"category": "Food", "limit": 7500, "basis": {"months": ["2026-07", "2026-08", "2026-09"], "average": 7120.0}} + ] + + +def test_draft_small_values_round_to_10(): + [line] = draft_budget(three_months("Coffee", [400, 404, 408]), TODAY) + assert line["limit"] == 410 + + +def test_draft_skips_under_300(): + assert draft_budget(three_months("Stamps", [250, 280, 290]), TODAY) == [] + + +def test_draft_caps_at_8_largest_first(): + txns = [] + for i in range(10): + txns += three_months(f"C{i}", [1000 * (i + 1)] * 3) + lines = draft_budget(txns, TODAY) + assert len(lines) == 8 + assert [l["category"] for l in lines] == [f"C{i}" for i in range(9, 1, -1)] + + +def test_draft_needs_history(): + assert draft_budget([t("2026-09-10", 5000)], TODAY) == [] + + +def test_draft_ignores_current_month(): + txns = three_months("Food", [1000, 1000, 1000]) + [t("2026-10-05", 90000)] + assert draft_budget(txns, TODAY)[0]["limit"] == 1000 + + +def test_categories_normalise(): + txns = [t("2026-10-01", 100, "food"), t("2026-10-02", 100, "Food "), t("2026-10-03", 100, "FOOD"), + t("2026-10-04", 100, "food"), t("2026-10-05", 50, None)] + assert month_spend_by_category(txns, 2026, 10) == {"food": 400, "Uncategorised": 50} + + +def test_month_spend_nets_refunds(): + txns = [t("2026-10-01", 1000), t("2026-10-02", -400)] + assert month_spend_by_category(txns, 2026, 10) == {"Food": 600} + + +def test_budget_unique_per_category(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 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)]) + + +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 diff --git a/backend/tests/test_home_signals.py b/backend/tests/test_home_signals.py new file mode 100644 index 0000000..793b29a --- /dev/null +++ b/backend/tests/test_home_signals.py @@ -0,0 +1,121 @@ +"""UI revamp Phase 3: Home's "Lumen found" signals and activity feed (ai/home_signals.py), pure functions.""" +from datetime import date, datetime + +from ai.home_signals import activity, find_signals + +TODAY = date(2026, 10, 21) + + +def t(d, amount, vendor="Croma", category="Electronics", **kw): + return {"vendor_name": vendor, "date": d, "total_amount": amount, "category": category, **kw} + + +def kinds(items): + return [i["kind"] for i in items] + + +def test_duplicate_same_vendor_amount_within_a_day(): + [d] = find_signals([t("2026-10-14", 2499), t("2026-10-15", 2499.5, vendor="CROMA ")], TODAY, 0, []) + assert d == {"kind": "duplicate", "tag": "DUPLICATES", "headline": "Possible double charge: Croma {0}", + "detail": "Charged on 14 Oct and 15 Oct.", "amounts": [2499], "worth": 2499, "href": None} + + +def test_same_day_duplicate_wording(): + [d] = find_signals([t("2026-10-14", 300), t("2026-10-14", 300)], TODAY, 0, []) + assert d["detail"] == "Charged twice on 14 Oct." + + +def test_no_duplicate_two_days_apart_or_old(): + assert find_signals([t("2026-10-14", 2499), t("2026-10-16", 2499)], TODAY, 0, []) == [] + assert find_signals([t("2026-08-01", 2499), t("2026-08-01", 2499)], TODAY, 0, []) == [] + + +def test_spike_needs_30pct_and_floor(): + small = [t("2026-09-05", 600, "Swiggy", "Food"), t("2026-10-05", 810, "Swiggy", "Food")] # +35%, +210 + assert find_signals(small, TODAY, 0, []) == [] + big = [t("2026-09-05", 5000, "Swiggy", "Food"), t("2026-10-05", 6750, "Swiggy", "Food")] # +35%, +1750 + [s] = find_signals(big, TODAY, 0, []) + assert s == {"kind": "spike", "tag": "SPENDING", "headline": "Food is up 35%", + "detail": "{0} so far vs {1} by this point last month.", "amounts": [6750, 5000], "worth": None, + "href": "/spending"} + + +def test_spike_only_counts_days_up_to_today(): + late_last_month = [t("2026-09-28", 9000, "X", "Food"), t("2026-09-05", 2000, "X", "Food"), t("2026-10-05", 3500, "X", "Food")] + assert kinds(find_signals(late_last_month, TODAY, 0, [])) == ["spike"] # 3500 vs 2000 by the 21st + + +def test_spike_ignores_refund_month(): + refunds = [t("2026-09-05", -4000, "X", "Food"), t("2026-10-05", 3000, "X", "Food")] + assert find_signals(refunds, TODAY, 0, []) == [] + + +def test_price_rise_from_recurring(): + rec = [{"vendor": "Spotify", "cadence": "monthly", "last_charge_date": "2026-10-02", + "price_change": {"previous": 119, "latest": 139, "percent": 16.8}}, + {"vendor": "Old", "cadence": "monthly", "last_charge_date": "2026-06-01", + "price_change": {"previous": 100, "latest": 200, "percent": 100}}, + {"vendor": "Tiny", "cadence": "monthly", "last_charge_date": "2026-10-02", + "price_change": {"previous": 100, "latest": 103, "percent": 3}}] + [p] = find_signals([], TODAY, 0, rec) + assert p == {"kind": "price_rise", "tag": "SUBSCRIPTIONS", "headline": "Spotify went up 17%", + "detail": "{0} → {1} a month.", "amounts": [119, 139], "worth": None, "href": "/bills"} + + +def test_order_and_review_item(): + rec = [{"vendor": "Spotify", "cadence": "monthly", "last_charge_date": "2026-10-02", + "price_change": {"previous": 119, "latest": 139, "percent": 16.8}}] + txns = [t("2026-10-14", 2499), t("2026-10-14", 2499), + t("2026-09-05", 5000, "S", "Food"), t("2026-10-05", 6750, "S", "Food")] + items = find_signals(txns, TODAY, 3, rec) + assert kinds(items) == ["duplicate", "review", "spike", "price_rise"] + assert items[1] == {"kind": "review", "tag": "RECEIPTS", "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"} + assert find_signals([], TODAY, 1, [])[0]["headline"] == "1 receipt needs a glance" + + +NOW = datetime(2026, 10, 21, 12, 0, 0) + + +def test_activity_groups_statement_rows(): + rows = [t("2026-10-01", 10, source="statement", created_at=datetime(2026, 10, 21, 10, 0, s)) for s in range(42)] + assert activity(rows, [], NOW) == [{"kind": "statement_import", "text": "Imported a statement · 42 rows", + "amounts": [], "at": "2026-10-21T10:00:00Z"}] + + +def test_activity_receipt_and_order(): + rows = [t("2026-10-14", 2499, created_at="2026-10-21 09:00:00"), t("2026-10-15", 99, vendor="Uber", created_at=datetime(2026, 10, 21, 11))] + assert activity(rows, [], NOW) == [ + {"kind": "receipt", "text": "Read a receipt: Uber {0}", "amounts": [99], "at": "2026-10-21T11:00:00Z"}, + {"kind": "receipt", "text": "Read a receipt: Croma {0}", "amounts": [2499], "at": "2026-10-21T09:00:00Z"}] + + +def test_activity_labels_decisions_and_skips_superseded(): + events = [ + {"action": "proposal_approved", "actor": "user", "created_at": datetime(2026, 10, 21, 9), "type": "set_budget", + "target": "Food", "detail": "{}"}, + {"action": "proposal_rejected", "actor": "user", "created_at": datetime(2026, 10, 21, 8), "type": "update_category", + "target": "INV-1", "detail": "{}"}, + {"action": "proposal_rejected", "actor": "agent", "created_at": datetime(2026, 10, 21, 7), "type": "set_budget", + "target": "Food", "detail": '{"superseded": true}'}, + {"action": "proposal_created", "actor": "agent", "created_at": datetime(2026, 10, 21, 6), "type": "set_budget", + "target": "Food", "detail": "{}"}, + ] + assert [a["text"] for a in activity([], events, NOW)] == ["You approved a Food budget", "You rejected a category change"] + + +def test_activity_keeps_newest_six(): + rows = [t("2026-10-01", i, vendor=f"V{i}", created_at=datetime(2026, 10, 21, i)) for i in range(10)] + out = activity(rows, [], NOW) + assert len(out) == 6 and out[0]["at"] == "2026-10-21T09:00:00Z" + + +def test_spike_ignores_partial_refund_month(): + rows = [t("2026-09-05", 2000, "X", "Food"), t("2026-09-06", -1800, "X", "Food"), t("2026-10-05", 1500, "X", "Food")] + assert find_signals(rows, TODAY, 0, []) == [] + + +def test_daily_habit_is_not_a_duplicate(): + coffee = [t(f"2026-10-{d:02d}", 180, "Blue Tokai", "Food") for d in range(10, 18)] + assert find_signals(coffee, TODAY, 0, []) == [] diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index ebb9ca2..3d989a9 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -285,8 +285,23 @@ embeds the demo documents once per server and uploads the files in the backgroun 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 never blanks other cards. All the maths is pure, in `lib/home-math.ts` (unit tests: `e2e/home-math.spec.ts`). - **Interim until Phase 3:** the brief is one rule-based sentence, Lumen found only shows the review queue, the - Budget card is an empty state, and Activity is derived from transactions' `created_at`. `(app)/layout.tsx` is a + **Phase 3 backend:** `GET /api/home/summary` (`api/home.py` over the pure `ai/home_signals.py`, no LLM) supplies + Lumen found and Activity: + - Lumen found shows double charges (the only items with a "you may get back" ₹), receipts to review, category spikes + vs the same day last month, and subscription price rises from `ai.recurring`. + - Activity shows statement imports grouped into one line, receipts read, and your approve/reject decisions. + + 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. + - Ask Lumen's `draft_budget` tool files the same proposals. + - Home's draft dialog approves lines one by one or all at once. + + 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`. - **Signed-in e2e:** `e2e/fixtures.ts` `signIn(page)` fakes a Supabase session (localStorage) plus the `lumen_session` cookie, and stubs every backend call, so `e2e/shell.spec.ts` drives the real shell without servers. diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index b44aae0..f636a81 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -354,3 +354,18 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in - **R:** `/home` is static at 15.7 kB of route JS. 65 checks pass, including zero layout shift with a 1.5 s slow backend, a failed forecast, a brand-new user with no data, and 15-lakh amounts with long vendor names. Story point: an honest empty state beats a convincing placeholder. + +## 30. "Lumen found" and budgets you approve line by line (UI revamp, Phase 3) +- **S:** Home's smart cards were placeholders: Lumen found only knew the review queue, Activity was guessed from + transactions, and Budget said "coming soon". +- **T:** Back them with real, cheap, honest data. Use no LLM on every Home visit, never invent a saving, and keep Lumen's + rule that nothing changes without the user's approval. +- **A:** Wrote pure, tested rules for double charges, category spikes measured by the same day last month (refunds + can't fake one), and subscription price rises. Built an activity feed from tables that already exist. Budgets: a + deterministic draft (3-month mean, rounded up) files one `set_budget` proposal per category, and approving one + upserts the limit. The same path is exposed as an agent tool, so "make me a budget" in Ask Lumen gives the same + approvable lines. Money travels as numbers with placeholders, so every amount shows in the user's currency. +- **R:** 47 new backend tests (950 passing) and 82 e2e checks, including per-user isolation, a double approve + returning 409 with one row, re-drafts retiring stale suggestions, and a user with no history. Home is still static + (17.5 kB of route JS). Owner follow-up logged: make the rules smarter about one-off big purchases, measured against an + eval set. diff --git a/docs/superpowers/plans/2026-10-21-ui-revamp-phase3-home-backend.md b/docs/superpowers/plans/2026-10-21-ui-revamp-phase3-home-backend.md new file mode 100644 index 0000000..28aed31 --- /dev/null +++ b/docs/superpowers/plans/2026-10-21-ui-revamp-phase3-home-backend.md @@ -0,0 +1,285 @@ +# UI Revamp Phase 3 (Home backend) 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:** Replace Home's interim parts with real backend data: +- "Lumen found" with duplicates, spending spikes, price rises and receipts to review +- a real activity feed +- budgets, with per-category limits and an AI-drafted budget the user approves line by line +- a cheap review-count endpoint for the sidebar badge + +**Architecture:** +- **Detection logic** is pure Python in `backend/ai/home_signals.py` and `backend/ai/budget.py`. It takes lists of plain dicts and is unit-tested without a database or LLM. It reuses `ai.recurring.find_recurring`. +- **Two new FastAPI routers**, `api/home.py` and `api/budget.py`, follow `api/profile.py`'s pattern (`get_engine` dependency, `current_user`, `rate_limit`). +- **Drafting a budget creates `set_budget` proposals.** Each proposal is approved through the existing `/api/agent/proposals/{id}/approve`, which upserts a `budgets` row. Ask Lumen can create the same proposals through a new agent tool. This is the "tool-calling + human approval" story the spec wants. +- **Money stays numeric on the wire.** Headlines carry `{0}` / `{1}` placeholders plus an `amounts` array, so the frontend formats them in the user's currency with `useMoney()`. + +**Tech Stack:** FastAPI, SQLAlchemy (Flask-SQLAlchemy models, `create_all` adds new tables), pytest with the `TestClient` pattern from `tests/test_api_profile.py`, and on the frontend Next.js and Playwright (the `signIn` fixture). + +**Spec:** `docs/superpowers/specs/2026-10-20-ui-revamp-design.md` §4 (Home table), §8 (new backend) and §12 (backlog items tagged Phase 3). It builds on `ui-revamp/phase2-home` (PR #92). + +## Global Constraints + +- **Honesty rule (spec anti-goal):** never invent a number. + - A ₹ "worth" appears only where money can plausibly come back. That means duplicates only. + - Price rises and spikes state facts, not savings. + - With nothing to report, `found` is empty, and the frontend keeps "Nothing needs you right now." +- **No LLM calls in any Home endpoint.** The draft budget is deterministic arithmetic. +- **Propose, then approve (Lumen's rule):** a draft never writes `budgets` directly. Only approving a `set_budget` proposal does. +- **Per-user scoping on every query** (`user_id == claims["sub"]`). This matches the existing routers. +- **Portable SQL:** transactions' `date` is TEXT on SQLite and DATE on Postgres. Load the user's rows and filter in Python, as `agent/tools.py::_get_overview` does. +- **New table via `create_all`:** `budgets` is new, so no `_ADDED_COLUMNS` entry is needed. If Alembic is used in deploy, also add `alembic/versions/002_budgets.py`; follow `001_unified_schema.py`'s style. +- **Timestamps on the wire are ISO UTC with `Z`.** Use `.isoformat() + "Z"` for naive UTC `datetime`s. +- **Git:** no "claude" in branch names and no attribution lines. Open one PR (base `ui-revamp/phase2-home` until #92 merges). Keep `docs/CODEBASE-GUIDE.md` current, tick spec §12 items, and add a STORY entry. + +## Decisions this plan makes (owner may override) + +- **The brief stays composed on the client.** Phase 2's tested `briefSentence` writes the pace sentence. The server supplies `found[0]`, and the client appends "The one thing worth a look: {headline}." This avoids two implementations of the same sentence. +- **The overall budget is the existing `User.monthly_budget`,** set during onboarding. Per-category limits go in the new `budgets` table. The Budget card shows the overall figure, plus up to 3 categories closest to their limit. +- **Draft budget per category:** + - Take the mean of the last 3 *complete* calendar months' spend. + - Round it up: `ceil(avg / 500) * 500` when the average is at least 500, otherwise `ceil(avg / 10) * 10`. + - Skip categories under ₹300 a month. + - Create at most 8 proposals, largest first. + - A new draft rejects the user's still-pending `set_budget` proposals first, with the audit `detail` `{"superseded": true}`, so drafts never pile up. +- **Thresholds for "Lumen found":** + - **Duplicate:** the same normalised vendor and the same amount (±₹1) within 1 day, in the last 30 days. + - **Spike:** a category's month-to-date spend is ≥30% above the same day last month **and** at least `max(₹1,000, 5% of last month's total)` above it. + - **Price rise:** a recurring charge with `price_change.percent ≥ 5` and a last charge within 45 days. + - **Review:** count > 0. + - **Order:** duplicate → review → spike → price rise. Return the top 3 plus `more`. +- **Activity is built from existing tables. There is no event table.** + - **Statement imports:** rows with `source == "statement"`, grouped by `created_at` to the minute, give "Imported a statement · {n} rows". + - **Receipts:** other transactions give "Read a receipt: {vendor} {0}". + - **Proposal decisions:** `AuditEvent` rows with actions `proposal_approved` / `proposal_rejected` give "You approved: {label}" or "You rejected: {label}". The label map is `set_budget` → "a {category} budget" and `update_category` → "a category change"; anything else becomes "a suggestion". + - The newest 6 are returned. Events from the agent, and from superseded rejections, are excluded. +- **The draft-budget review UI is a dialog on Home.** It lists the pending `set_budget` proposals using the existing `components/ask/proposal-card.tsx`, plus an "Approve all" button. A Budget page comes in Phase 5. + +## Wire formats (produced by Tasks 2–4, consumed by Task 5) + +```jsonc +// GET /api/home/summary +{ "success": true, + "found": [ { "kind": "duplicate|review|spike|price_rise", "tag": "DUPLICATES|RECEIPTS|SPENDING|SUBSCRIPTIONS", + "headline": "Possible double charge: Croma {0}", "detail": "Charged on 14 and 15 Oct.", + "amounts": [2499], "worth": 2499 | null, "href": "/receipts?tab=review" | null } ], + "more": 0, + "worth_total": 2499, // sum of non-null worth, 0 if none + "activity": [ { "kind": "statement_import|receipt|decision", "text": "Read a receipt: Croma {0}", + "amounts": [2499], "at": "2026-10-21T06:00:00Z" } ] } + +// GET /api/budget +{ "success": true, "month": "2026-10", "monthly_budget": 28000 | null, "spent": 18412, + "categories": [ { "category": "Food", "limit": 8000, "spent": 7480 } ] } // sorted by spent/limit desc + +// PUT /api/budget body: { "monthly_budget"?: number|null, "categories"?: [{ "category": str, "limit": number|null }] } +// limit null deletes that category's row; returns the GET shape + +// POST /api/budget/draft -> { "success": true, "proposals": [ <_proposal_dict>, ... ] } // may be [] +// GET /api/review?status=flagged&count=1 -> { "success": true, "count": 3 } +``` + +## Review Focus + +1. **A user with no transactions, or only one month of history:** `draft` returns `[]` (it doesn't divide by zero, and doesn't propose ₹0 limits), the summary returns `found: []`, and the budget `spent` is 0. Tests: `test_draft_needs_history`, `test_summary_empty_user`. +2. **Another user's data never leaks:** + - Summary, budget and draft only read the caller's rows. + - Approving someone else's `set_budget` proposal gives 404, which already holds in `_decide`. + - Tests: `test_summary_is_per_user`, `test_budget_is_per_user`. +3. **Double approval and races:** approving the same `set_budget` twice gives 409 and leaves one budget row. A unique `(user_id, category)` constraint plus an upsert keeps it to one row. Test: `test_set_budget_approve_twice`. +4. **Category spelling:** "food", "Food " and "FOOD" are the same category for spikes, drafts and budget `spent`. Normalise with strip and case-fold for matching, and display the most common spelling. Test: `test_categories_normalise`. +5. **Refunds and negative amounts:** they don't create "spikes", and a refund-heavy previous month doesn't invert the comparison. This also closes the backlog item: `sameDayDelta` guards `then <= 0`. Tests: `test_spike_ignores_refund_month` and Task 5 unit test `sameDayDelta guards a non-positive previous total`. + +## File map + +| File | Responsibility | +|---|---| +| `backend/models/__init__.py` | `class Budget` (table `budgets`: `id` str36 pk, `user_id` str36 indexed, `category` str, `monthly_limit` float, `updated_at` datetime; `UniqueConstraint(user_id, category)`) | +| `backend/ai/budget.py` (new) | `draft_budget(txns: list[dict], today: date) -> list[dict]` (category, limit, basis) and `month_spend_by_category(txns, today) -> dict[str, float]` | +| `backend/ai/home_signals.py` (new) | `find_signals(txns, today, review_count, recurring) -> list[dict]` and `activity(txns, audit_events, now) -> list[dict]` | +| `backend/api/budget.py` (new) | `GET/PUT /api/budget`, `POST /api/budget/draft` | +| `backend/api/home.py` (new) | `GET /api/home/summary` | +| `backend/api/review.py` | `count` query param on the list | +| `backend/api/agent.py` (`_decide`) | `set_budget` apply branch | +| `backend/agent/tools.py` | `set_budget` in `ProposeActionArgs.type`, plus a `draft_budget` tool that creates the same proposals | +| `backend/asgi.py` | Register the `home` and `budget` routers | +| `backend/tests/test_budget.py`, `test_home_signals.py`, `test_api_home.py`, `test_api_budget.py` (new) | Tests | +| `frontend/src/lib/api/client.ts` | `homeApi.summary()`, `budgetApi.get/put/draft()`, `reviewApi.count()` | +| `frontend/src/app/(app)/home/*` | Wire the summary and budget into the cards; add a draft-budget dialog | +| `frontend/src/components/app-sidebar.tsx` | Badge uses `reviewApi.count()` | +| `frontend/e2e/fixtures.ts`, `e2e/home.spec.ts`, `e2e/home-math.spec.ts` | Stubs and tests | + +--- + +### Task 1: `Budget` model + `draft_budget` (pure) + +**Files:** `models/__init__.py`, `ai/budget.py` (new), `tests/test_budget.py` (new) + +**Interfaces (produced):** +- `Budget` model, as described in the file map. +- `month_spend_by_category(txns: list[dict], year: int, month: int) -> dict[str, float]`. + - Keys are the display spelling (the most common original spelling). + - Matching uses `category.strip().casefold()`. `None` becomes "Uncategorised". + - Negative amounts are included in totals, so refunds net out. +- `draft_budget(txns: list[dict], today: date) -> list[dict]` returns `[{"category", "limit", "basis": {"months": [..3 "YYYY-MM"], "average": float}}]`, sorted by `limit` descending, max 8. + - It uses the 3 complete months before `today`'s month. + - It returns `[]` if fewer than 2 of those months have any spend. + +- [ ] **Step 1: Write the failing tests:** + - `test_draft_rounds_up_to_500`: Food averages 7,120 over Jul, Aug and Sep, so limit = 7,500. + - `test_draft_small_values_round_to_10`: an average of 404 gives a limit of 410. + - `test_draft_skips_under_300`. + - `test_draft_caps_at_8_largest_first`. + - `test_draft_needs_history`: only last month has data, so `[]`. + - `test_draft_ignores_current_month`. + - `test_categories_normalise`: "food", "Food " and "FOOD" form one bucket, displayed as the most common spelling. + - `test_budget_unique_per_category`: inserting two `Budget` rows with the same user and category raises `IntegrityError` (SQLite engine, `create_all`). +- [ ] **Step 2: Run** `pytest tests/test_budget.py -q`. The expected result is FAIL with an import error. +- [ ] **Step 3: Implement** according to the Interfaces above. +- [ ] **Step 4: Run** the tests again. All should pass. +- [ ] **Step 5: Commit** with `feat: budgets table and deterministic draft budget`. + +### Task 2: Budget API + `set_budget` proposals (approve applies) + agent tool + +**Files:** `api/budget.py` (new), `api/agent.py` (`_decide`), `agent/tools.py`, `asgi.py`, `tests/test_api_budget.py` (new) + +**Interfaces:** +- `router = APIRouter(prefix="/api/budget")`, with `get_engine` as a dependency (mirror `api/profile.py`). + - **`GET ""`** returns the wire shape above. `spent` is the total for the current calendar month, from `month_spend_by_category`. `categories` lists the user's `Budget` rows with this month's spend. + - **`PUT ""`** validates `monthly_budget >= 0` or `null` and each `limit > 0` or `null`. It sets `User.monthly_budget`, upserts or deletes `Budget` rows, and returns the GET shape. Invalid input gives a 422. + - **`POST "/draft"`** runs `draft_budget` over the user's transactions, rejects that user's pending `set_budget` proposals (audit `{"superseded": true}`), and creates one `Proposal(type="set_budget", target=, payload={"limit": n, "basis": {...}}, risk="low", reason="About ₹{avg} a month over {Jul–Sep}; rounded up.", evidence=[])` per line. It returns them through `_proposal_dict`. Rate limit: `"10 per minute"`. +- **`_decide` approve branch for `set_budget`:** inside the same session, upsert `Budget(user_id, category=p.target)` with `monthly_limit=payload["limit"]` and return `applied={"category", "limit"}`. +- **`agent/tools.py`:** + - Add `"set_budget"` to `ProposeActionArgs.type`. + - Add a tool `draft_budget(NoArgs)`, described as "Draft a monthly budget from the last 3 months; creates set_budget proposals the user approves". It calls the same helper as `POST /draft`. Factor that helper out as `api.budget.create_draft(engine, user_id, today) -> list[Proposal dict]`. + - Register the tool wherever the other tools are listed. + +- [ ] **Step 1: Write the failing tests** in `test_api_budget.py` (fixture like `test_api_profile.py::client`, including the `agent` router and `get_agent_deps` override): + - **`test_get_budget_empty`:** `monthly_budget` is null, `spent` is 0 and `categories` is `[]`. + - **`test_put_then_get`:** set the budget to 28000 with Food at 8000, then GET shows Food's spent for this month. + - **`test_put_null_deletes_category`.** + - **`test_put_rejects_negative`:** returns 422. + - **`test_draft_creates_pending_set_budget_proposals`:** seed 3 months of rows, POST, and get N proposals of type `set_budget` with status pending. + - **`test_redraft_supersedes_pending`:** a second POST leaves only the new proposals pending, and the old ones are rejected. + - **`test_approve_set_budget_upserts_row`:** after approval, GET `/api/budget` shows the category limit. + - **`test_set_budget_approve_twice`:** the second approve gives 409 and there is exactly one `budgets` row. + - **`test_budget_is_per_user`:** user B's GET doesn't see user A's rows, and B approving A's proposal gives 404. + - **`test_agent_tool_draft_budget`:** calling the tool function with a `ToolContext` creates the same pending proposals. +- [ ] **Step 2: Run them.** The expected result is FAIL with 404s or import errors. +- [ ] **Step 3: Implement** according to the Interfaces above. +- [ ] **Step 4: Run** `pytest -q`. The whole backend suite should pass, with the CI env vars `OPENROUTER_API_KEY=test-key SECRET_KEY=test-secret-key-for-ci-only SUPABASE_URL=https://example.supabase.co`. +- [ ] **Step 5: Commit** with `feat: budget API and set_budget proposals (draft, approve, agent tool)`. + +### Task 3: Home signals + activity (pure) and `GET /api/home/summary` + +**Files:** `ai/home_signals.py` (new), `api/home.py` (new), `asgi.py`, `tests/test_home_signals.py` and `tests/test_api_home.py` (new) + +**Interfaces:** +- `find_signals(txns: list[dict], today: date, review_count: int, recurring: list[dict]) -> list[dict]` returns ordered items in the wire format. Each kind's headline: + - **duplicate:** `"Possible double charge: {vendor} {0}"`, detail `"Charged on {d1} and {d2}."`, `amounts=[amount]`, `worth=amount`, `href=None`. + - **review:** `"{n} receipt(s) need a glance"`, detail `"Lumen wasn't sure about these. Nothing is saved until you approve."`, `href="/receipts?tab=review"`. + - **spike:** `"{Category} is up {pct}%"`, detail `"{0} so far vs {1} by this point last month."`, `amounts=[now, then]`, `href="/spending"`. + - **price_rise:** `"{vendor} went up {pct}%"`, detail `"{0} → {1} a {cadence}."`, `href="/bills"`. +- `activity(txns: list[dict], events: list[dict], now: datetime) -> list[dict]`, according to the Decisions above. `events` are dicts with `action`, `created_at` and `type`/`target` of the proposal. +- `GET /api/home/summary` loads the caller's transactions, flagged review count, recurring list (`find_recurring(txns, today)`), and the last 50 `AuditEvent`s joined to `Proposal` for type and target. It returns `{"success": True, "found": items[:3], "more": max(0, len(items)-3), "worth_total": …, "activity": …}`. Rate limit: default. + +- [ ] **Step 1: Write the failing tests:** + - **`test_home_signals.py`:** + - `test_duplicate_same_vendor_amount_within_a_day` + - `test_no_duplicate_two_days_apart` + - `test_spike_needs_30pct_and_floor`: +35% on ₹600 isn't a spike, +35% on ₹5,000 is. + - `test_spike_ignores_refund_month` + - `test_price_rise_from_recurring` + - `test_order_and_review_item` + - `test_activity_groups_statement_rows`: 42 rows in one minute give one "Imported a statement · 42 rows". + - `test_activity_labels_decisions_and_skips_superseded` + - `test_activity_times_are_utc_z` + - **`test_api_home.py`:** + - `test_summary_empty_user`: returns `found []`, `worth_total 0` and `activity []`. + - `test_summary_reports_duplicate_and_worth`. + - `test_summary_is_per_user`. + - `test_summary_requires_auth`: 401. +- [ ] **Step 2: Run them.** The expected result is FAIL. +- [ ] **Step 3: Implement** according to the Interfaces above. Normalise vendors with `ai.recurring`'s `_norm` (import it; don't copy it). +- [ ] **Step 4: Run** `pytest -q`. The whole suite should pass. +- [ ] **Step 5: Commit** with `feat: Home summary endpoint (Lumen found signals and activity feed)`. + +### Task 4: Review count + +**Files:** `api/review.py`, `tests/test_api_review.py` + +- [ ] **Step 1:** Write `test_list_count_only`: with 3 flagged items, `GET /api/review?status=flagged&count=1` returns `{"success": true, "count": 3}` and has no `items` key. +- [ ] **Step 2:** Run it. The expected result is FAIL, because `items` comes back. +- [ ] **Step 3:** Add a `count: bool = False` param. When it's true, run `select(func.count())` with the same filters. +- [ ] **Step 4:** Run `pytest tests/test_api_review.py -q`. It should pass. +- [ ] **Step 5:** Commit with `feat: count-only review queue query for the sidebar badge`. + +### Task 5: Frontend wiring + +**Files:** +- `lib/api/client.ts`, `app-sidebar.tsx`, `lib/home-math.ts` (backlog fix) +- `home/use-home-data.ts`, `home/homeContent.tsx`, `home/_components/{lumen-found,budget-card,activity-card,calendar-card}.tsx` +- A new `home/_components/draft-budget-dialog.tsx` +- `e2e/fixtures.ts`, `e2e/home.spec.ts`, `e2e/home-math.spec.ts` + +**Interfaces:** +- **`client.ts`:** + - `homeApi.summary(): Promise`, using `cached`, with key `/api/home/summary`. + - `budgetApi.get()`, `budgetApi.put(body)` and `budgetApi.draft()`. Non-GET calls already clear `cached`. + - `reviewApi.count(): Promise`. + - Export the types `HomeSummary`, `FoundItem`, `ActivityItem` and `BudgetState`, matching the wire formats above. +- **`fill(text: string, amounts: number[], money: (n: number) => string): string`** in `lib/home-math.ts` replaces `{i}` with `money(amounts[i])`. Unit-test it. +- **`useHomeData`:** the second effect ("once") also loads `homeApi.summary()` and `budgetApi.get()` through `allSettled`. `HomeData` gains `summary: HomeSummary | null` and `budget: BudgetState | null`, and loses the transaction-derived activity. `toReview` comes from `summary.found` (the review item) or 0. The separate review list call is removed. +- **`LumenFound`:** + - The header reads "Lumen found", then "{n} things worth a look". When `worth_total > 0`, it adds " · {money(worth_total)} you may get back". + - It shows up to 3 tiles (tag, serif headline from `fill`, detail from `fill`, and a link "SEE →" when there's an `href`). + - It keeps the empty copy. +- **Brief:** when `summary.found[0]` exists, append `" The one thing worth a look: " + fill(headline).replace(/\.$/, "") + "."`. +- **`BudgetCard`:** + - With `monthly_budget`: "₹spent of ₹budget", a progress bar (`bg-good`, or `bg-bad` when over), and "₹left · N days · ~₹/day". N days is days left in the month including today. When over budget, it says "₹X over" instead. + - It then lists up to 3 category rows, "Food ₹7,480 / ₹8,000". + - Without `monthly_budget` but with categories: it shows the category rows only. + - With neither: the Phase 2 empty state. +- **`DraftBudgetPromo`:** + - The button reads "DRAFT BUDGET" and is enabled. On click, it calls `budgetApi.draft()` and opens `DraftBudgetDialog`. + - The dialog lists the returned proposals with the existing `ProposalCard`, using its approve and reject handlers through `agentApi.decide`; check that function's name in `client.ts`. It has an "Approve all" button. + - On close, refetch `budgetApi.get()`. + - If there are no proposals, the dialog says "Not enough history yet. Lumen needs about two months of spending to draft a budget." + - The promo hides when the user already has category budgets. +- **`ActivityCard`:** renders `summary.activity`, each item as `fill(text)` plus `relativeTime(at)`. It keeps the empty copy. +- **`CalendarCard`:** `kShort` uses the user's currency symbol. Derive it once from `money(0)` by stripping digits and separators, and keep the `k` suffix. This is a backlog item. +- **`sameDayDelta`:** guard `then <= 0`. This is a backlog item. +- **Sidebar badge:** `reviewApi.count()`. +- **Fixture:** `signIn(page, { home: { summary?, budget?, draft? } })` stubs `/api/home/summary`, `/api/budget` (GET/PUT), `/api/budget/draft`, and `/api/review?count=1` (from `reviewCount`). The existing proposal approve stub returns `{ success: true, proposal: {...status:"approved"}, applied: {...} }`. + +- [ ] **Step 1: Write the failing tests:** + - **`home-math`:** + - `fill replaces placeholders with money` + - `sameDayDelta guards a non-positive previous total`: `[5]` vs `[-10]` gives null. + - **`home`:** + - **`lumen found shows server signals and worth`:** a summary with a duplicate (worth 2499) and a spike gives the header "2 things worth a look · ₹2,499 you may get back" and two tiles. + - **`brief adds the one thing worth a look`.** + - **`budget card shows progress against the monthly budget`:** budget 28000 and spent 18412 give "₹18,412 of ₹28,000" and "₹9,588 left". + - **`budget card says when over`.** + - **`draft budget opens proposals and approve all applies them`:** + 1. The draft stub returns 2 `set_budget` proposals. + 2. Click DRAFT BUDGET. The dialog shows 2 cards. + 3. Click Approve all. Both approve endpoints are called. + 4. Close the dialog. The budget is refetched. + - **`draft with no history explains why`.** + - **`activity comes from the summary`:** "Imported a statement · 42 rows" and "2h ago". + - **`calendar uses the user's currency`:** with a profile currency of USD, the cell shows "$2.2k". + - **`sidebar badge uses the count endpoint`:** the request to `/api/review` has `count=1`. +- [ ] **Step 2: Run** `npm run test:e2e`. The expected result is that the new tests FAIL. +- [ ] **Step 3: Implement** according to the Interfaces above. Remove the Phase 2 interim code paths that the summary replaces: transaction-derived activity, and the review list call on Home. +- [ ] **Step 4: Run** `npm run test:e2e`, `tsc`, `lint` and `build`. All should pass, and `/home` should stay static. +- [ ] **Step 5: Commit** with `feat: Home wired to summary and budget APIs; draft-budget dialog; badge via count`. + +### Task 6: Docs, review, ship + +- [ ] **Step 1:** Update the docs: + - `docs/CODEBASE-GUIDE.md`: the Home section (the interim notes become real sources), new API endpoints, the `budgets` table, `set_budget` proposals and the agent tool. + - Spec §12: tick the Phase 3 items. That covers the interim Home parts, `kShort`, `sameDayDelta`, the review count endpoint, and confirming that statement import writes before returning. It does: `api/statements.py` commits inside the request. + - `docs/direction/STORY.md`: one entry. +- [ ] **Step 2:** Run the whole-branch review on the most capable model, with this plan's Review Focus. Fix Critical and Important findings with RED→GREEN tests. Ledger the minors into spec §12. +- [ ] **Step 3:** Run the backend `pytest -q` and the frontend `npm run test:e2e`, `tsc`, `lint` and `build`. All should be green. Push `ui-revamp/phase3-home-backend` and open the PR. diff --git a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md index 2a1ec73..3ecf003 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -215,17 +215,23 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **Phase 6.** The e2e suite is local and Chromium-only. Add Firefox once Windows allows Playwright's build (`E2E_FIREFOX=1`), and consider running it in CI with the `signIn` fixture. *(Phase 0)* - [x] ~~**Phase 6.** `(app)/layout` reads the sidebar cookie on the server.~~ Not needed: the sidebar only renders client-side (behind the auth guard), so Phase 1 reads the cookie there and the app routes stay static. *(Phase 1)* - [ ] **Phase 5.** Spending and Bills both show the payment calendar and reminders cards in the interim. Remove the duplicate when the pages are redesigned. *(Phase 1 decision)* -- [ ] **Phase 3.** The sidebar's review badge downloads every flagged item just to count them. Add a count-only endpoint (e.g. `GET /api/review?status=flagged&count=1`). *(Phase 1 review)* +- [x] **Phase 3.** The sidebar's review badge downloads every flagged item just to count them. Add a count-only endpoint (e.g. `GET /api/review?status=flagged&count=1`). *(Phase 1 review)* - [ ] **Phase 5.** `/spending` stacks two old pages, so it has two `

`s and fetches the calendar and reminders twice. It goes away with the redesign. *(Phase 1 review)* - [ ] **Phase 6.** iOS Safari 15 and older don't support `overflow-x: clip`, so wide landing marquees could scroll sideways there. If that matters, add an `@supports not (overflow-x: clip)` fallback (not `hidden` on body). *(Phase 1 review)* - [ ] **Phase 4.** Ctrl/⌘+K checks `e.key === "k"`, so Caps Lock or Shift misses it. Use `e.code === "KeyK"`. *(Phase 1 review)* - [ ] **Phase 6.** The sidebar cookie expires after 7 days (shadcn default), so a collapsed rail reopens after a week away. Extend it to about a year. *(Phase 1 review)* -- [ ] **Phase 3.** Replace Home's interim parts: the brief (summary endpoint), Lumen found (subscriptions, spikes, duplicates), Activity (a real feed, including statement imports dated earlier than this month), and wire up Budget and the draft-budget promo. *(Phase 2)* +- [x] **Phase 3.** Replace Home's interim parts: the brief (summary endpoint), Lumen found (subscriptions, spikes, duplicates), Activity (a real feed, including statement imports dated earlier than this month), and wire up Budget and the draft-budget promo. *(Phase 2)* - [ ] **Later.** Home sub-tabs (Overview / This month / Year). The chart's range chips cover this for now. *(Phase 2 decision)* - [ ] **Phase 5.** The Export and Upload buttons on Home still use the old loud blue `Button` and the dark upload dialog. Restyle them with the dialogs. *(Phase 2)* -- [ ] **Phase 3.** The calendar's `kShort` hardcodes `₹`; use the user's currency symbol like `useMoney`. *(Phase 2 review)* -- [ ] **Phase 3.** `sameDayDelta` only guards a zero previous total. A refund-heavy start of month (negative running total) flips the brief's "lighter"/"heavier". Use `then <= 0`. *(Phase 2 review)* +- [x] **Phase 3.** The calendar's `kShort` hardcodes `₹`; use the user's currency symbol like `useMoney`. *(Phase 2 review)* +- [x] **Phase 3.** `sameDayDelta` only guards a zero previous total. A refund-heavy start of month (negative running total) flips the brief's "lighter"/"heavier". Use `then <= 0`. *(Phase 2 review)* - [ ] **Phase 6.** `CountUp` paints the final number for one frame before counting from 0. Start at 0 when it will animate. *(Phase 2 review)* - [ ] **Phase 6.** Measure each Home card's real content height against its `skeletonHeight` (the spend card's is estimated at about 270px vs 262px); the CLS test threshold of 0.01 can hide a small shift. *(Phase 2 review)* -- [ ] **Phase 3.** Confirm statement imports write rows before the POST returns. Otherwise a Home visit in that window caches stale totals for 5 minutes. *(Phase 2 review)* +- [x] **Phase 3.** Confirm statement imports write rows before the POST returns. Confirmed: `api/statements.py` commits inside the request. *(Phase 2 review)* +- [ ] **Later (owner request).** Make "Lumen found" and the budget draft smarter. A plain 3-month mean is skewed by one-off big purchases (a phone, a laptop). Ideas: a median or trimmed mean per category; treat single large purchases as one-offs and leave them out of the limit (or suggest a separate "planned purchases" line); use seasonality and income; let the agent (LLM) explain or adjust a draft line, while approval still stays with the user. Measure any new rule against an eval set before switching. *(Phase 3)* +- [ ] **Phase 4.** `_decide` (api/agent.py) checks `status == pending` without a conditional UPDATE. Two concurrent approvals on Postgres can both apply (duplicate audit lines), and two approvals for the same new category can return 500 on the unique constraint. Use the conditional `update(...).where(status=="pending")` pattern from `_save_invoice`. *(Phase 3 review)* +- [ ] **Phase 4.** Drafting a budget (HTTP or the agent tool) retires all pending `set_budget` proposals. A draft from Ask while Home's dialog is open makes that dialog's approvals return 409. Two quick drafts can leave two pending sets. Consider scoping by draft id and a per-user lock. *(Phase 3 review)* +- [ ] **Phase 4.** After approving draft lines, Home reloads only the budget. Also reload the summary so Activity shows "You approved…" at once. *(Phase 3 review)* +- [ ] **Phase 6.** `/api/home/summary` and `/api/budget` each scan all of a user's transactions in Python on every Home visit. Fine for a few thousand rows; filter by date in SQL (and cache) if a user grows past that. *(Phase 3 review)* +- [ ] **Phase 5.** Draft dialog: per-line buttons are named just "Approve" / "Skip". Add the category to their accessible names. The `basis.months` list always shows 3 months even when only 2 had spend. *(Phase 3 review)* - [ ] **Owner, manual.** In Firefox, check that the profile/avatar menu doesn't shift the Ask Lumen column, and click through the old pages on light in a demo session. *(Phase 0 PR #89)* diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index e1efc5f..b0f8da6 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -11,6 +11,13 @@ export type HomeFixture = { forecast?: { dates: string[]; values: number[] } | "fail"; delayMs?: number; forecastDelayMs?: number; + /** GET /api/home/summary; default: just the review item when reviewCount > 0 */ + summary?: { found: object[]; more?: number; worth_total?: number; activity?: object[] }; + /** GET/PUT /api/budget; default: nothing set */ + budget?: { monthly_budget: number | null; spent: number; categories: { category: string; limit: number; spent: number }[] }; + /** POST /api/budget/draft proposals; default [] */ + draft?: object[]; + currency?: string; }; export async function signIn(page: Page, opts: { reviewCount?: number; home?: HomeFixture } = {}): Promise { @@ -63,7 +70,23 @@ export async function signIn(page: Page, opts: { reviewCount?: number; home?: Ho const f = home.forecast; return route.fulfill({ json: { success: true, forecast: f ? { success: true, forecast: f } : { success: false } } }); } - if (path === "/api/review") return route.fulfill({ json: { items: queue } }); + if (path === "/api/review") { + if (url.searchParams.get("count") === "1") return route.fulfill({ json: { success: true, count: queue.length } }); + return route.fulfill({ json: { items: queue } }); + } + if (path === "/api/home/summary") { + const review = queue.length + ? [{ kind: "review", tag: "RECEIPTS", headline: `${queue.length} ${queue.length === 1 ? "receipt needs" : "receipts need"} a glance`, + detail: "Lumen wasn't sure about these. Nothing is saved until you approve.", amounts: [], worth: null, href: "/receipts?tab=review" }] + : []; + const s = home.summary ?? { found: review }; + return route.fulfill({ json: { success: true, more: 0, worth_total: 0, activity: [], ...s } }); + } + if (path === "/api/budget") + return route.fulfill({ json: { success: true, month: "", ...(home.budget ?? { monthly_budget: null, spent: 0, categories: [] }) } }); + if (path === "/api/budget/draft") return route.fulfill({ json: { success: true, proposals: home.draft ?? [] } }); + const proposal = path.match(/^\/api\/agent\/proposals\/([^/]+)\/(approve|reject)$/); + if (proposal) return route.fulfill({ json: { success: true, proposal: { id: proposal[1], status: `${proposal[2]}d` }, applied: null } }); const decided = path.match(/^\/api\/review\/([^/]+)\/(approve|reject)$/); if (decided) { const item = queue.find((q) => q.id === decided[1])!; @@ -75,7 +98,7 @@ export async function signIn(page: Page, opts: { reviewCount?: number; home?: Ho if (path === "/chat/suggestions") return route.fulfill({ json: { suggestions: [] } }); if (path === "/api/profile") return route.fulfill({ - json: { profile: { display_name: "E2E User", avatar_key: null, currency: "INR", onboarded: true, demo: false } }, + json: { profile: { display_name: "E2E User", avatar_key: null, currency: home.currency ?? "INR", onboarded: true, demo: false } }, }); return route.fulfill({ json: {} }); }); diff --git a/frontend/e2e/home-math.spec.ts b/frontend/e2e/home-math.spec.ts index bf33c7e..51047b8 100644 --- a/frontend/e2e/home-math.spec.ts +++ b/frontend/e2e/home-math.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { briefSentence, + fill, cumulative, heat, periods, @@ -124,3 +125,14 @@ test("relativeTime treats offset-less backend timestamps as UTC", () => { expect(relativeTime("2026-10-20T10:00:00Z", now)).toBe("2h ago"); expect(relativeTime("2026-10-20T15:30:00+05:30", now)).toBe("2h ago"); }); + +test("fill replaces placeholders with money", () => { + expect(fill("{0} so far vs {1} by this point last month.", [6750, 5000], money)).toBe( + "₹6,750 so far vs ₹5,000 by this point last month.", + ); + expect(fill("No amounts here", [], money)).toBe("No amounts here"); +}); + +test("sameDayDelta guards a non-positive previous total", () => { + expect(sameDayDelta([5], [-10], 0)).toBeNull(); +}); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index d747b3a..556cd58 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -169,16 +169,19 @@ test("bills card lists next three", async ({ page }) => { await expect(rows.first()).toContainText("Netflix"); }); -test("budget is honest until Phase 3", async ({ page }) => { +test("budget card is honest with no budget", async ({ page }) => { await openHome(page); await expect(page.getByText("No budget yet")).toBeVisible(); - await expect(page.getByRole("button", { name: "COMING SOON" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "DRAFT BUDGET" })).toBeEnabled(); }); -test("activity shows newest imports", async ({ page }) => { - await openHome(page, { txns: [{ ...tx("r", "Swiggy", thisMonth(1), 486), created_at: hoursAgo(2) }] }); +test("activity comes from the summary", async ({ page }) => { + await openHome(page, { + ...typical, + summary: { found: [], activity: [{ kind: "statement_import", text: "Imported a statement · 42 rows", amounts: [], at: hoursAgo(2) }] }, + }); + await expect(page.getByTestId("activity")).toContainText("Imported a statement · 42 rows"); await expect(page.getByTestId("activity")).toContainText("2h ago"); - await expect(page.getByTestId("activity")).toContainText("Added Swiggy"); }); test("side column order", async ({ page }) => { @@ -226,3 +229,85 @@ test("a forecast with no matching days draws no line", async ({ page }) => { await expect(spend(page).locator('[data-series="current"]')).toHaveCount(1); await expect(spend(page).locator('[data-series="forecast"]')).toHaveCount(0); }); + +const dupe = { kind: "duplicate", tag: "DUPLICATES", headline: "Possible double charge: Croma {0}", detail: "Charged on 14 Oct and 15 Oct.", + amounts: [2499], worth: 2499, href: null }; +const spike = { kind: "spike", tag: "SPENDING", headline: "Food is up 35%", detail: "{0} so far vs {1} by this point last month.", + amounts: [6750, 5000], worth: null, href: "/spending" }; + +test("lumen found shows server signals and worth", async ({ page }) => { + await openHome(page, { ...typical, summary: { found: [dupe, spike], worth_total: 2499 } }); + const found = page.getByTestId("lumen-found"); + await expect(found).toContainText("2 things worth a look · ₹2,499 you may get back"); + await expect(found.getByTestId("found-tile")).toHaveCount(2); + await expect(found).toContainText("Possible double charge: Croma ₹2,499"); + await expect(found).toContainText("₹6,750 so far vs ₹5,000 by this point last month."); +}); + +test("brief adds the one thing worth a look", async ({ page }) => { + await openHome(page, { ...typical, summary: { found: [spike] } }); + await expect(page.getByTestId("brief")).toContainText("The one thing worth a look: Food is up 35%."); +}); + +test("budget card shows progress against the monthly budget", async ({ page }) => { + await openHome(page, { ...typical, budget: { monthly_budget: 28000, spent: 18412, categories: [{ category: "Food", limit: 8000, spent: 7480 }] } }); + const card = page.getByTestId("budget-card"); + await expect(card).toContainText("₹18,412 of ₹28,000"); + await expect(card).toContainText("₹9,588 left"); + await expect(card).toContainText("Food"); + await expect(page.getByText("Let Lumen draft your budget")).toHaveCount(0); // has category budgets already +}); + +test("budget card says when over", async ({ page }) => { + await openHome(page, { ...typical, budget: { monthly_budget: 15000, spent: 18412, categories: [] } }); + await expect(page.getByTestId("budget-card")).toContainText("₹3,412 over"); +}); + +const draftLine = (id: string, target: string, limit: number) => ({ + id, type: "set_budget", target, risk: "low", reason: "Averaged a month over Jul–Sep, rounded up.", + payload: { limit, basis: { months: [], average: limit } }, status: "pending", created_at: null, +}); + +test("draft budget opens proposals and approve all applies them", async ({ page }) => { + await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)] }); + await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByTestId("draft-line")).toHaveCount(2); + await expect(dialog).toContainText("₹4,000"); + const approvals = [page.waitForRequest(/proposals\/p1\/approve/), page.waitForRequest(/proposals\/p2\/approve/)]; + await dialog.getByRole("button", { name: "Approve all" }).click(); + await Promise.all(approvals); + await expect(dialog.getByText("Approved")).toHaveCount(2); + const refetch = page.waitForRequest((r) => r.url().endsWith("/api/budget") && r.method() === "GET"); + await page.keyboard.press("Escape"); + await refetch; +}); + +test("draft with no history explains why", async ({ page }) => { + await openHome(page, { ...typical, draft: [] }); + await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); + await expect(page.getByRole("dialog")).toContainText("Not enough history yet"); +}); + +test("calendar uses the user's currency", async ({ page }) => { + await openHome(page, { txns: [tx("c", "Croma", thisMonth(4), 2210)], currency: "USD" }); + await expect(page.getByTestId("cal-day-4")).toContainText("$2.2k"); +}); + +test("sidebar badge uses the count endpoint", async ({ page }) => { + const counted = page.waitForRequest((r) => r.url().includes("/api/review") && r.url().includes("count=1")); + await openHome(page, typical, 2); + await counted; + await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("2"); +}); + +test("draft dialog ignores a double click", async ({ page }) => { + await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)] }); + let approvals = 0; + page.on("request", (r) => r.url().includes("/proposals/") && r.url().endsWith("/approve") && approvals++); + await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Approve all" }).dblclick(); + await expect(page.getByRole("dialog").getByText("Approved")).toHaveCount(2); + await page.waitForTimeout(300); + expect(approvals).toBe(2); +}); diff --git a/frontend/src/app/(app)/home/_components/activity-card.tsx b/frontend/src/app/(app)/home/_components/activity-card.tsx index 76a17a4..c6fb675 100644 --- a/frontend/src/app/(app)/home/_components/activity-card.tsx +++ b/frontend/src/app/(app)/home/_components/activity-card.tsx @@ -1,15 +1,12 @@ import Link from "next/link"; +import type { ActivityItem } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; -import { relativeTime, type Txn } from "@/lib/home-math"; +import { fill, relativeTime } from "@/lib/home-math"; import { HomeCard } from "./card"; -// Interim activity feed: the newest imports by when they arrived (Phase 3 may replace it with a real feed). -export function ActivityCard({ txns, now, loading }: { txns: Txn[]; now: Date; loading: boolean }) { +// What happened lately: statement imports, receipts read, your decisions (GET /api/home/summary). +export function ActivityCard({ items, now, loading }: { items: ActivityItem[]; now: Date; loading: boolean }) { const money = useMoney(); - const items = txns - .filter((t) => t.created_at) - .sort((a, b) => b.created_at!.localeCompare(a.created_at!)) - .slice(0, 5); return ( {items.length === 0 ? ( @@ -21,12 +18,10 @@ export function ActivityCard({ txns, now, loading }: { txns: Txn[]; now: Date; l to get started.

) : ( - items.map((t) => ( -
- - Added {t.vendor_name ?? "a transaction"} {money(t.total_amount ?? 0)} - - {relativeTime(t.created_at!, now)} + items.map((a, i) => ( +
+ {fill(a.text, a.amounts, money)} + {relativeTime(a.at, now)}
)) )} diff --git a/frontend/src/app/(app)/home/_components/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx index 2895abf..b8bf4ef 100644 --- a/frontend/src/app/(app)/home/_components/budget-card.tsx +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -1,16 +1,102 @@ +"use client"; + +import { useRef, useState } from "react"; +import { agentApi, budgetApi, type BudgetState, type Proposal } from "@/lib/api/client"; +import { useMoney } from "@/lib/format"; +import { toast } from "@/lib/toast"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; import { HomeCard } from "./card"; -// Budgets arrive in Phase 3 (model + an AI draft you approve line by line). Until then: an honest empty state. -export function BudgetCard({ monthName }: { monthName: string }) { +// Overall budget = the profile's monthly budget (onboarding); per-category limits come from approved set_budget +// proposals or PUT /api/budget. Nothing here is invented: with neither, the card says so. +export function BudgetCard({ budget, monthName, today, loading }: { budget: BudgetState | null; monthName: string; today: Date; loading: boolean }) { + const money = useMoney(); + const total = budget?.monthly_budget ?? null; + const cats = (budget?.categories ?? []).slice(0, 3); + const spent = budget?.spent ?? 0; + const daysLeft = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() - today.getDate() + 1; + return ( - -

No budget yet

-

Lumen can draft one from your last 3 months.

+ + {total ? ( + <> +
+ {money(spent)} of {money(total)} +
+
+ total ? "bg-bad" : "bg-good")} style={{ width: `${Math.min(100, (spent / total) * 100)}%` }} /> +
+

+ {spent > total + ? `${money(spent - total)} over` + : `${money(total - spent)} left · ${daysLeft} ${daysLeft === 1 ? "day" : "days"} · ~${money((total - spent) / daysLeft)}/day`} +

+ + ) : cats.length === 0 ? ( + <> +

No budget yet

+

Lumen can draft one from your last 3 months.

+ + ) : null} + {cats.length > 0 && ( +
+ {cats.map((c) => ( +
+ {c.category} + c.limit && "text-bad")}> + {money(c.spent)} / {money(c.limit)} + +
+ ))} +
+ )}
); } -export function DraftBudgetPromo() { +type Line = Proposal & { decided?: "approve" | "reject" }; + +// "Let Lumen draft your budget": files set_budget proposals (POST /api/budget/draft) and lets you approve each line. +export function DraftBudgetPromo({ onChanged }: { onChanged: () => void }) { + const money = useMoney(); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [lines, setLines] = useState(null); + const [deciding, setDeciding] = useState(false); + const inFlight = useRef(false); // a ref, so a double click is blocked before React re-renders + + async function draft() { + setBusy(true); + try { + setLines(await budgetApi.draft()); + setOpen(true); + } catch { + toast.error("Couldn't draft a budget right now."); + } finally { + setBusy(false); + } + } + + async function decide(ids: string[], decision: "approve" | "reject") { + if (inFlight.current) return; + inFlight.current = true; + setDeciding(true); + try { + for (const id of ids) { + await agentApi.decide(id, decision); + setLines((ls) => ls?.map((l) => (l.id === id ? { ...l, decided: decision } : l)) ?? ls); + } + } catch { + toast.error("Couldn't save that decision."); + } finally { + inFlight.current = false; + setDeciding(false); + } + } + + const pending = (lines ?? []).filter((l) => !l.decided).map((l) => l.id); + return (

Let Lumen draft your budget

@@ -19,12 +105,67 @@ export function DraftBudgetPromo() {

+ { + setOpen(o); + if (!o) onChanged(); + }} + > + + Your draft budget + Each line is a monthly limit. Nothing is saved until you approve it. + {lines && lines.length === 0 ? ( +

+ Not enough history yet. Lumen needs about two months of spending to draft a budget. +

+ ) : ( + <> +
+ {lines?.map((l) => ( +
+
+

{l.target}

+

{l.reason}

+
+ {money(Number(l.payload.limit))} + + {l.decided ? ( + + {l.decided === "approve" ? "Approved" : "Skipped"} + + ) : ( + <> + + + + )} + +
+ ))} +
+ + + )} +
+
); } diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx index 85013b2..f4da6f4 100644 --- a/frontend/src/app/(app)/home/_components/calendar-card.tsx +++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx @@ -2,12 +2,14 @@ import { useMoney } from "@/lib/format"; import { calendarBuckets, heat, parseDay, type Period, type Txn } from "@/lib/home-math"; import { HomeCard } from "./card"; -const kShort = (n: number) => (n >= 1000 ? `₹${(n / 1000).toFixed(1)}k` : `₹${Math.round(n)}`); +// "₹2.2k" in the user's own currency: take the symbol from how useMoney prints zero +const kShort = (n: number, symbol: string) => (n >= 1000 ? `${symbol}${(n / 1000).toFixed(1)}k` : `${symbol}${Math.round(n)}`); const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" }); // This month as a heat grid (darker = more spent) beside the latest transactions. export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: Period; loading: boolean }) { const money = useMoney(); + const symbol = money(0).replace(/[\d.,\s]/g, ""); const days = calendarBuckets(txns, period); const max = Math.max(...days.map((d) => d.amount), 0); const lead = (period.start.getDay() + 6) % 7; // Monday first @@ -30,7 +32,7 @@ export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: P style={h ? { background: `color-mix(in srgb, var(--primary) ${Math.round(h * 100)}%, transparent)`, color: h > 0.5 ? "#fff" : undefined } : undefined} > {day} - {amount > 0 && {kShort(amount)}} + {amount > 0 && {kShort(amount, symbol)}}
); })} diff --git a/frontend/src/app/(app)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx index 75a3910..8c1e987 100644 --- a/frontend/src/app/(app)/home/_components/lumen-found.tsx +++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx @@ -1,28 +1,42 @@ import Link from "next/link"; +import type { HomeSummary } from "@/lib/api/client"; +import { useMoney } from "@/lib/format"; +import { fill } from "@/lib/home-math"; import { HomeCard } from "./card"; -// "Lumen found": things worth your attention. Phase 2 has one real source (the review queue); Phase 3 adds -// subscriptions, spending spikes and duplicate charges, so the tile grid is already three wide. -export function LumenFound({ toReview, loading }: { toReview: number; loading: boolean }) { +// "Lumen found": duplicates, receipts to review, spending spikes and price rises (GET /api/home/summary, rule-based). +// A money "worth" is only claimed for things you may get back (duplicates). +export function LumenFound({ summary, loading }: { summary: HomeSummary | null; loading: boolean }) { + const money = useMoney(); + const found = summary?.found ?? []; + const total = found.length + (summary?.more ?? 0); return ( - {toReview > 0 ? ( -
-
-
Receipts
-

- {toReview} {toReview === 1 ? "receipt needs" : "receipts need"} a glance -

-

- Lumen wasn't sure about these. Nothing is saved until you approve. -

- - Review → - -
-
- ) : ( + {found.length === 0 ? (

Nothing needs you right now.

+ ) : ( + <> +

+ {total} {total === 1 ? "thing" : "things"} worth a look + {summary!.worth_total > 0 && ( + · {money(summary!.worth_total)} you may get back + )} +

+
+ {found.map((f, i) => ( +
+
{f.tag}
+

{fill(f.headline, f.amounts, money)}

+

{fill(f.detail, f.amounts, money)}

+ {f.href && ( + + {f.kind === "review" ? "Review →" : "See →"} + + )} +
+ ))} +
+ )}
); diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index 2a07975..4962a8b 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -5,8 +5,9 @@ import { useSearchParams } from "next/navigation"; import { useProfile } from "@/components/profile/profile-provider"; import { DialogDemo } from "@/components/uploadDialog"; import { ExportDialog } from "@/components/exportDialog"; +import type { FoundItem } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; -import { briefSentence, cumulative, dayIndex, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math"; +import { briefSentence, cumulative, dayIndex, fill, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math"; import { Brief } from "./_components/brief"; import { ActivityCard } from "./_components/activity-card"; import { BillsCard } from "./_components/bills-card"; @@ -19,6 +20,11 @@ import { useHomeData, type Reminder } from "./use-home-data"; const partOfDay = (h: number) => (h < 12 ? "morning" : h < 17 ? "afternoon" : "evening"); const monthName = (d: Date) => d.toLocaleString("en-IN", { month: "long" }); +// The brief's second sentence: the top "Lumen found" item, if there is one. +function worthALook(item: FoundItem | undefined, money: (n: number) => string) { + return item ? ` The one thing worth a look: ${fill(item.headline, item.amounts, money).replace(/\.$/, "")}.` : ""; +} + export function nextBill(reminders: Reminder[], today: Date) { return reminders .filter((r) => r.type === "upcoming" && parseDay(r.date) >= today) @@ -53,7 +59,7 @@ export default function HomeContent() { nextBill: bill ? { vendor: bill.vendor, date: parseDay(bill.date) } : undefined, money: (n) => money(n), today, - }); + }) + worthALook(data.summary?.found[0], money); const firstName = profile?.display_name?.split(" ")[0]; const chart = periods(range, today); @@ -88,14 +94,15 @@ export default function HomeContent() { today={today} loading={data.status === "loading"} /> - +
- + - - + {/* once you have category budgets, the promo has done its job */} + {!data.budget?.categories.length && } +
diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts index f0613f6..19590de 100644 --- a/frontend/src/app/(app)/home/use-home-data.ts +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { aiAnalyticsApi, reviewApi, transactionApi } from "@/lib/api/client"; +import { useCallback, useEffect, useState } from "react"; +import { aiAnalyticsApi, budgetApi, homeApi, transactionApi, type BudgetState, type HomeSummary } from "@/lib/api/client"; import { periods, type Range, type Txn } from "@/lib/home-math"; export type Reminder = { id: string | number; vendor: string; amount: number | null; date: string; type: string; category?: string }; @@ -14,7 +14,9 @@ export type HomeData = { prevMonthTxns: Txn[]; reminders: Reminder[]; forecast: Forecast; - toReview: number; + summary: HomeSummary | null; + budget: BudgetState | null; + reloadBudget: () => void; }; const ymd = (d: Date) => @@ -43,10 +45,10 @@ const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fu // A failed call empties its card instead of breaking the page. Three independent loads: // - transactions, per chart range (skeletons only on the very first load; later range changes keep the old // numbers until the new ones arrive, so a click on the chart never blanks the brief or another card) -// - reminders + review count, once +// - reminders, the Home summary (Lumen found + activity) and the budget, once // - the forecast, once, numbers only (no LLM text); it never holds up the page, the dotted line just appears type Txns = Pick; -type Extras = Pick; +type Extras = Pick; export function useHomeData(range: Range, today: Date): HomeData { const [txns, setTxns] = useState(null); @@ -81,9 +83,10 @@ export function useHomeData(range: Range, today: Date): HomeData { let live = true; Promise.allSettled([ aiAnalyticsApi.getReminders(31).then((r: { reminders?: Reminder[] }) => r.reminders ?? []), - reviewApi.list().then((items) => items.length), - ]).then(([reminders, toReview]) => { - if (live) setExtras({ reminders: value(reminders, []), toReview: value(toReview, 0) }); + homeApi.summary(), + budgetApi.get(), + ]).then(([reminders, summary, budget]) => { + if (live) setExtras({ reminders: value(reminders, []), summary: value(summary, null), budget: value(budget, null) }); }); aiAnalyticsApi .getForecast(31, false) @@ -96,6 +99,10 @@ export function useHomeData(range: Range, today: Date): HomeData { }; }, [day]); + const reloadBudget = useCallback(() => { + budgetApi.get().then((budget) => setExtras((e) => (e ? { ...e, budget } : e)), () => {}); + }, []); + return { status: txns && extras ? "ready" : "loading", txns: txns?.txns ?? [], @@ -103,7 +110,9 @@ export function useHomeData(range: Range, today: Date): HomeData { monthTxns: txns?.monthTxns ?? [], prevMonthTxns: txns?.prevMonthTxns ?? [], reminders: extras?.reminders ?? [], - toReview: extras?.toReview ?? 0, + summary: extras?.summary ?? null, + budget: extras?.budget ?? null, + reloadBudget, forecast, }; } diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 2af97cd..0231ac8 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -62,7 +62,7 @@ export function AppSidebar(props: React.ComponentProps) { // refreshed on navigation and after each review decision (which happens without leaving /receipts) React.useEffect(() => { - const refresh = () => reviewApi.list().then((items) => setToReview(items.length), () => setToReview(0)); + const refresh = () => reviewApi.count().then(setToReview, () => setToReview(0)); void refresh(); return eventBus.on(EVENTS.REVIEW_DECIDED, refresh); }, [pathname]); diff --git a/frontend/src/components/ask/proposal-card.tsx b/frontend/src/components/ask/proposal-card.tsx index 78f6637..8e5af04 100644 --- a/frontend/src/components/ask/proposal-card.tsx +++ b/frontend/src/components/ask/proposal-card.tsx @@ -11,6 +11,7 @@ const TITLES: Record = { mark_paid: "Mark this as paid?", update_category: "Change the category?", follow_up_vendor: "Follow up with this vendor?", + set_budget: "Set this monthly budget?", }; const RISK: Record = { @@ -33,7 +34,8 @@ export function ProposalCard({ proposal }: { proposal: Proposal }) { const [decided, setDecided] = useState<"approve" | "reject" | null>(null); const [busy, setBusy] = useState(false); const risk = RISK[proposal.risk] ?? RISK.medium; - const details = Object.entries(proposal.payload); + // nested values (e.g. a set_budget's "basis") aren't readable as chips + const details = Object.entries(proposal.payload).filter(([, v]) => v === null || typeof v !== "object"); async function decide(decision: "approve" | "reject") { setBusy(true); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 80fe037..509405f 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -720,6 +720,47 @@ export interface Proposal { created_at: string | null; } +// Home (UI revamp Phase 3). Text carries {0}/{1} placeholders filled from `amounts` in the user's currency (fill()). +export interface FoundItem { + kind: "duplicate" | "review" | "spike" | "price_rise"; + tag: string; + headline: string; + detail: string; + amounts: number[]; + worth: number | null; + href: string | null; +} +export interface ActivityItem { + kind: "statement_import" | "receipt" | "decision"; + text: string; + amounts: number[]; + at: string; +} +export interface HomeSummary { + found: FoundItem[]; + more: number; + worth_total: number; + activity: ActivityItem[]; +} +export interface BudgetState { + month: string; + monthly_budget: number | null; + spent: number; + categories: { category: string; limit: number; spent: number }[]; +} + +export const homeApi = { + summary: (): Promise => cached("/api/home/summary", async () => (await apiClient.get("/api/home/summary")).data), +}; + +export const budgetApi = { + get: async (): Promise => (await apiClient.get("/api/budget")).data, + put: async (body: { monthly_budget?: number | null; categories?: { category: string; limit: number | null }[] }): Promise => + (await apiClient.put("/api/budget", body)).data, + /** Files set_budget proposals (nothing is saved until each is approved); [] = not enough history. */ + draft: async (): Promise => (await apiClient.post("/api/budget/draft")).data.proposals, +}; + // The agent's proposals (SPEC-AGENT), served by the FastAPI routes under /api/agent. Questions go through chatApi. export const agentApi = { proposals: async (): Promise => { @@ -758,6 +799,12 @@ export const reviewApi = { return response.data.items; }, + /** Just the number waiting (the sidebar badge). */ + count: async (): Promise => { + const response = await apiClient.get("/api/review", { params: { status: "flagged", count: 1 } }); + return response.data.count ?? 0; + }, + decide: async (id: string, decision: "approve" | "reject", body: { edits?: Record; note?: string } = {}): Promise => { const response = await apiClient.post(`/api/review/${encodeURIComponent(id)}/${decision}`, body); return response.data.item; diff --git a/frontend/src/lib/home-math.ts b/frontend/src/lib/home-math.ts index 6b5eef9..fbae055 100644 --- a/frontend/src/lib/home-math.ts +++ b/frontend/src/lib/home-math.ts @@ -59,7 +59,7 @@ export function sameDayDelta(cur: number[], prev: number[], index: number): { ab if (!cur.length || !prev.length) return null; const now = cur[Math.min(index, cur.length - 1)]; const then = prev[Math.min(index, prev.length - 1)]; - if (!then) return null; + if (then <= 0) return null; // nothing (or net refunds) to compare against return { abs: now - then, pct: ((now - then) / then) * 100 }; } @@ -93,6 +93,11 @@ export function briefSentence(a: { return s; } +/** Fill "{0}"-style placeholders with formatted money (the server keeps amounts numeric). */ +export function fill(text: string, amounts: number[], money: (n: number) => string): string { + return text.replace(/\{(\d+)\}/g, (_, i) => (amounts[Number(i)] == null ? "" : money(amounts[Number(i)]))); +} + export function calendarBuckets(txns: Txn[], p: Period): { day: number; amount: number }[] { const out = Array.from({ length: p.days }, (_, i) => ({ day: i + 1, amount: 0 })); for (const t of txns) {