diff --git a/backend/ai/budget.py b/backend/ai/budget.py index 7192861..5ff264f 100644 --- a/backend/ai/budget.py +++ b/backend/ai/budget.py @@ -55,8 +55,8 @@ def _round_up(avg: float) -> int: def draft_budget(txns: list[dict], today: date) -> list[dict]: - """One line per category: the mean over the 3 complete months before today's (only those with any spend), - rounded up. + """One line per category: the mean over the 3 complete months before today's (only those with any spend, which + are the ones basis.months lists), rounded up. Empty when fewer than 2 of those months have any spend (not enough history to suggest limits).""" months = [] y, m = today.year, today.month @@ -65,15 +65,15 @@ def draft_budget(txns: list[dict], today: date) -> list[dict]: months.append((y, m)) months.reverse() totals = _totals(txns, set(months)) - active = len({mo for (_, mo), v in totals.items() if v}) # months with any spend (a new user may have only 2) - if active < 2: + spent = sorted({mo for (_, mo), v in totals.items() if v}) # months with any spend (a new user may have only 2) + if len(spent) < 2: return [] names = _display_names(txns) lines = [] for key in {k for k, _ in totals}: - avg = sum(totals.get((key, mo), 0.0) for mo in months) / active + avg = sum(totals.get((key, mo), 0.0) for mo in months) / len(spent) if avg >= MIN_MONTHLY: lines.append({"category": names[key], "limit": _round_up(avg), - "basis": {"months": [f"{yy}-{mm:02d}" for yy, mm in months], "average": round(avg, 2)}}) + "basis": {"months": [f"{yy}-{mm:02d}" for yy, mm in spent], "average": round(avg, 2)}}) lines.sort(key=lambda l: l["limit"], reverse=True) return lines[:MAX_LINES] diff --git a/backend/api/agent.py b/backend/api/agent.py index 8dd3eb2..6671c78 100644 --- a/backend/api/agent.py +++ b/backend/api/agent.py @@ -158,16 +158,18 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d p.status, p.decided_at = decision, now applied = None if decision == "approved" and p.type == "set_budget": - from api.budget import upsert_budget + from api.budget import this_month, upsert_budget, valid_month # the agent can file set_budget with any payload: only a real positive limit is ever applied try: - limit = float(json.loads(p.payload or "{}")["limit"]) + payload = json.loads(p.payload or "{}") + limit = float(payload["limit"]) except (KeyError, TypeError, ValueError): - limit = math.nan + payload, limit = {}, math.nan if not math.isfinite(limit) or limit <= 0 or not p.target.strip(): raise HTTPException(422, "This budget suggestion has no valid limit; reject it instead.") - upsert_budget(session, user_id, p.target.strip(), limit) + # from the month the draft was for (a draft made last month still starts there), else this month + upsert_budget(session, user_id, p.target.strip(), limit, valid_month(payload.get("month")) or this_month()) applied = {"category": p.target.strip(), "limit": limit} if decision == "approved" and p.type == "update_category": category = json.loads(p.payload or "{}").get("category") diff --git a/backend/api/budget.py b/backend/api/budget.py index 675a763..5d40c93 100644 --- a/backend/api/budget.py +++ b/backend/api/budget.py @@ -1,12 +1,14 @@ """UI revamp Phase 3: budgets. The overall monthly budget is User.monthly_budget (set in onboarding); per-category limits live in the budgets table. A draft (ai/budget.py) never writes budgets: it files set_budget proposals that the -user approves one by one through /api/agent/proposals/{id}/approve (api/agent.py applies them).""" +user approves one by one through /api/agent/proposals/{id}/approve (api/agent.py applies them). +Phase 5d: limits are versioned by month (models.Budget); every read is for one month, every write is from one month on.""" from __future__ import annotations import json +import re from datetime import date, datetime -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field from sqlalchemy import select, text from sqlalchemy.engine import Engine @@ -18,6 +20,30 @@ router = APIRouter(prefix="/api/budget", tags=["budget"]) _MONTH = "%b" +MONTH_RE = r"^\d{4}-(0[1-9]|1[0-2])$" + + +def this_month(today: date | None = None) -> str: + return (today or date.today()).strftime("%Y-%m") + + +def valid_month(value) -> str | None: + return value if isinstance(value, str) and re.match(MONTH_RE, value) else None + + +def _shift(month: str, n: int) -> str: + y, m = divmod(int(month[:4]) * 12 + int(month[5:]) - 1 + n, 12) + return f"{y}-{m + 1:02d}" + + +def limits_in_force(rows, month: str) -> dict[str, tuple[str, float]]: + """Case-folded category -> (name, limit) for `month`: each category's latest row from that month or before. + A limit of 0 is a removal, so the category has none.""" + latest: dict[str, object] = {} + for b in sorted(rows, key=lambda b: b.effective_from or ""): + if (b.effective_from or "") <= month: + latest[b.category.casefold()] = b + return {k: (b.category, b.monthly_limit) for k, b in latest.items() if b.monthly_limit > 0} class CategoryLimit(BaseModel): @@ -37,31 +63,42 @@ def _txns(conn, user_id: str) -> list[dict]: {"u": user_id}).mappings()] -def _state(engine: Engine, user_id: str, today: date) -> dict: +def _spend(engine: Engine, user_id: str, month: str) -> dict[str, float]: from ai.budget import month_spend_by_category - from models import Budget, User with engine.connect() as conn: - spend = month_spend_by_category(_txns(conn, user_id), today.year, today.month) + return month_spend_by_category(_txns(conn, user_id), int(month[:4]), int(month[5:])) + + +def _rows(session: Session, user_id: str) -> list: + from models import Budget + + return session.scalars(select(Budget).where(Budget.user_id == user_id)).all() + + +def _state(engine: Engine, user_id: str, month: str) -> dict: + from models import User + + spend = _spend(engine, user_id, month) by_key = {k.casefold(): v for k, v in spend.items()} with Session(engine) as session: user = session.get(User, user_id) - rows = session.scalars(select(Budget).where(Budget.user_id == user_id)).all() - cats = [{"category": b.category, "limit": b.monthly_limit, "spent": round(by_key.get(b.category.casefold(), 0), 2)} - for b in rows] + cats = [{"category": name, "limit": limit, "spent": round(by_key.get(key, 0), 2)} + for key, (name, limit) in limits_in_force(_rows(session, user_id), month).items()] cats.sort(key=lambda c: c["spent"] / c["limit"], reverse=True) - return {"success": True, "month": today.strftime("%Y-%m"), "monthly_budget": user.monthly_budget if user else None, + return {"success": True, "month": month, "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.""" +def upsert_budget(session: Session, user_id: str, category: str, limit: float, month: str) -> None: + """Sets `category`'s limit from `month` on (0 removes it). One row per (user, category, month), matched + case-insensitively so 'food' and 'Food' don't become two budgets.""" from models import Budget - rows = session.scalars(select(Budget).where(Budget.user_id == user_id)).all() - row = next((b for b in rows if b.category.casefold() == category.casefold()), None) + row = next((b for b in _rows(session, user_id) + if b.category.casefold() == category.casefold() and b.effective_from == month), None) if row is None: - session.add(Budget(user_id=user_id, category=category, monthly_limit=limit)) + session.add(Budget(user_id=user_id, category=category, monthly_limit=limit, effective_from=month)) else: row.monthly_limit, row.updated_at = limit, datetime.utcnow() @@ -85,7 +122,7 @@ def create_draft(engine: Engine, user_id: str, today: date) -> list[dict]: months = line["basis"]["months"] span = f"{date.fromisoformat(months[0] + '-01'):{_MONTH}}–{date.fromisoformat(months[-1] + '-01'):{_MONTH}}" p = Proposal(user_id=user_id, type="set_budget", target=line["category"], risk="low", - payload=json.dumps({"limit": line["limit"], "basis": line["basis"]}), + payload=json.dumps({"limit": line["limit"], "basis": line["basis"], "month": this_month(today)}), reason=f"Averaged {line['basis']['average']:,.0f} a month over {span}, rounded up.", evidence="[]", status="pending") session.add(p) @@ -97,27 +134,46 @@ def create_draft(engine: Engine, user_id: str, today: date) -> list[dict]: @router.get("", dependencies=[Depends(rate_limit())]) -def get_budget(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): - return _state(engine, claims["sub"], date.today()) +def get_budget(month: str | None = Query(default=None, pattern=MONTH_RE), claims: dict = Depends(current_user), + engine: Engine = Depends(get_engine)): + return _state(engine, claims["sub"], month or this_month()) + + +@router.get("/history", dependencies=[Depends(rate_limit())]) +def get_history(months: int = Query(default=6, ge=1, le=12), claims: dict = Depends(current_user), + engine: Engine = Depends(get_engine)): + """Budget vs spent for the last `months` months, oldest first, ending with this one. A past month with no + category limit in force has no budget (null): spend only.""" + from models import User + + user_id, now = claims["sub"], this_month() + with Session(engine) as session: + user = session.get(User, user_id) + overall = user.monthly_budget if user else None + rows = _rows(session, user_id) + from ai.budget import month_spend_by_category + + with engine.connect() as conn: + txns = _txns(conn, user_id) + out = [] + for month in (_shift(now, -n) for n in range(months - 1, -1, -1)): + limits = limits_in_force(rows, month) + budget = (overall or round(sum(v for _, v in limits.values()), 2) or None) if limits or month == now else None + spent = sum(month_spend_by_category(txns, int(month[:4]), int(month[5:])).values()) + out.append({"month": month, "budget": budget, "spent": round(spent, 2)}) + return {"success": True, "months": out} @router.put("", dependencies=[Depends(rate_limit())]) def put_budget(body: BudgetUpdate, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): - from models import Budget - - user_id = claims["sub"] + """Changes apply from the current month on; past months keep the limits they had.""" + user_id, month = claims["sub"], this_month() with Session(engine) as session, session.begin(): if "monthly_budget" in body.model_fields_set: ensure_user(session, user_id, claims.get("email")).monthly_budget = body.monthly_budget for c in body.categories: - name = c.category.strip() - if c.limit is None: - for b in session.scalars(select(Budget).where(Budget.user_id == user_id)).all(): - if b.category.casefold() == name.casefold(): - session.delete(b) - else: - upsert_budget(session, user_id, name, c.limit) - return _state(engine, user_id, date.today()) + upsert_budget(session, user_id, c.category.strip(), c.limit or 0, month) + return _state(engine, user_id, month) @router.post("/draft", dependencies=[Depends(rate_limit("10 per minute"))]) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 2b193f7..a9ced99 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -302,7 +302,9 @@ class Proposal(db.Model): class Budget(db.Model): """A monthly spending limit for one category (UI revamp Phase 3). The overall budget is User.monthly_budget. - Rows are written only by approving a set_budget proposal or by PUT /api/budget.""" + Rows are written only by approving a set_budget proposal or by PUT /api/budget. + Versioned by month (Phase 5d): a month's limit is the latest row with effective_from <= that month, so changing + a budget never rewrites past months. A monthly_limit of 0 removes the category from that month on.""" __tablename__ = "budgets" @@ -310,9 +312,10 @@ class Budget(db.Model): user_id = db.Column(String(36), nullable=False, index=True) category = db.Column(db.String, nullable=False) monthly_limit = db.Column(db.Float, nullable=False) + effective_from = db.Column(db.String(7)) # "YYYY-MM"; always set by the writers (older rows: migrated) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - __table_args__ = (db.UniqueConstraint("user_id", "category", name="u_budget_user_category"),) + __table_args__ = (db.UniqueConstraint("user_id", "category", "effective_from", name="u_budget_user_category_month"),) class RecurringState(db.Model): """What the user said about a recurring charge on the Bills page (UI revamp Phase 5c): "cancelled" keeps it listed diff --git a/backend/models/database.py b/backend/models/database.py index 2848a79..c09f92c 100644 --- a/backend/models/database.py +++ b/backend/models/database.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from flask import Flask from flask_sqlalchemy import SQLAlchemy @@ -34,6 +35,7 @@ def _migrate_transaction_unique_index(app: Flask) -> None: # Columns added after their table first shipped (create_all doesn't alter existing tables). All nullable. _ADDED_COLUMNS = { + "budgets": [("effective_from", "VARCHAR(7)")], # UI revamp Phase 5d: budgets per month "chat_messages": [("thread_id", "VARCHAR(36)")], # UI revamp Phase 4: chat threads "review_items": [("extracted", "TEXT")], # SPEC-FEEDBACK "transactions": [("currency", "VARCHAR(3)"), ("po_number", "VARCHAR"), ("file_key", "VARCHAR"), @@ -58,6 +60,35 @@ def _add_missing_columns() -> None: logger.warning("Column migration for %s skipped: %s", table, e) +def _migrate_budget_versions(engine) -> None: + """Phase 5d: budgets went from one row per (user, category) to one per month. Dates older rows by their last + update and swaps the unique constraint. SQLite can't drop a table constraint, so there the table is rebuilt. + Idempotent.""" + insp = inspect(engine) + if "budgets" not in insp.get_table_names(): + return + old = any(set(u["column_names"]) == {"user_id", "category"} for u in insp.get_unique_constraints("budgets")) + with engine.begin() as conn: + if old and engine.dialect.name == "sqlite": + conn.execute(text("ALTER TABLE budgets RENAME TO budgets_pre_5d")) + conn.execute(text("CREATE TABLE budgets (id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, " + "category VARCHAR NOT NULL, monthly_limit FLOAT NOT NULL, effective_from VARCHAR(7), " + "updated_at DATETIME)")) + have = {c["name"] for c in insp.get_columns("budgets_pre_5d")} + eff = "effective_from" if "effective_from" in have else "NULL" + conn.execute(text(f"INSERT INTO budgets (id, user_id, category, monthly_limit, effective_from, updated_at) " + f"SELECT id, user_id, category, monthly_limit, {eff}, updated_at FROM budgets_pre_5d")) + conn.execute(text("DROP TABLE budgets_pre_5d")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_budgets_user_id ON budgets (user_id)")) + elif old: + conn.execute(text("ALTER TABLE budgets DROP CONSTRAINT IF EXISTS u_budget_user_category")) + for row_id, updated in conn.execute(text("SELECT id, updated_at FROM budgets WHERE effective_from IS NULL")).all(): + conn.execute(text("UPDATE budgets SET effective_from = :m WHERE id = :i"), + {"m": str(updated or datetime.utcnow())[:7], "i": row_id}) + conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS u_budget_user_category_month " + "ON budgets (user_id, category, effective_from)")) + + RAG_TABLES = {"documents", "document_chunks"} @@ -90,6 +121,10 @@ def init_db(app: Flask): db.metadata.create_all(db.engine, tables=tables) _migrate_transaction_unique_index(app) _add_missing_columns() + try: + _migrate_budget_versions(db.engine) + except Exception as e: + logger.warning("Budget versions migration skipped: %s", e) logger.info("Database connected and tables initialized") except Exception as e: logger.warning("Could not connect to database: %s", e) diff --git a/backend/tests/test_api_budget.py b/backend/tests/test_api_budget.py index 7f99a21..d96482d 100644 --- a/backend/tests/test_api_budget.py +++ b/backend/tests/test_api_budget.py @@ -195,3 +195,68 @@ def test_decide_claims_with_a_conditional_update(setup): claim = next(i for i, s in enumerate(statements) if s.startswith("update proposals") and "status = ?" in s.split("where", 1)[1]) writes = [i for i, s in enumerate(statements) if s.startswith("insert into budgets") or s.startswith("insert into audit_events")] assert writes and claim < min(writes) + + +def ym(n): + return months_back(n)[:7] + + +def put_row(engine, user, category, limit, month): + from models import Budget + + with Session(engine) as s, s.begin(): + s.add(Budget(user_id=user, category=category, monthly_limit=limit, effective_from=month)) + + +def test_editing_this_month_keeps_last_months_limits(setup): + c, engine = setup + put_row(engine, A, "Food", 3000, ym(1)) + seed(engine, A, [(months_back(1), 2500, "Food")]) + c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "Food", "limit": 5000}]}) + last = c.get(f"/api/budget?month={ym(1)}", headers=bearer(A)).json() + assert last["month"] == ym(1) and last["spent"] == 2500 + assert last["categories"] == [{"category": "Food", "limit": 3000, "spent": 2500}] + assert c.get("/api/budget", headers=bearer(A)).json()["categories"][0]["limit"] == 5000 + + +def test_removing_a_category_only_applies_from_this_month(setup): + c, engine = setup + put_row(engine, A, "Food", 3000, ym(1)) + body = c.put("/api/budget", headers=bearer(A), json={"categories": [{"category": "food", "limit": None}]}).json() + assert body["categories"] == [] + assert c.get(f"/api/budget?month={ym(1)}", headers=bearer(A)).json()["categories"][0]["limit"] == 3000 + + +def test_a_bad_month_is_rejected(setup): + c, _ = setup + for bad in ("2026-13", "26-01", "2026-1", "x"): + assert c.get(f"/api/budget?month={bad}", headers=bearer(A)).status_code == 422 + assert c.get("/api/budget/history?months=0", headers=bearer(A)).status_code == 422 + + +def test_history_compares_each_month_with_the_limits_then(setup): + c, engine = setup + put_row(engine, A, "Food", 3000, ym(2)) + put_row(engine, A, "Food", 4000, ym(1)) + seed(engine, A, [(months_back(3), 900, "Food"), (months_back(2), 3500, "Food"), (months_back(1), 1000, "Food")]) + months = c.get("/api/budget/history?months=4", headers=bearer(A)).json()["months"] + assert months == [ + {"month": ym(3), "budget": None, "spent": 900}, # before any budget: spend only + {"month": ym(2), "budget": 3000, "spent": 3500}, + {"month": ym(1), "budget": 4000, "spent": 1000}, + {"month": ym(0), "budget": 4000, "spent": 0}, + ] + c.put("/api/budget", headers=bearer(A), json={"monthly_budget": 50000}) + assert c.get("/api/budget/history?months=1", headers=bearer(A)).json()["months"][0]["budget"] == 50000 + + +def test_approving_a_draft_writes_it_from_the_drafts_month(setup): + from models import Budget + + c, engine = setup + history(engine, A) + food = next(p for p in c.post("/api/budget/draft", headers=bearer(A)).json()["proposals"] if p["target"] == "Food") + assert food["payload"]["month"] == ym(0) + c.post(f"/api/agent/proposals/{food['id']}/approve", headers=bearer(A)) + with Session(engine) as s: + assert [(b.category, b.effective_from) for b in s.scalars(select(Budget))] == [("Food", ym(0))] diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py index be19c35..bfdd9f0 100644 --- a/backend/tests/test_budget.py +++ b/backend/tests/test_budget.py @@ -63,15 +63,48 @@ def test_month_spend_nets_refunds(): assert month_spend_by_category(txns, 2026, 10) == {"Food": 600} -def test_budget_unique_per_category(tmp_path): +def test_budget_unique_per_category_and_month(tmp_path): import models # noqa: F401 from models import Budget from models.database import db as flask_db engine = create_engine(f"sqlite:///{tmp_path / 'b.db'}") flask_db.metadata.create_all(engine) + with Session(engine) as s, s.begin(): # one row per month is how a budget changes over time + s.add_all([Budget(user_id="u", category="Food", monthly_limit=1, effective_from="2026-09"), + Budget(user_id="u", category="Food", monthly_limit=2, effective_from="2026-10")]) with pytest.raises(IntegrityError), Session(engine) as s, s.begin(): - s.add_all([Budget(user_id="u", category="Food", monthly_limit=1), Budget(user_id="u", category="Food", monthly_limit=2)]) + s.add(Budget(user_id="u", category="Food", monthly_limit=3, effective_from="2026-10")) + + +def test_migration_versions_an_old_budgets_table(tmp_path): + """A database from before Phase 5d: one row per (user, category), no effective_from. The migration keeps the rows, + dates them by their last update, and lets a category have one row per month. Running it twice is harmless.""" + from sqlalchemy import text + + from models.database import _migrate_budget_versions + + engine = create_engine(f"sqlite:///{tmp_path / 'old.db'}") + with engine.begin() as conn: + conn.execute(text("CREATE TABLE budgets (id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, " + "category VARCHAR NOT NULL, monthly_limit FLOAT NOT NULL, updated_at DATETIME, " + "CONSTRAINT u_budget_user_category UNIQUE (user_id, category))")) + conn.execute(text("INSERT INTO budgets VALUES ('1', 'u', 'Food', 4000, '2026-08-14 10:00:00')")) + _migrate_budget_versions(engine) + _migrate_budget_versions(engine) + with engine.begin() as conn: + assert conn.execute(text("SELECT category, monthly_limit, effective_from FROM budgets")).all() == [ + ("Food", 4000, "2026-08")] + conn.execute(text("INSERT INTO budgets (id, user_id, category, monthly_limit, effective_from) " + "VALUES ('2', 'u', 'Food', 5000, '2026-10')")) + with pytest.raises(IntegrityError), engine.begin() as conn: + conn.execute(text("INSERT INTO budgets (id, user_id, category, monthly_limit, effective_from) " + "VALUES ('3', 'u', 'Food', 6000, '2026-10')")) + + +def test_basis_lists_only_months_with_spend(): + txns = [t("2026-08-10", 6000), t("2026-09-10", 6000)] # nothing in July + assert draft_budget(txns, TODAY)[0]["basis"]["months"] == ["2026-08", "2026-09"] def test_draft_with_two_months_averages_over_two(): diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index d628543..3e826a2 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -257,7 +257,7 @@ embeds the demo documents once per server and uploads the files in the backgroun (`?tab=documents|import|review`), `/ask`, `/settings`. The old URLs (`/dashboard`, `/analytics`, `/ai-analytics`, `/chatbot`, `/documents`, `/import`, `/review`, `/upload`) redirect to them in `next.config.js` (temporary 307s, query strings carried). Until UI revamp Phase 5, Spending, Bills and Receipts reuse the old pages' `*Content.tsx`. -- **Sidebar** (`components/app-sidebar.tsx`): Home · Track (Spending, Bills & subscriptions) · Receipts & statements +- **Sidebar** (`components/app-sidebar.tsx`): Home · Track (Spending, Bills & subscriptions) · Plan (Budget) · Receipts & statements (badge = flagged review items) · Ask Lumen (Ctrl/⌘+K). It collapses to a 56px icon rail with Ctrl/⌘+B, remembered in the `sidebar_state` cookie. The layout reads that cookie on the sidebar's first render, which is always client-side because the auth guard shows a loader on the server, so there's no jump and no server read. Shortcuts @@ -307,6 +307,15 @@ embeds the demo documents once per server and uploads the files in the backgroun with each bill on its projected days (‹ month › switcher), Upcoming grouped This week / Next week / Later, and the table with tabs, price-rise / looks-stopped / charged-again pills, Mark as cancelled, Not a bill, and How to cancel (`/ask?draft=How do I cancel X?`). Maths in `lib/bills-math.ts` (tests: `e2e/bills-math.spec.ts`). +- **Budget** (`app/(app)/budget/`, Phase 5d): "am I on track, and what should my plan be?" A ‹ month › switcher + (past months read-only, as they were), Edit budget (the overall total, in place) and Let Lumen draft. Hero: spent of + total, a bar with the even-pace tick, left / days left / safe per day (left ÷ days left), and the running total + against a straight line to the budget (`CumulativeChart` with that line as its dashed series). Last 6 months: dashed + budget outline vs actual (green under, red over), this month with a run-rate projection, and the track record. By + category: budget / spent / left / used, the amount editable in place (Enter saves, Esc cancels, same row height), + Add category, and the unassigned footer. The draft panel shows pending `set_budget` lines (Skip / Approve named for + the category, "Approve remaining N"). History sits above By category so the data-height cards come last. + Maths in `lib/budget-math.ts` (tests: `e2e/budget-math.spec.ts`). - **Home** (`app/(app)/home/`, Home v2 from Phase 5a, modelled on Origin's app home): greeting, quiet Upload and Export, and a serif brief over a sky band. Then a full-width hero: Spent in (total, change vs the previous period by the same day, "heading for" from the forecast, smooth now-vs-before lines, bill markers on the axis, @@ -327,12 +336,18 @@ embeds the demo documents once per server and uploads the files in the backgroun Text carries `{0}` placeholders plus `amounts`, which the client fills in the user's currency (`fill` in `lib/home-math.ts`). The brief adds "The one thing worth a look" from the top item. - Budgets: the overall budget is `User.monthly_budget` (onboarding); per-category limits are the `budgets` table. - - `GET/PUT /api/budget` (`api/budget.py`). - - `POST /api/budget/draft` runs the deterministic `ai/budget.py` (3-month mean, rounded up) and files `set_budget` - proposals. Approving one (`/api/agent/proposals/{id}/approve`) upserts the row. A new draft retires pending ones. + Budgets: the overall budget is `User.monthly_budget` (onboarding); per-category limits are the `budgets` table, + one row per (user, category, `effective_from` month) since Phase 5d. A month's limit is the latest row from that + month or before (`limits_in_force`), so edits never rewrite past months; a 0 limit removes a category from that + month on. `models/database._migrate_budget_versions` dates older rows by `updated_at` and swaps the unique + constraint (SQLite rebuilds the table). + - `GET /api/budget?month=YYYY-MM` (default this month), `PUT /api/budget` (writes from the current month), + `GET /api/budget/history?months=6` (budget vs spent per month; `budget` null for a past month with no limits). + - `POST /api/budget/draft` runs the deterministic `ai/budget.py` (3-month mean over the months that had spend, + rounded up; `basis.months` lists those) and files `set_budget` proposals with `payload.month` = this month. + Approving one (`/api/agent/proposals/{id}/approve`) writes the row from that month. A new draft retires pending ones. - Ask Lumen's `draft_budget` tool files the same proposals. - - Home's draft dialog approves lines one by one or all at once. + - The Budget page's draft panel approves lines; Home's promo links to `/budget?draft=1`. The sidebar badge uses `GET /api/review?count=1`. `(app)/layout.tsx` is a tiny server file exporting the light `theme-color`; the client shell is `components/app-shell.tsx`. diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index 54d743d..7f2e78b 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -441,3 +441,23 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in undo, hide and undo, the Ask link, CLS under 0.01, tabs on one line at 375 px) and 4 maths tests. A screenshot against the mockup caught the wrapping tabs on phones, now pinned by a test. +## 35. A budget you can plan with (UI revamp, Phase 5d) +- **S:** Budgets were one limit per category with no history: changing one rewrote every past month, there was no + way to look back, and the only place to approve Lumen's draft was a dialog on Home with buttons named just + "Approve" / "Skip". +- **T:** A Budget page that answers "am I on track, and what should my plan be?", where past months stay as they were + and the draft lives on the page. +- **A:** Budgets became versioned by month (TDD): an `effective_from` column, reads that take each category's latest + row from that month or before, writes from the current month on, and a migration that dates old rows and swaps the + unique constraint (rebuilding the table on SQLite, where a constraint can't be dropped). New `?month=` and + `/history` endpoints; approved drafts write from the month they were for; `basis.months` lists only months with + spend. Pure maths (`lib/budget-math.ts`), then the page: a pace hero with the even-pace tick and safe per day, six + months of budget-vs-actual, a category table edited in place, and the draft panel with category-named buttons. A + Plan group joined the sidebar; Home's promo now links to the page. +- **R:** Backend 993 passed (8 new: last month's limits survive an edit, removal only from this month, history with + and without a budget, draft month, bad input, the old-schema migration, basis months). Frontend: 17 Budget e2e + checks (hero numbers, month switch read-only, inline edit without the row moving, Esc, add category, edit total, + draft skip/approve/approve-remaining/double click/409/`?draft=1`/no history, history bars, CLS under 0.01, phone fit) + and 4 maths tests. A phone screenshot showed buttons and bars clipped past the screen edge, which the old + "no sideways scroll" check missed; the test now checks every header button, bar and table cell ends on screen. + diff --git a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md index 24d59a1..733dd20 100644 --- a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md +++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md @@ -266,8 +266,30 @@ Branch: `ui-revamp/phase5c-bills`, stacked on `ui-revamp/phase5b-spending` (#98) - STORY 34, guide (Bills), spec §12 (tick the 5c item). - Full checks green, screenshot vs `bills.html` at 1440 and 375, PR stacked on #98. -## Phase 5d — Budget page (outline) -Backend (TDD): `Budget.effective_from` (migration + create_all column list; existing rows → created month); `get_budget(month)` = latest row per category with `effective_from <= month`; `GET /api/budget?month=YYYY-MM`, `GET /api/budget/history?months=6`; edits write rows effective from the current month; draft approval writes rows effective from the draft's month; `basis.months` only months with spend; draft buttons' accessible names include the category. Frontend: `/budget` page + sidebar Plan group; hero with even-pace tick and pace chart; category table with inline edit (no row height change); draft panel on the page (Home promo links here); last 6 months bars. STORY 35. +## Phase 5d — Budget page (detailed) + +Branch: `ui-revamp/phase5d-budget`, stacked on `ui-revamp/phase5c-bills` (#99). Mockup `budget.html`; spec §4.4 and the builder-check decision "budgets are versioned by month". + +### Task D1: budgets per month (backend, TDD) +- `Budget.effective_from` = `"YYYY-MM"` (String(7)); unique (user_id, category, effective_from) as `u_budget_user_category_month`. `models/database.py`: `_migrate_budget_versions(engine)` after `_add_missing_columns` (column in `_ADDED_COLUMNS`): fills NULLs with the row's `updated_at` month, drops the old `u_budget_user_category` (Postgres: `DROP CONSTRAINT IF EXISTS`; SQLite: rebuild the table, since its autoindex can't be dropped), creates the new unique index. Idempotent. +- `api/budget.py`: `limits_in_force(rows, month) -> {key: (category, limit)}` = latest row per case-folded category with `effective_from <= month`; a `monthly_limit <= 0` row is a removal (PUT with `limit: null` writes one for the current month). `upsert_budget(session, user_id, category, limit, month)`. +- `GET /api/budget?month=YYYY-MM` (default current; 422 on a bad month) → the existing shape for that month. `GET /api/budget/history?months=6` (1–12) → `{success, months: [{month, budget, spent}]}` oldest first ending this month; `budget` = `monthly_budget` or the sum of limits in force, `null` for a past month with no limit in force. +- Draft: `payload.month` = current month; approving writes rows effective from `payload.month` (current month if missing/bad). `draft_budget`'s `basis.months` lists only months with spend (§12). +- Tests: `test_api_budget.py` — editing this month leaves last month's limits; history budget/spent per month and `null` before any budget; removal hides the category only from this month on; approve writes `effective_from`; bad month 422; `test_budget.py` — basis months skip an empty month; unique index allows two months; migration on an old-schema SQLite file keeps rows, fills `effective_from`, and allows a second month. + +### Task D2: the Budget page (frontend) +- `budgetApi.get(month?)`, `budgetApi.history(months)`; type `BudgetMonth {month, budget: number|null, spent}`. `lib/budget-math.ts`: `pace(total, spent, today, month)` → `{pct, evenPct|null, left, daysLeft, perDay}` (evenPct/perDay only for the current month); `usedTone(spent, limit)` → `"good"|"warn"|"bad"` (warn ≥ 90%). +- `app/(app)/budget/` + sidebar **Plan · Budget** between Track and Records. Header "Budget", ‹ month › switcher (next disabled at the current month), actions on the current month only: "Edit budget" (quiet; edits the overall total in place) and "Let Lumen draft" (dark). Brief from real numbers ("You're on track: ₹X under an even pace." / "₹X over an even pace." / "₹X over budget."). Past months read-only. +- Hero: spent of total, bar with even-pace tick, Left / Days left / Safe per day (= left ÷ days left), pace chart = `CumulativeChart` with the even line passed as the dashed series. +- By category table: Budget / Spent / Left / Used bar (green, amber ≥90%, red over). Clicking the budget amount (current month) swaps it for an input of the same height: Enter saves (`PUT`), Esc cancels. Footer "Categories add up to X · Y of your Z is unassigned" (only with an overall total) + Add category (inline name + amount row). +- Draft panel on the page: shows pending `set_budget` proposals (loaded on open, or filed by "Let Lumen draft"; `?draft=1` drafts on arrival). Each line: category, proposed amount, "was X" / "new", basis sentence, Skip / Approve with aria-labels "Skip " / "Approve at ", decided state in place, "Approve remaining N", 409 → "This draft was replaced. Draft again." No month-bars glyph (basis has no per-month amounts; hidden, not faked). +- Last 6 months: dashed budget outline + actual bar (green under, red over), the current month "so far" with a pale run-rate projection; "N of M months under" over past months that had a budget. +- Home: the promo becomes a link to `/budget?draft=1` (dialog removed); the budget card title links to `/budget`. +- Tests (`budget.spec.ts`, `budget-math.spec.ts`): hero numbers and pace tick; month switch requests `?month=` and hides edit actions; inline edit PUTs and the row's box doesn't change; Esc cancels; draft panel approve/skip names, approve-remaining, 409 message; history bars over/under; CLS < 0.01; phone width no sideways scroll. Home's draft-dialog tests move here. + +### Task D3: docs, checks, PR +- STORY 35, guide (Budget), spec §12 ticks (draft accessible names, `basis.months`). +- Full checks green, screenshot vs `budget.html` at 1440 and 375, PR stacked on #99. ## Phase 5e — Receipts & statements (outline) Backend (TDD): `StatementImport` table + write on import; `GET /api/statements/imports`. Frontend: header actions; Needs-your-review cards from the review queue with highlights from validation flags (replaces the Review tab); library list with filter chips (Statements = import rows) and search; Statement imports card; From your inbox card; Activity moves here from Home if it no longer fits. Old `/documents`, `/import`, `/review` routes redirect into `/receipts`. STORY 36, then the whole-branch review for Phase 5. 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 b03b370..d3974a3 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -233,7 +233,7 @@ Small items found while building, parked here so they aren't lost. Tick them off - [ ] **Phase 4.** Drafting a budget (HTTP or the agent tool) retires all pending `set_budget` proposals. A draft from Ask while Home's dialog is open makes that dialog's approvals return 409. Two quick drafts can leave two pending sets. Consider scoping by draft id and a per-user lock. *(Phase 3 review; Phase 4: Home's dialog now says "This draft was replaced. Draft again." instead of erroring. Scoping is still open.)* - [x] **Phase 4.** After approving draft lines, Home reloads only the budget. Also reload the summary so Activity shows "You approved…" at once. *(Phase 3 review)* - [ ] **Phase 6.** `/api/home/summary` and `/api/budget` each scan all of a user's transactions in Python on every Home visit. Fine for a few thousand rows; filter by date in SQL (and cache) if a user grows past that. *(Phase 3 review)* -- [ ] **Phase 5.** Draft dialog: per-line buttons are named just "Approve" / "Skip". Add the category to their accessible names. The `basis.months` list always shows 3 months even when only 2 had spend. *(Phase 3 review)* +- [x] **Phase 5.** Draft dialog: per-line buttons are named just "Approve" / "Skip". Add the category to their accessible names. The `basis.months` list always shows 3 months even when only 2 had spend. *(Phase 3 review; 5d: the draft moved to the Budget page with named buttons, and `basis.months` lists only months with spend.)* - [x] **Phase 5.** `POST /chat` commits a new thread before saving its messages. If that save fails, the response points at an empty titled thread. Create both in one commit. *(Phase 4 review)* *(Done in 5a.)* - [x] **Phase 5.** A lost approval race returns 409 "Proposal is already pending" (status read before the claim). Re-read the status or use a generic message. *(Phase 4 review)* *(Done in 5a: generic "This proposal was already decided.")* - [ ] **Phase 6.** The `chat_messages.thread_id` index exists only on fresh databases (`_ADDED_COLUMNS` adds the column, not the index). Add `CREATE INDEX IF NOT EXISTS`. Fine at current scale because every query also filters on the indexed `user_id`. *(Phase 4 review)* @@ -241,6 +241,9 @@ Small items found while building, parked here so they aren't lost. Tick them off - [x] **Phase 5.** Below 768px the sidebar is a Sheet, and `AskPalette` renders inside it, so Ctrl+K on a tablet keyboard does nothing until the sheet is open. Render the palette outside ``. *(Phase 4 review)* *(Done in 5a: the palette lives in app-shell.)* - [ ] **Later.** Suggested question "What's waiting for my review?": check the agent can answer it (no review-queue tool). `price_rise` could use its subject ("Why did Netflix go up?"). *(Phase 4 review)* - [x] **Phase 5c.** Home's "Coming up" and bill pins come from `/api/analytics/reminders`, which only has rows after the spending-pattern analysis has run (the old Spending page triggers it). Switch Home and Bills to the recurring-charges API in 5c. *(5a, owner report)* *(Done in 5c: Home and Bills read `/api/bills/recurring`.)* -- [ ] **Phase 5d.** Below `lg` the Home cards stack and their placeholders no longer match the loaded heights; the Budget card also grows once a budget exists. Size placeholders per breakpoint, or load the budget with the page. *(5a)* +- [ ] **Phase 6.** Below `lg` the Home cards stack and their placeholders no longer match the loaded heights; the Budget card also grows once a budget exists. Size placeholders per breakpoint, or load the budget with the page. *(5a; not done in 5d, moved to Phase 6)* +- [ ] **Later.** Budget page: "Safe per day" is left ÷ days left; subtract bills still due this month (the Bills data) once both are on the page. *(5d)* +- [ ] **Later.** The overall monthly budget (`User.monthly_budget`) isn't versioned like category limits, so history uses today's total for past budgeted months. Version it if people change it often. *(5d)* +- [ ] **Later.** Draft lines could show a small month-by-month glyph once `basis` carries per-month amounts. *(5d)* - [ ] **Later.** Spending: the category edit (pencil) is hidden below `sm`, so phones can't recategorise; and "Lumen noticed" wraps beside its pill at 375px. *(5b)* - [ ] **Owner, manual.** In Firefox, check that the profile/avatar menu doesn't shift the Ask Lumen column, and click through the old pages on light in a demo session. *(Phase 0 PR #89)* diff --git a/frontend/e2e/budget-math.spec.ts b/frontend/e2e/budget-math.spec.ts new file mode 100644 index 0000000..12b1975 --- /dev/null +++ b/frontend/e2e/budget-math.spec.ts @@ -0,0 +1,37 @@ +// Node-only unit tests (no browser) for the Budget page's calculations (UI revamp Phase 5d). +import { test, expect } from "@playwright/test"; +import { evenLine, pace, runRate, usedTone } from "../src/lib/budget-math"; + +const today = new Date(2026, 9, 22); // 22 Oct 2026; October has 31 days +const october = new Date(2026, 9, 1); + +test("pace: this month has an even-pace mark, days left and a safe amount per day", () => { + const p = pace(100000, 62182, today, october); + expect(p.pct).toBeCloseTo(0.62182); + expect(p.evenPct).toBeCloseTo(22 / 31); + expect(p.left).toBe(37818); + expect(p.daysLeft).toBe(10); // today counts + expect(p.perDay).toBeCloseTo(3781.8); + expect(p.vsPace).toBeCloseTo(62182 - (100000 * 22) / 31); // negative = under an even pace +}); + +test("pace: a past month has no pace, days left or per day; over budget has no per day", () => { + const past = pace(50000, 61000, today, new Date(2026, 8, 1)); + expect([past.evenPct, past.daysLeft, past.perDay, past.vsPace]).toEqual([null, 0, null, null]); + expect(past.left).toBe(-11000); + expect(pace(50000, 61000, today, october).perDay).toBeNull(); + expect(pace(0, 500, today, october).pct).toBe(0); +}); + +test("usedTone: green, amber from 90%, red when over", () => { + expect(usedTone(899, 1000)).toBe("good"); + expect(usedTone(900, 1000)).toBe("warn"); + expect(usedTone(1000, 1000)).toBe("warn"); + expect(usedTone(1001, 1000)).toBe("bad"); +}); + +test("evenLine reaches the budget on the last day; runRate projects the month", () => { + const line = evenLine(31000, 31); + expect([line[0], line[30], line.length]).toEqual([1000, 31000, 31]); + expect(runRate(22000, today)).toBe(31000); +}); diff --git a/frontend/e2e/budget.spec.ts b/frontend/e2e/budget.spec.ts new file mode 100644 index 0000000..2afd00b --- /dev/null +++ b/frontend/e2e/budget.spec.ts @@ -0,0 +1,205 @@ +import { test, expect, type Page } from "@playwright/test"; +import { signIn, type HomeFixture } from "./fixtures"; + +const now = new Date(); +const ym = (n: number) => { + const d = new Date(now.getFullYear(), now.getMonth() - n, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +}; +const monthName = (n: number) => new Date(now.getFullYear(), now.getMonth() - n, 1).toLocaleString("en-US", { month: "long" }); +const daysLeft = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate() - now.getDate() + 1; +const c = (category: string, limit: number, spent: number) => ({ category, limit, spent }); +const line = (id: string, target: string, limit: number) => ({ + id, type: "set_budget", target, risk: "low", reason: "Averaged a month over Jul–Sep, rounded up.", + payload: { limit, basis: { months: [], average: limit } }, status: "pending", created_at: null, +}); +const sample: HomeFixture = { + budget: { monthly_budget: 100000, spent: 62182, categories: [c("Shopping", 8000, 16840), c("Food", 6000, 5930), c("Travel", 5000, 2140)] }, + history: [ + { month: ym(3), budget: null, spent: 70000 }, + { month: ym(2), budget: 100000, spent: 104200 }, + { month: ym(1), budget: 100000, spent: 79900 }, + { month: ym(0), budget: 100000, spent: 62182 }, + ], +}; +async function openBudget(page: Page, home: HomeFixture = sample, path = "/budget") { + await signIn(page, { home }); + await page.goto(path); + await expect(page.getByTestId("budget-hero")).toContainText("₹", { timeout: 15_000 }); +} +const row = (page: Page, name: string) => page.getByTestId("budget-row").filter({ hasText: name }); + +test("budget: heading, spent of total, left, days left and safe per day", async ({ page }) => { + await openBudget(page); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Budget"); + const hero = page.getByTestId("budget-hero"); + await expect(hero).toContainText("₹62,182"); + await expect(hero).toContainText("of ₹100,000"); + await expect(hero.getByTestId("left")).toContainText("₹37,818"); + await expect(hero.getByTestId("days-left")).toContainText(String(daysLeft)); + await expect(hero.getByTestId("per-day")).toContainText(`₹${Math.round(37818 / daysLeft).toLocaleString("en-US")}`); + await expect(hero.getByTestId("even-pace")).toBeVisible(); +}); + +test("the sidebar has a Plan group with Budget", async ({ page }) => { + await openBudget(page); + const link = page.locator('[data-sidebar="menu-button"]', { hasText: "Budget" }); + await expect(link).toHaveAttribute("href", "/budget"); + await expect(link).toHaveAttribute("data-active", "true"); + await expect(page.getByText("Plan", { exact: true })).toBeVisible(); +}); + +test("by category: tones, unassigned footer", async ({ page }) => { + await openBudget(page); + await expect(row(page, "Shopping")).toHaveAttribute("data-tone", "bad"); + await expect(row(page, "Food")).toHaveAttribute("data-tone", "warn"); + await expect(row(page, "Travel")).toHaveAttribute("data-tone", "good"); + await expect(row(page, "Shopping")).toContainText("₹8,840 over"); + await expect(page.getByTestId("budget-footer")).toContainText("Categories add up to ₹19,000"); + await expect(page.getByTestId("budget-footer")).toContainText("₹81,000 of your ₹100,000 is unassigned"); +}); + +test("editing a category's budget in place: Enter saves, the row keeps its height", async ({ page }) => { + const puts: unknown[] = []; + page.on("request", (r) => r.method() === "PUT" && r.url().endsWith("/api/budget") && puts.push(r.postDataJSON())); + await openBudget(page); + const before = (await row(page, "Food").boundingBox())!; + await row(page, "Food").getByRole("button", { name: "Edit Food budget" }).click(); + const input = row(page, "Food").getByRole("textbox", { name: "Food budget" }); + await expect(input).toBeFocused(); + expect((await row(page, "Food").boundingBox())!.height).toBe(before.height); + await input.fill("9000"); + await input.press("Enter"); + await expect(row(page, "Food")).toContainText("₹9,000"); + expect(puts).toEqual([{ categories: [{ category: "Food", limit: 9000 }] }]); +}); + +test("Esc cancels an edit without saving", async ({ page }) => { + let puts = 0; + page.on("request", (r) => r.method() === "PUT" && puts++); + await openBudget(page); + await row(page, "Food").getByRole("button", { name: "Edit Food budget" }).click(); + await row(page, "Food").getByRole("textbox").fill("1"); + await row(page, "Food").getByRole("textbox").press("Escape"); + await expect(row(page, "Food")).toContainText("₹6,000"); + expect(puts).toBe(0); +}); + +test("add a category", async ({ page }) => { + await openBudget(page); + await page.getByRole("button", { name: "Add category" }).click(); + await page.getByRole("textbox", { name: "New category", exact: true }).fill("Health"); + await page.getByRole("textbox", { name: "New category budget" }).fill("3000"); + await page.getByRole("textbox", { name: "New category budget" }).press("Enter"); + await expect(row(page, "Health")).toContainText("₹3,000"); +}); + +test("edit budget changes the overall total", async ({ page }) => { + await openBudget(page); + await page.getByRole("button", { name: "Edit budget" }).click(); + const input = page.getByRole("textbox", { name: "Monthly budget" }); + await expect(input).toBeFocused(); + await input.fill("120000"); + await input.press("Enter"); + await expect(page.getByTestId("budget-hero")).toContainText("of ₹120,000"); +}); + +test("a past month is read-only and loads that month", async ({ page }) => { + const asked = page.waitForRequest((r) => r.url().includes(`/api/budget?month=${ym(1)}`)); + await openBudget(page, { ...sample, budgetMonths: { [ym(1)]: { monthly_budget: 100000, spent: 79900, categories: [c("Food", 5000, 4100)] } } }); + await expect(page.getByRole("button", { name: "Next month" })).toBeDisabled(); + await page.getByRole("button", { name: "Previous month" }).click(); + await asked; + await expect(page.getByTestId("month-label")).toContainText(monthName(1)); + await expect(page.getByTestId("budget-hero")).toContainText("₹79,900"); + await expect(page.getByRole("button", { name: "Let Lumen draft" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Edit Food budget" })).toHaveCount(0); + await expect(page.getByTestId("even-pace")).toHaveCount(0); +}); + +test("draft panel: pending lines with named buttons, skip one, approve the rest", async ({ page }) => { + const approved: string[] = []; + page.on("request", (r) => r.url().endsWith("/approve") && approved.push(r.url().split("/").at(-2)!)); + await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500), line("p2", "Travel", 4000), line("p3", "Health", 2000)] }); + const panel = page.getByTestId("draft-panel"); + await expect(panel.getByTestId("draft-line")).toHaveCount(3); + await expect(panel.getByTestId("draft-line").filter({ hasText: "Food" })).toContainText("was ₹6,000"); + await expect(panel.getByTestId("draft-line").filter({ hasText: "Health" })).toContainText("new"); + await panel.getByRole("button", { name: "Skip Travel" }).click(); + await expect(panel.getByTestId("draft-line").filter({ hasText: "Travel" })).toContainText("Skipped"); + await panel.getByRole("button", { name: "Approve remaining 2" }).click(); + await expect(panel.getByText("Approved")).toHaveCount(2); + expect(approved.sort()).toEqual(["p1", "p3"]); + await expect(panel.getByRole("button", { name: "Approve Food at ₹6,500" })).toHaveCount(0); +}); + +test("let Lumen draft files a draft", async ({ page }) => { + await openBudget(page, { ...sample, draft: [line("p1", "Food", 6500)] }); + await page.getByRole("button", { name: "Let Lumen draft" }).click(); + await expect(page.getByTestId("draft-panel").getByRole("button", { name: "Approve Food at ₹6,500" })).toBeVisible(); +}); + +test("a draft with no history says why", async ({ page }) => { + await openBudget(page, { ...sample, draft: [] }); + await page.getByRole("button", { name: "Let Lumen draft" }).click(); + await expect(page.getByTestId("draft-panel")).toContainText("Not enough history yet"); +}); + +test("?draft=1 drafts on arrival (Home's promo links here)", async ({ page }) => { + const drafted = page.waitForRequest((r) => r.url().endsWith("/api/budget/draft") && r.method() === "POST"); + await openBudget(page, { ...sample, draft: [line("p1", "Food", 6500)] }, "/budget?draft=1"); + await drafted; + await expect(page.getByTestId("draft-line")).toHaveCount(1); +}); + +test("a replaced draft says so", async ({ page }) => { + await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500)], replaced: ["p1"] }); + await page.getByRole("button", { name: "Approve Food at ₹6,500" }).click(); + await expect(page.getByTestId("draft-panel")).toContainText("This draft was replaced. Draft again."); +}); + +test("approve remaining ignores a double click", async ({ page }) => { + let approvals = 0; + page.on("request", (r) => r.url().endsWith("/approve") && approvals++); + await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500), line("p2", "Travel", 4000)] }); + await page.getByRole("button", { name: "Approve remaining 2" }).dblclick(); + await expect(page.getByTestId("draft-panel").getByText("Approved")).toHaveCount(2); + await page.waitForTimeout(300); + expect(approvals).toBe(2); +}); + +test("last months: budget outline vs actual, over in red, track record", async ({ page }) => { + await openBudget(page); + const bars = page.getByTestId("history-month"); + await expect(bars).toHaveCount(4); + await expect(bars.nth(1)).toHaveAttribute("data-over", "true"); + await expect(bars.nth(2)).not.toHaveAttribute("data-over", "true"); + await expect(bars.nth(0).getByTestId("history-budget")).toHaveCount(0); // no budget then + await expect(page.getByTestId("track-record")).toContainText("1 of 2"); +}); + +test("budget has no layout shift while loading", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.addInitScript(() => { + (window as unknown as { cls: number }).cls = 0; + new PerformanceObserver((list) => { + for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[]) + if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value; + }).observe({ type: "layout-shift", buffered: true }); + }); + await openBudget(page, { ...sample, proposals: [line("p1", "Food", 6500)], delayMs: 1500 }); + await expect(page.getByTestId("draft-line")).toHaveCount(1); + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01); +}); + +test("budget fits a phone without sideways scroll", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openBudget(page, { ...sample, history: [5, 4, 3, 2, 1, 0].map((n) => ({ month: ym(n), budget: 100000, spent: 104200 })) }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + // the page clips rather than scrolls, so check nothing ends past the screen edge either + const ends = await page + .locator('header button, [data-testid="history-month"], [data-testid="budget-row"] > *') + .evaluateAll((els) => els.map((e) => [e.getAttribute("aria-label") ?? e.textContent, e.getBoundingClientRect().right] as const)); + for (const [what, right] of ends) expect(right, String(what)).toBeLessThanOrEqual(375); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 209ceea..7d0da98 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -17,8 +17,14 @@ export type HomeFixture = { summary?: { found: object[]; more?: number; worth_total?: number; activity?: object[] }; /** GET/PUT /api/budget; default: nothing set */ budget?: { monthly_budget: number | null; spent: number; categories: { category: string; limit: number; spent: number }[] }; + /** GET /api/budget?month=YYYY-MM for other months, keyed by month; default: the current `budget` */ + budgetMonths?: Record; + /** GET /api/budget/history months, oldest first; default [] */ + history?: { month: string; budget: number | null; spent: number }[]; /** POST /api/budget/draft proposals; default [] */ draft?: object[]; + /** GET /api/agent/proposals (pending); default [] */ + proposals?: object[]; currency?: string; /** proposal ids whose approve returns 409 (a newer draft replaced them) */ replaced?: string[]; @@ -130,8 +136,25 @@ export async function signIn( const s = home.summary ?? { found: review }; return route.fulfill({ json: { success: true, more: 0, worth_total: 0, activity: [], ...s } }); } - if (path === "/api/budget") - return route.fulfill({ json: { success: true, month: "", ...(home.budget ?? { monthly_budget: null, spent: 0, categories: [] }) } }); + if (path === "/api/budget") { + const none = { monthly_budget: null, spent: 0, categories: [] }; + // PUT is stateful like the real one: a null limit removes the category + if (route.request().method() === "PUT") { + const body = route.request().postDataJSON() as { monthly_budget?: number | null; categories?: { category: string; limit: number | null }[] }; + const b = (home.budget = structuredClone(home.budget ?? none)); + if ("monthly_budget" in body) b.monthly_budget = body.monthly_budget ?? null; + for (const c of body.categories ?? []) { + const row = b.categories.find((x) => x.category.toLowerCase() === c.category.toLowerCase()); + if (c.limit === null) b.categories = b.categories.filter((x) => x !== row); + else if (row) row.limit = c.limit; + else b.categories.push({ category: c.category, limit: c.limit, spent: 0 }); + } + } + const month = url.searchParams.get("month"); + const state = (month && home.budgetMonths?.[month]) || home.budget || none; + return route.fulfill({ json: { success: true, month: month ?? "", ...state } }); + } + if (path === "/api/budget/history") return route.fulfill({ json: { success: true, months: home.history ?? [] } }); if (path === "/api/budget/draft") return route.fulfill({ json: { success: true, proposals: home.draft ?? [] } }); const proposal = path.match(/^\/api\/agent\/proposals\/([^/]+)\/(approve|reject)$/); if (proposal && home.replaced?.includes(proposal[1])) @@ -143,7 +166,7 @@ export async function signIn( queue = queue.filter((q) => q.id !== decided[1]); return route.fulfill({ json: { item: { ...item, status: `${decided[2]}d` } } }); } - if (path === "/api/agent/proposals") return route.fulfill({ json: { proposals: [] } }); + if (path === "/api/agent/proposals") return route.fulfill({ json: { proposals: home.proposals ?? [] } }); if (path === "/chat/suggestions") return route.fulfill({ json: { suggestions: opts.chat?.suggestions ?? [] } }); if (path === "/chat/threads") return route.fulfill({ diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 66e04fe..b53d04b 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -185,7 +185,7 @@ test("coming up lists the next four bills", 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: "DRAFT BUDGET" })).toBeEnabled(); + await expect(page.getByRole("link", { name: "Draft budget" })).toBeVisible(); }); test("activity comes from the summary", async ({ page }) => { @@ -278,30 +278,10 @@ test("budget card says when over", async ({ page }) => { await expect(page.getByTestId("budget-card")).toContainText("₹3,412 over"); }); -const draftLine = (id: string, target: string, limit: number) => ({ - id, type: "set_budget", target, risk: "low", reason: "Averaged a month over Jul–Sep, rounded up.", - payload: { limit, basis: { months: [], average: limit } }, status: "pending", created_at: null, -}); - -test("draft budget opens proposals and approve all applies them", async ({ page }) => { - await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)] }); - await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByTestId("draft-line")).toHaveCount(2); - await expect(dialog).toContainText("₹4,000"); - const approvals = [page.waitForRequest(/proposals\/p1\/approve/), page.waitForRequest(/proposals\/p2\/approve/)]; - await dialog.getByRole("button", { name: "Approve all" }).click(); - await Promise.all(approvals); - await expect(dialog.getByText("Approved")).toHaveCount(2); - const refetch = page.waitForRequest((r) => r.url().endsWith("/api/budget") && r.method() === "GET"); - await page.keyboard.press("Escape"); - await refetch; -}); - -test("draft with no history explains why", async ({ page }) => { - await openHome(page, { ...typical, draft: [] }); - await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); - await expect(page.getByRole("dialog")).toContainText("Not enough history yet"); +test("the draft promo and the budget card lead to the Budget page", async ({ page }) => { + await openHome(page, typical); + await expect(page.getByRole("link", { name: "Draft budget" })).toHaveAttribute("href", "/budget?draft=1"); + await expect(page.getByTestId("budget-card").getByRole("link", { name: /Budget in/ })).toHaveAttribute("href", "/budget"); }); test("calendar uses the user's currency", async ({ page }) => { @@ -316,39 +296,6 @@ test("sidebar badge uses the count endpoint", async ({ page }) => { await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("2"); }); -test("draft dialog ignores a double click", async ({ page }) => { - await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)] }); - let approvals = 0; - page.on("request", (r) => r.url().includes("/proposals/") && r.url().endsWith("/approve") && approvals++); - await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); - await page.getByRole("dialog").getByRole("button", { name: "Approve all" }).dblclick(); - await expect(page.getByRole("dialog").getByText("Approved")).toHaveCount(2); - await page.waitForTimeout(300); - expect(approvals).toBe(2); -}); - -test("closing the draft dialog refreshes activity", async ({ page }) => { - await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000)] }); - await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); - await page.getByRole("dialog").getByRole("button", { name: "Approve all" }).click(); - await expect(page.getByRole("dialog").getByText("Approved")).toHaveCount(1); - const summary = page.waitForRequest((r) => r.url().endsWith("/api/home/summary")); - await page.keyboard.press("Escape"); - await summary; -}); - -test("a replaced draft says so", async ({ page }) => { - await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)], replaced: ["p1"] }); - let approvals = 0; - page.on("request", (r) => r.url().includes("/proposals/") && r.url().endsWith("/approve") && approvals++); - await page.getByRole("button", { name: "DRAFT BUDGET" }).click(); - const dialog = page.getByRole("dialog"); - await dialog.getByRole("button", { name: "Approve all" }).click(); - await expect(dialog).toContainText("This draft was replaced. Draft again."); - await page.waitForTimeout(300); - expect(approvals).toBe(1); -}); - const overlaps = (a: { x: number; y: number; width: number; height: number }, b: typeof a) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index d010ecf..203bba8 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -61,9 +61,9 @@ test("sidebar shows the new IA", async ({ page }) => { await signIn(page); await page.goto("/home"); const links = sidebar(page).locator('[data-sidebar="menu-button"]'); - await expect(links).toHaveText(["Home", "Spending", "Bills & subscriptions", "Receipts & statements", "Ask Lumen", /^Ask anything/]); + await expect(links).toHaveText(["Home", "Spending", "Bills & subscriptions", "Budget", "Receipts & statements", "Ask Lumen", /^Ask anything/]); // every group is labelled (owner, 2026-10-22) - for (const group of ["Overview", "Track", "Records", "Assistant"]) { + for (const group of ["Overview", "Track", "Plan", "Records", "Assistant"]) { await expect(sidebar(page).getByText(group, { exact: true })).toBeVisible(); } for (const old of ["Workspace", "Compare", "Financial command center"]) { diff --git a/frontend/src/app/(app)/budget/_components/categories.tsx b/frontend/src/app/(app)/budget/_components/categories.tsx new file mode 100644 index 0000000..59c00cf --- /dev/null +++ b/frontend/src/app/(app)/budget/_components/categories.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { PageCard } from "@/components/page/page-card"; +import type { BudgetState } from "@/lib/api/client"; +import { usedTone } from "@/lib/budget-math"; +import { useMoney } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +const TONE = { good: "bg-good", warn: "bg-amber-500", bad: "bg-bad" } as const; +const COLS = "grid grid-cols-[minmax(0,1.2fr)_76px_minmax(0,1fr)_minmax(0,1fr)] items-center gap-2 sm:gap-3 sm:grid-cols-[minmax(0,1.3fr)_100px_90px_minmax(0,1fr)_minmax(0,1.4fr)]"; + +// By category (spec 2026-10-22 §4.4): budget, spent, left and a used bar per category. On the current month the +// budget amount is a button that turns into an input of the same height (Enter saves, Esc cancels), and new +// categories are added inline. Past months are read-only. +export function BudgetCategories({ + budget, + total, + editable, + onSet, + loading, +}: { + budget: BudgetState | null; + total: number | null; + editable: boolean; + onSet: (category: string, limit: number) => Promise; + loading: boolean; +}) { + const money = useMoney(); + const [editing, setEditing] = useState(null); + const [adding, setAdding] = useState(false); + const cats = budget?.categories ?? []; + const sum = cats.reduce((s, c) => s + c.limit, 0); + const over = cats.filter((c) => usedTone(c.spent, c.limit) === "bad").length; + const close = cats.filter((c) => usedTone(c.spent, c.limit) === "warn").length; + + return ( + + {over > 0 && {over} over} + {close > 0 && {close} close} + + } + loading={loading} + skeletonHeight={180} + data-testid="budget-categories" + > + {cats.length === 0 && !adding ? ( +

+ No category budgets{editable ? " yet. Add one, or let Lumen draft them from your spending." : " this month."} +

+ ) : ( +
+
+ Category + Budget + Spent + Left + Used +
+ {cats.map((c) => { + const tone = usedTone(c.spent, c.limit); + const left = c.limit - c.spent; + return ( +
+ {c.category} + + {editing === c.category ? ( + setEditing(null)} + onSave={async (v) => { + await onSet(c.category, v); + setEditing(null); + }} + /> + ) : editable ? ( + + ) : ( + money(c.limit) + )} + + {money(c.spent)} + + {left < 0 ? `${money(-left)} over` : money(left)} + + + + + + {Math.round((c.spent / c.limit) * 100)}% + +
+ ); + })} + {adding && ( + setAdding(false)} + onSave={async (name, v) => { + await onSet(name, v); + setAdding(false); + }} + /> + )} +
+ )} + {(total !== null || editable) && ( +
+ {total !== null && cats.length > 0 && ( + + Categories add up to {money(sum)} + {sum < total ? ` · ${money(total - sum)} of your ${money(total)} is unassigned` : sum > total ? ` · ${money(sum - total)} more than your ${money(total)}` : ""} + + )} + {editable && !adding && ( + + )} +
+ )} +
+ ); +} + +function AmountInput({ label, initial, onSave, onCancel }: { label: string; initial: number; onSave: (v: number) => void; onCancel: () => void }) { + const [v, setV] = useState(String(initial)); + const ref = useRef(null); + useEffect(() => ref.current?.select(), []); + return ( + setV(e.target.value)} + onBlur={onCancel} + onKeyDown={(e) => { + if (e.key === "Escape") onCancel(); + if (e.key === "Enter" && Number(v) > 0) onSave(Number(v)); + }} + className="-mx-1.5 h-7 w-full max-w-[96px] rounded-md border border-border bg-card px-1.5 tabular-nums" + /> + ); +} + +function NewCategory({ onSave, onCancel }: { onSave: (name: string, v: number) => void; onCancel: () => void }) { + const [name, setName] = useState(""); + const [v, setV] = useState(""); + const ref = useRef(null); + useEffect(() => ref.current?.focus(), []); + const keys = (e: React.KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + if (e.key === "Enter" && name.trim() && Number(v) > 0) onSave(name.trim(), Number(v)); + }; + return ( +
+ setName(e.target.value)} onKeyDown={keys} maxLength={60} className="h-7 min-w-0 rounded-md border border-border bg-card px-1.5" /> + setV(e.target.value)} onKeyDown={keys} className="h-7 w-full max-w-[96px] rounded-md border border-border bg-card px-1.5" /> + Enter to save · Esc +
+ ); +} diff --git a/frontend/src/app/(app)/budget/_components/draft-panel.tsx b/frontend/src/app/(app)/budget/_components/draft-panel.tsx new file mode 100644 index 0000000..eb49b89 --- /dev/null +++ b/frontend/src/app/(app)/budget/_components/draft-panel.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Sparkles } from "lucide-react"; +import { agentApi, type BudgetState, type Proposal } from "@/lib/api/client"; +import { useMoney } from "@/lib/format"; +import { toast } from "@/lib/toast"; +import { cn } from "@/lib/utils"; + +export type DraftLine = Proposal & { decided?: "approve" | "reject" }; + +// Lumen's draft, on the page (spec 2026-10-22 §4.4; it was a dialog on Home). Each line is a pending set_budget +// proposal: the proposed limit against the one in force, the basis sentence, and Skip / Approve named for the category. +// Nothing changes until a line is approved. No month-bars glyph: the basis has no per-month amounts to draw. +export function DraftPanel({ + lines, + setLines, + budget, + monthName, + onDecided, +}: { + lines: DraftLine[]; + setLines: (f: (ls: DraftLine[] | null) => DraftLine[] | null) => void; + budget: BudgetState | null; + monthName: string; + onDecided: () => void; +}) { + const money = useMoney(); + const [deciding, setDeciding] = useState(false); + const [replaced, setReplaced] = useState(false); + const inFlight = useRef(false); // a ref, so a double click is blocked before React re-renders + // the limits before this draft: approving a line reloads the budget, and "was" must not become "no change" + const [current] = useState(() => new Map((budget?.categories ?? []).map((c) => [c.category.toLowerCase(), c.limit]))); + const was = (l: DraftLine) => current.get(l.target.toLowerCase()); + const limit = (l: DraftLine) => Number(l.payload.limit); + const pending = lines.filter((l) => !l.decided); + const taking = lines.filter((l) => l.decided !== "reject"); + const after = [...current.entries()].filter(([k]) => !taking.some((l) => l.target.toLowerCase() === k)).reduce((s, [, v]) => s + v, 0) + + taking.reduce((s, l) => s + limit(l), 0); + + async function decide(ids: string[], decision: "approve" | "reject") { + if (inFlight.current) return; + inFlight.current = true; + setDeciding(true); + try { + for (const id of ids) { + await agentApi.decide(id, decision); + setLines((ls) => ls?.map((l) => (l.id === id ? { ...l, decided: decision } : l)) ?? ls); + } + } catch (error) { + // a newer draft (from here or from Ask Lumen) retired these lines + if ((error as { response?: { status?: number } })?.response?.status === 409) setReplaced(true); + else toast.error("Couldn't save that decision."); + } finally { + inFlight.current = false; + setDeciding(false); + onDecided(); + } + } + + return ( +
+
+ +

Lumen's draft for {monthName}

+ {lines.length > 0 && ( + + {lines.length - pending.length} of {lines.length} decided + + )} +
+ {lines.length === 0 ? ( +

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

+ ) : ( + <> +

Each line is a monthly limit; nothing changes until you approve it.

+
+ {lines.map((l) => { + const old = was(l); + return ( +
+
+ {l.target} + {money(limit(l))} + + {old === undefined ? "new" : old === limit(l) ? "no change" : `was ${money(old)}`} + +
+
+

{l.reason}

+ + {l.decided ? ( + + {l.decided === "approve" ? "✓ Approved" : old === undefined ? "Skipped" : `Skipped · keeps ${money(old)}`} + + ) : ( + <> + + + + )} + +
+
+ ); + })} +
+ {replaced && ( +

+ This draft was replaced. Draft again. +

+ )} +
+ {pending.length > 0 && ( + + )} + {budget?.monthly_budget ? ( + + Categories would add up to {money(after)} of {money(budget.monthly_budget)} + + ) : null} +
+ + )} +
+ ); +} diff --git a/frontend/src/app/(app)/budget/_components/hero.tsx b/frontend/src/app/(app)/budget/_components/hero.tsx new file mode 100644 index 0000000..41e66e5 --- /dev/null +++ b/frontend/src/app/(app)/budget/_components/hero.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { CumulativeChart } from "@/components/page/cumulative-chart"; +import { PageCard } from "@/components/page/page-card"; +import { evenLine, pace } from "@/lib/budget-math"; +import { useMoney } from "@/lib/format"; +import { cumulative, dayIndex, period, type Txn } from "@/lib/home-math"; +import { cn } from "@/lib/utils"; + +// Budget's hero (spec 2026-10-22 §4.4): spent of total, a bar with the even-pace tick, what's left and safe per day, +// and the running total against a straight line to the budget. `editing` swaps the total for an input in place. +export function BudgetHero({ + month, + monthName, + today, + total, + spent, + txns, + editing, + onSave, + onCancel, + loading, +}: { + month: Date; + monthName: string; + today: Date; + total: number | null; + spent: number; + txns: Txn[]; + editing: boolean; + onSave: (total: number) => void; + onCancel: () => void; + loading: boolean; +}) { + const money = useMoney(); + const p = pace(total ?? 0, spent, today, month); + const days = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); + const span = period(month, new Date(month.getFullYear(), month.getMonth(), days)); + const t = p.evenPct === null ? days - 1 : dayIndex(span, today); + const over = total !== null && spent > total; + const tone = over ? "bad" : p.vsPace !== null && p.vsPace > 0 ? "warn" : "good"; + return ( + + {over ? "Over budget" : p.evenPct === null ? "Within budget" : tone === "warn" ? "Ahead of pace" : "On track"} + + ) + } + loading={loading} + skeletonHeight={250} + data-testid="budget-hero" + > +
+
+
+ {money(Math.round(spent))} + + {editing ? : total !== null ? `of ${money(total)}` : "no budget set"} + +
+ {total !== null && ( + <> +
+ + {p.evenPct !== null && ( + + )} +
+
+ {Math.round(p.pct * 100)}% spent + {p.evenPct !== null && Even pace today: {Math.round(p.evenPct * 100)}%} +
+
+ + {p.evenPct !== null && } + {p.perDay !== null && } +
+ + )} +
+
+
+ Pace + Actual + {total !== null && ( + Even pace to {money(total)} + )} +
+ +
+
+
+ ); +} + +function Stat({ label, value, testId, bad }: { label: string; value: string; testId: string; bad?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +function TotalInput({ initial, onSave, onCancel }: { initial: number | null; onSave: (v: number) => void; onCancel: () => void }) { + const [v, setV] = useState(initial ? String(initial) : ""); + const ref = useRef(null); + useEffect(() => ref.current?.focus(), []); + return ( + + ); +} diff --git a/frontend/src/app/(app)/budget/_components/history.tsx b/frontend/src/app/(app)/budget/_components/history.tsx new file mode 100644 index 0000000..ff8021f --- /dev/null +++ b/frontend/src/app/(app)/budget/_components/history.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { PageCard } from "@/components/page/page-card"; +import type { BudgetMonth } from "@/lib/api/client"; +import { runRate } from "@/lib/budget-math"; +import { useMoney } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +const H = 130; +const short = (m: string) => new Date(Number(m.slice(0, 4)), Number(m.slice(5)) - 1, 1).toLocaleString("en-US", { month: "short" }); + +// The last months (spec 2026-10-22 §4.4): each month's budget as a dashed outline (only when it had one) and what was +// spent as a bar inside it, green under and red over. This month is "so far", with a pale bar for where it's heading +// at the pace so far. Beside it the track record over the past months that had a budget. +export function BudgetHistory({ months, today, loading }: { months: BudgetMonth[]; today: Date; loading: boolean }) { + const money = useMoney(); + const now = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`; + const projected = (m: BudgetMonth) => (m.month === now ? runRate(m.spent, today) : m.spent); + const max = Math.max(...months.map((m) => Math.max(m.budget ?? 0, projected(m))), 1); + const h = (v: number) => Math.max(2, (v / max) * H); + const judged = months.filter((m) => m.month !== now && m.budget !== null); + const under = judged.filter((m) => m.spent <= m.budget!).length; + const overMonths = judged.filter((m) => m.spent > m.budget!).map((m) => new Date(Number(m.month.slice(0, 4)), Number(m.month.slice(5)) - 1, 1).toLocaleString("en-US", { month: "long" })); + + return ( + + Within budget + Over + Budget + + } + loading={loading} + skeletonHeight={176} + data-testid="budget-history" + > +
+
+ {months.map((m) => { + const cur = m.month === now; + const over = m.budget !== null && m.spent > m.budget; + return ( +
+ {/* amounts don't fit six across a phone; the bar's title still has them */} + {money(Math.round(m.spent))} +
+ {m.budget !== null && ( + + )} + {cur && } + +
+ + {short(m.month)} + {cur && · so far} + +
+ ); + })} +
+ {judged.length > 0 && ( +
+ Track record +
+ {under} of {judged.length} months under +
+

+ {overMonths.length ? `Over in ${overMonths.join(", ")}.` : "Under budget every month."} +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(app)/budget/budget-content.tsx b/frontend/src/app/(app)/budget/budget-content.tsx new file mode 100644 index 0000000..3b3d719 --- /dev/null +++ b/frontend/src/app/(app)/budget/budget-content.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ChevronLeft, ChevronRight, Pencil, Sparkles } from "lucide-react"; +import { PageHeader } from "@/components/page/page-header"; +import { Button } from "@/components/ui/button"; +import { budgetApi } from "@/lib/api/client"; +import { pace, usedTone } from "@/lib/budget-math"; +import { useMoney } from "@/lib/format"; +import { toast } from "@/lib/toast"; +import { Brief } from "../home/_components/brief"; +import { BudgetCategories } from "./_components/categories"; +import { DraftPanel, type DraftLine } from "./_components/draft-panel"; +import { BudgetHero } from "./_components/hero"; +import { BudgetHistory } from "./_components/history"; +import { useBudgetData, ym } from "./use-budget-data"; + +const monthYear = (d: Date) => d.toLocaleString("en-US", { month: "long", year: "numeric" }); +const monthLong = (d: Date) => d.toLocaleString("en-US", { month: "long" }); + +// Budget (UI revamp Phase 5d, spec 2026-10-22 §4.4): am I on track, and what should my plan be? Budgets are saved per +// month, so past months are shown as they were and are read-only; edits and the draft apply from this month on. +// History sits above By category so the cards whose height depends on the data come last (no layout shift). +export default function BudgetContent() { + const money = useMoney(); + const today = useMemo(() => { + const n = new Date(); + return new Date(n.getFullYear(), n.getMonth(), n.getDate()); + }, []); + const [month, setMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1)); + const current = ym(month) === ym(today); + const data = useBudgetData(month, today); + const loading = data.status === "loading"; + const [editingTotal, setEditingTotal] = useState(false); + const [lines, setLines] = useState(null); + const [draftKey, setDraftKey] = useState(0); + const [drafting, setDrafting] = useState(false); + const router = useRouter(); + const params = useSearchParams(); + const autoDraft = useRef(params.get("draft") === "1"); + + // pending lines from an earlier draft show once loaded; ?draft=1 (Home's promo) files a fresh one on arrival + useEffect(() => { + if (loading || lines !== null) return; + if (autoDraft.current) { + autoDraft.current = false; + router.replace("/budget", { scroll: false }); + void draft(); + } else if (data.pending.length) setLines(data.pending); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading]); + + async function draft() { + setDrafting(true); + try { + setLines(await budgetApi.draft()); + setDraftKey((k) => k + 1); + } catch { + toast.error("Couldn't draft a budget right now."); + } finally { + setDrafting(false); + } + } + + async function save(body: Parameters[0]) { + try { + data.setBudget(await budgetApi.put(body)); + data.reload(); + } catch { + toast.error("Couldn't save the budget."); + } + } + + const b = data.budget; + const catSum = (b?.categories ?? []).reduce((s, c) => s + c.limit, 0); + const total = b?.monthly_budget || catSum || null; + const spent = b?.spent ?? 0; + const brief = (() => { + if (loading) return null; + const overCats = (b?.categories ?? []).filter((c) => usedTone(c.spent, c.limit) === "bad").map((c) => c.category); + const overText = overCats.length === 1 ? ` ${overCats[0]} is the one line over.` : overCats.length > 1 ? ` ${overCats.length} categories are over.` : ""; + if (total === null) return current ? `No budget yet. You've spent ${money(Math.round(spent))} this month; set one below or let Lumen draft it.` : `No budget for ${monthLong(month)}. You spent ${money(Math.round(spent))}.`; + if (!current) return spent > total ? `${monthLong(month)} went ${money(Math.round(spent - total))} over its ${money(total)} budget.${overText}` : `${monthLong(month)} came in ${money(Math.round(total - spent))} under its ${money(total)} budget.${overText}`; + if (spent > total) return `You're ${money(Math.round(spent - total))} over this month's budget.${overText}`; + const p = pace(total, spent, today, month); + return p.vsPace! <= 0 ? `You're on track: ${money(Math.round(-p.vsPace!))} under where an even pace would put you.${overText}` : `You're ${money(Math.round(p.vsPace!))} ahead of an even pace, with ${p.daysLeft} days to go.${overText}`; + })(); + + const step = (d: number) => { + setEditingTotal(false); + setMonth((m) => new Date(m.getFullYear(), m.getMonth() + d, 1)); + }; + const actions = ( + <> +
+ + {monthYear(month)} + +
+ {current && ( + <> + + + + )} + + ); + + return ( +
+
+
+ } actions={actions} /> + { + setEditingTotal(false); + void save({ monthly_budget: v }); + }} + onCancel={() => setEditingTotal(false)} + loading={loading} + /> +
+ +
+
+ save({ categories: [{ category, limit }] })} + loading={loading} + /> + {lines && current && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/app/(app)/budget/page.tsx b/frontend/src/app/(app)/budget/page.tsx new file mode 100644 index 0000000..d83d3b4 --- /dev/null +++ b/frontend/src/app/(app)/budget/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import BudgetContent from "./budget-content"; + +export const metadata: Metadata = { title: "Budget" }; + +// useSearchParams (?draft=1) needs a Suspense boundary for the static build +export default function Page() { + return ( + + + + ); +} diff --git a/frontend/src/app/(app)/budget/use-budget-data.ts b/frontend/src/app/(app)/budget/use-budget-data.ts new file mode 100644 index 0000000..4731214 --- /dev/null +++ b/frontend/src/app/(app)/budget/use-budget-data.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { agentApi, budgetApi, type BudgetMonth, type BudgetState, type Proposal } from "@/lib/api/client"; +import type { Txn } from "@/lib/home-math"; +import { fetchTxns } from "../home/use-home-data"; + +export const ym = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fulfilled" ? r.value : fallback); + +export type BudgetData = { + status: "loading" | "ready"; + budget: BudgetState | null; + txns: Txn[]; + history: BudgetMonth[]; + /** set_budget proposals still waiting for a decision when the page opened */ + pending: Proposal[]; + /** after a save: replace the shown month's state */ + setBudget: (b: BudgetState) => void; + /** after budget decisions: refetch this month's budget and the history */ + reload: () => void; +}; + +// The Budget page's data (spec 2026-10-22 §4.4). The shown month's budget and transactions reload when the month +// changes, keeping the old numbers until the new ones arrive (like Spending); history and pending draft lines load once. +export function useBudgetData(month: Date, today: Date): BudgetData { + const [shown, setShown] = useState<{ budget: BudgetState | null; txns: Txn[] } | null>(null); + const [once, setOnce] = useState<{ history: BudgetMonth[]; pending: Proposal[] } | null>(null); + const key = ym(month); + const day = today.toDateString(); + + useEffect(() => { + let live = true; + const end = new Date(month.getFullYear(), month.getMonth() + 1, 0); + Promise.allSettled([budgetApi.get(key === ym(today) ? undefined : key), fetchTxns(month, end < today ? end : today)]).then(([b, t]) => { + if (live) setShown({ budget: value(b, null), txns: value(t, []) }); + }); + return () => { + live = false; + }; + // month is a fresh Date each render; its key is what matters + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, day]); + + useEffect(() => { + let live = true; + Promise.allSettled([budgetApi.history(6), agentApi.proposals()]).then(([h, p]) => { + if (live) setOnce({ history: value(h, []), pending: value(p, []).filter((x) => x.type === "set_budget") }); + }); + return () => { + live = false; + }; + }, [day]); + + const setBudget = useCallback((budget: BudgetState) => setShown((s) => (s ? { ...s, budget } : s)), []); + const reload = useCallback(() => { + Promise.allSettled([budgetApi.get(), budgetApi.history(6)]).then(([b, h]) => { + if (b.status === "fulfilled") setShown((s) => (s ? { ...s, budget: b.value } : s)); + if (h.status === "fulfilled") setOnce((o) => (o ? { ...o, history: h.value } : o)); + }); + }, []); + + return { + status: shown && once ? "ready" : "loading", + budget: shown?.budget ?? null, + txns: shown?.txns ?? [], + history: once?.history ?? [], + pending: once?.pending ?? [], + setBudget, + reload, + }; +} diff --git a/frontend/src/app/(app)/home/_components/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx index b611e2a..79ba7e6 100644 --- a/frontend/src/app/(app)/home/_components/budget-card.tsx +++ b/frontend/src/app/(app)/home/_components/budget-card.tsx @@ -1,10 +1,8 @@ "use client"; -import { useRef, useState } from "react"; -import { agentApi, budgetApi, type BudgetState, type Proposal } from "@/lib/api/client"; +import Link from "next/link"; +import type { BudgetState } from "@/lib/api/client"; import { useMoney } from "@/lib/format"; -import { toast } from "@/lib/toast"; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { PageCard } from "@/components/page/page-card"; @@ -20,7 +18,7 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg const pct = total ? Math.round((spent / total) * 100) : 0; return ( - + {total ? ( <>
@@ -72,126 +70,17 @@ export function BudgetCard({ budget, monthName, today, loading }: { budget: Budg ); } -type Line = Proposal & { decided?: "approve" | "reject" }; - -// "Let Lumen draft your budget": files set_budget proposals (POST /api/budget/draft) and lets you approve each line. -export function DraftBudgetPromo({ onChanged }: { onChanged: () => void }) { - const money = useMoney(); - const [open, setOpen] = useState(false); - const [busy, setBusy] = useState(false); - const [lines, setLines] = useState(null); - const [deciding, setDeciding] = useState(false); - const [replaced, setReplaced] = useState(false); - const inFlight = useRef(false); // a ref, so a double click is blocked before React re-renders - - async function draft() { - setBusy(true); - try { - setLines(await budgetApi.draft()); - setReplaced(false); - setOpen(true); - } catch { - toast.error("Couldn't draft a budget right now."); - } finally { - setBusy(false); - } - } - - async function decide(ids: string[], decision: "approve" | "reject") { - if (inFlight.current) return; - inFlight.current = true; - setDeciding(true); - try { - for (const id of ids) { - await agentApi.decide(id, decision); - setLines((ls) => ls?.map((l) => (l.id === id ? { ...l, decided: decision } : l)) ?? ls); - } - } catch (error) { - // a newer draft (from here or from Ask Lumen) retired these lines - if ((error as { response?: { status?: number } })?.response?.status === 409) setReplaced(true); - else toast.error("Couldn't save that decision."); - } finally { - inFlight.current = false; - setDeciding(false); - } - } - - const pending = (lines ?? []).filter((l) => !l.decided).map((l) => l.id); - +// "Let Lumen draft your budget": the draft itself lives on the Budget page (Phase 5d); ?draft=1 files it on arrival. +export function DraftBudgetPromo() { return (

Let Lumen draft your budget

Built from 3 months of your actual spending. You approve every line before it's saved.

- - { - 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"} - - ) : ( - <> - - - - )} - -
- ))} -
- {replaced && ( -

- This draft was replaced. Draft again. -

- )} - - - )} -
-
+
); } diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx index 122b663..ff4c2a1 100644 --- a/frontend/src/app/(app)/home/homeContent.tsx +++ b/frontend/src/app/(app)/home/homeContent.tsx @@ -105,7 +105,7 @@ export default function HomeContent() { {/* once you have category budgets, the promo has done its job */} - {!data.budget?.categories.length && } + {!data.budget?.categories.length && }
diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts index da74c73..94ee77b 100644 --- a/frontend/src/app/(app)/home/use-home-data.ts +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { aiAnalyticsApi, billsApi, budgetApi, homeApi, transactionApi, type BudgetState, type HomeSummary, type RecurringItem } from "@/lib/api/client"; import { parseDay, periods, type Range, type Txn } from "@/lib/home-math"; @@ -28,8 +28,6 @@ export type HomeData = { forecast: Forecast; summary: HomeSummary | null; budget: BudgetState | null; - /** refetch the summary and the budget (after budget decisions) */ - reload: () => void; }; const ymd = (d: Date) => @@ -114,13 +112,6 @@ export function useHomeData(range: Range, today: Date): HomeData { // eslint-disable-next-line react-hooks/exhaustive-deps }, [day]); - const reload = useCallback(() => { - // decisions are POSTs, which already cleared the cached summary, so this refetches it - Promise.allSettled([homeApi.summary(), budgetApi.get()]).then(([summary, budget]) => - setExtras((e) => (e ? { ...e, summary: value(summary, e.summary), budget: value(budget, e.budget) } : e)), - ); - }, []); - return { status: txns && extras ? "ready" : "loading", txns: txns?.txns ?? [], @@ -130,7 +121,6 @@ export function useHomeData(range: Range, today: Date): HomeData { reminders: extras?.reminders ?? [], summary: extras?.summary ?? null, budget: extras?.budget ?? null, - reload, forecast, }; } diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 4e54e0d..0dc6e33 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { CalendarClock, ChartLine, House, Receipt, Sparkles, type LucideIcon } from "lucide-react"; +import { CalendarClock, ChartLine, House, PieChart, Receipt, Sparkles, type LucideIcon } from "lucide-react"; import { Sidebar, @@ -26,12 +26,13 @@ import { eventBus, EVENTS } from "@/lib/events"; type Item = { label: string; url: string; icon: LucideIcon }; // Spec section 3. Every group is labelled (owner, 2026-10-22); labels say what you do, not what the backend is. -// Budget and Forecast (Plan) join once their pages exist. +// Forecast (Plan) joins once its page exists. const HOME: Item = { label: "Home", url: "/home", icon: House }; const TRACK: Item[] = [ { label: "Spending", url: "/spending", icon: ChartLine }, { label: "Bills & subscriptions", url: "/bills", icon: CalendarClock }, ]; +const BUDGET: Item = { label: "Budget", url: "/budget", icon: PieChart }; const RECEIPTS: Item = { label: "Receipts & statements", url: "/receipts", icon: Receipt }; const ASK: Item = { label: "Ask Lumen", url: "/ask", icon: Sparkles }; @@ -106,6 +107,12 @@ export function AppSidebar({ onAsk, ...props }: React.ComponentProps + + Plan + + + + Records diff --git a/frontend/src/components/page/page-header.tsx b/frontend/src/components/page/page-header.tsx index 38a4480..b98fe2b 100644 --- a/frontend/src/components/page/page-header.tsx +++ b/frontend/src/components/page/page-header.tsx @@ -17,7 +17,7 @@ export function PageHeader({

{title}

- {actions &&
{actions}
} + {actions &&
{actions}
}
{brief &&
{brief}
} {children &&
{children}
} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 4e638f8..c412267 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -696,8 +696,17 @@ export const homeApi = { summary: (): Promise => cached("/api/home/summary", async () => (await apiClient.get("/api/home/summary")).data), }; +/** One month of GET /api/budget/history: `budget` is null for a past month that had none. */ +export interface BudgetMonth { + month: string; + budget: number | null; + spent: number; +} + export const budgetApi = { - get: async (): Promise => (await apiClient.get("/api/budget")).data, + /** `month` "YYYY-MM"; default the current month. Limits are the ones in force that month. */ + get: async (month?: string): Promise => (await apiClient.get("/api/budget", { params: month ? { month } : {} })).data, + history: async (months = 6): Promise => (await apiClient.get("/api/budget/history", { params: { months } })).data.months, 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. */ diff --git a/frontend/src/lib/budget-math.ts b/frontend/src/lib/budget-math.ts new file mode 100644 index 0000000..781f031 --- /dev/null +++ b/frontend/src/lib/budget-math.ts @@ -0,0 +1,33 @@ +// Pure calculations behind the Budget page (UI revamp Phase 5d). No React, no fetching; unit tests in +// e2e/budget-math.spec.ts. + +const daysIn = (d: Date) => new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); +const sameMonth = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth(); + +// Where a month stands against its budget. The even-pace mark, days left, safe-per-day and the gap to an even pace +// only exist for the current month (null / 0 otherwise); safe-per-day is null once the budget is gone. +export function pace(total: number, spent: number, today: Date, month: Date) { + const days = daysIn(month); + const current = sameMonth(today, month); + const evenPct = current ? today.getDate() / days : null; + const left = total - spent; + const daysLeft = current ? days - today.getDate() + 1 : 0; + return { + pct: total > 0 ? spent / total : 0, + evenPct, + left, + daysLeft, + perDay: current && left > 0 ? left / daysLeft : null, + vsPace: evenPct === null ? null : spent - total * evenPct, + }; +} + +export function usedTone(spent: number, limit: number): "good" | "warn" | "bad" { + return spent > limit ? "bad" : spent >= limit * 0.9 ? "warn" : "good"; +} + +// The straight line from nothing to the whole budget on the last day, one value per day. +export const evenLine = (total: number, days: number) => Array.from({ length: days }, (_, i) => (total * (i + 1)) / days); + +// This month's total if the rest of it goes like it has so far. +export const runRate = (spent: number, today: Date) => (spent / today.getDate()) * daysIn(today);