diff --git a/backend/api/bills.py b/backend/api/bills.py new file mode 100644 index 0000000..1add729 --- /dev/null +++ b/backend/api/bills.py @@ -0,0 +1,72 @@ +"""UI revamp Phase 5c: recurring charges for the Bills page and Home's "Coming up". + +Detection is ai/recurring.find_recurring over the user's transactions (pure, no LLM). The user's choices live in +recurring_states: "not_a_bill" moves a vendor to `hidden`, "cancelled" keeps it listed as cancelled unless it was +charged again after the cancel ("charged_again"). Posting "active" forgets the choice. +""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy import select, text +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session + +from ai.recurring import _norm, find_recurring +from api.deps import current_user, rate_limit +from api.profile import get_engine + +router = APIRouter(prefix="/api/bills", tags=["bills"]) + + +class StateChange(BaseModel): + vendor: str = Field(min_length=1, max_length=200) + state: Literal["cancelled", "not_a_bill", "active"] + + +def _recurring(engine: Engine, user_id: str, today: date) -> dict: + from models import RecurringState + + with engine.connect() as conn: + rows = [dict(r) for r in conn.execute(text( + "SELECT vendor_name, date, total_amount, category FROM transactions WHERE user_id = :u"), {"u": user_id}).mappings()] + with Session(engine) as session: + states = {s.vendor_key: s for s in session.scalars(select(RecurringState).where(RecurringState.user_id == user_id))} + items, hidden = [], [] + for item in find_recurring(rows, today): + key = _norm(item["vendor"]) + saved = states.get(key) + if saved and saved.state == "not_a_bill": + hidden.append({"vendor": item["vendor"], "vendor_key": key}) + continue + state = "active" + if saved and saved.state == "cancelled": + state = "charged_again" if item["last_charge_date"] > saved.changed_at.date().isoformat() else "cancelled" + items.append({**item, "state": state}) + return {"success": True, "items": items, "hidden": hidden} + + +@router.get("/recurring", dependencies=[Depends(rate_limit())]) +def recurring(claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): + return _recurring(engine, claims["sub"], date.today()) + + +@router.post("/recurring/state", dependencies=[Depends(rate_limit())]) +def set_state(body: StateChange, claims: dict = Depends(current_user), engine: Engine = Depends(get_engine)): + from models import RecurringState + + user_id, key = claims["sub"], _norm(body.vendor) + with Session(engine) as session, session.begin(): + row = session.scalars(select(RecurringState).where(RecurringState.user_id == user_id, + RecurringState.vendor_key == key)).first() + if body.state == "active": + if row: + session.delete(row) + elif row: + row.state, row.changed_at = body.state, datetime.utcnow() + else: + session.add(RecurringState(user_id=user_id, vendor_key=key, state=body.state)) + return _recurring(engine, user_id, date.today()) diff --git a/backend/asgi.py b/backend/asgi.py index 5baaec3..b552a38 100644 --- a/backend/asgi.py +++ b/backend/asgi.py @@ -20,7 +20,7 @@ from fastapi.middleware.cors import CORSMiddleware import app as flask_module -from api import agent, budget, demo, documents, errors, files, home, keys, profile, review, statements +from api import agent, bills, budget, demo, documents, errors, files, home, keys, profile, review, statements from api.deps import llm_deadline from config import Config @@ -90,5 +90,5 @@ def create_app(*routers: APIRouter) -> FastAPI: app = create_app(documents.router, agent.router, demo.router, review.router, files.router, profile.router, statements.router, - budget.router, home.router, + budget.router, bills.router, home.router, keys.router) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index f220616..2b193f7 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -314,6 +314,21 @@ class Budget(db.Model): __table_args__ = (db.UniqueConstraint("user_id", "category", name="u_budget_user_category"),) +class RecurringState(db.Model): + """What the user said about a recurring charge on the Bills page (UI revamp Phase 5c): "cancelled" keeps it listed + (and flags a charge after the cancel), "not_a_bill" hides it. Keyed by ai.recurring._norm(vendor), the same key + the detection groups charges by. No row = active.""" + + __tablename__ = "recurring_states" + + id = db.Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = db.Column(String(36), nullable=False, index=True) + vendor_key = db.Column(db.String, nullable=False) + state = db.Column(db.String(20), nullable=False) # cancelled | not_a_bill + changed_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + __table_args__ = (db.UniqueConstraint("user_id", "vendor_key", name="u_recurring_state_user_vendor"),) + class AuditEvent(db.Model): """Append-only log of proposals and decisions (who, what, when).""" diff --git a/backend/tests/test_api_bills.py b/backend/tests/test_api_bills.py new file mode 100644 index 0000000..acf4a08 --- /dev/null +++ b/backend/tests/test_api_bills.py @@ -0,0 +1,107 @@ +"""UI revamp Phase 5c: recurring charges for the Bills page, with the user's saved choices (cancelled / not a bill).""" +from datetime import date, timedelta + +import jwt +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +A = "7a1c0000-0000-4000-8000-0000000000aa" +B = "7a1c0000-0000-4000-8000-0000000000bb" + + +def bearer(sub): + return {"Authorization": "Bearer " + jwt.encode({"sub": sub, "email": f"{sub}@x.com"}, "k" * 32, algorithm="HS256")} + + +def ago(days): + return (date.today() - timedelta(days=days)).isoformat() + + +@pytest.fixture +def setup(tmp_path, monkeypatch): + import models # noqa: F401 + import utils.auth + from api import bills + from asgi import create_app + from models.database import db as flask_db + from utils.limiter import limiter + + monkeypatch.setattr(utils.auth, "verify_token", lambda t: jwt.decode(t, options={"verify_signature": False})) + monkeypatch.setattr(limiter, "enabled", False) + engine = create_engine(f"sqlite:///{tmp_path / 'bills.db'}") + flask_db.metadata.create_all(engine) + app = create_app(bills.router) + app.dependency_overrides[bills.get_engine] = lambda: engine + return TestClient(app), engine + + +def charge(engine, user, vendor, when, amount=649.0, category="Entertainment"): + from models import Transaction + + with Session(engine) as s, s.begin(): + s.add(Transaction(user_id=user, vendor_name=vendor, invoice_number=f"{user}-{vendor}-{when}", date=when, + total_amount=amount, category=category)) + + +def monthly(engine, user, vendor, amount=649.0, category="Entertainment", latest=10): + for n in (latest + 60, latest + 30, latest): + charge(engine, user, vendor, ago(n), amount, category) + + +def items(c, user=A): + r = c.get("/api/bills/recurring", headers=bearer(user)) + assert r.status_code == 200, r.text + return r.json() + + +def test_recurring_lists_active_charges_per_user(setup): + c, engine = setup + monthly(engine, A, "Netflix") + monthly(engine, B, "Spotify", 119) + [item] = items(c)["items"] + assert item["vendor"] == "Netflix" and item["cadence"] == "monthly" and item["state"] == "active" + assert items(c)["hidden"] == [] + assert [i["vendor"] for i in items(c, B)["items"]] == ["Spotify"] + + +def test_cancel_then_charged_again(setup): + c, engine = setup + monthly(engine, A, "Netflix") + r = c.post("/api/bills/recurring/state", json={"vendor": "Netflix", "state": "cancelled"}, headers=bearer(A)) + assert r.status_code == 200 and r.json()["items"][0]["state"] == "cancelled" + # cancelled yesterday, charged again today: the user should know the cancel didn't stick + from models import RecurringState + + charge(engine, A, "Netflix", date.today().isoformat()) + with Session(engine) as s, s.begin(): + row = s.query(RecurringState).one() + row.changed_at = row.changed_at - timedelta(days=1) + assert items(c)["items"][0]["state"] == "charged_again" + + +def test_not_a_bill_hides_and_active_restores(setup): + c, engine = setup + monthly(engine, A, "Netflix") + r = c.post("/api/bills/recurring/state", json={"vendor": "NETFLIX", "state": "not_a_bill"}, headers=bearer(A)) + body = r.json() + assert body["items"] == [] and [h["vendor"] for h in body["hidden"]] == ["Netflix"] + r = c.post("/api/bills/recurring/state", json={"vendor": "Netflix", "state": "active"}, headers=bearer(A)) + assert [i["state"] for i in r.json()["items"]] == ["active"] and r.json()["hidden"] == [] + + +def test_states_are_per_user_and_match_vendor_spelling(setup): + c, engine = setup + monthly(engine, A, "Netflix.com") + monthly(engine, B, "Netflix.com") + c.post("/api/bills/recurring/state", json={"vendor": "netflix com", "state": "cancelled"}, headers=bearer(A)) + assert items(c)["items"][0]["state"] == "cancelled" + assert items(c, B)["items"][0]["state"] == "active" + + +def test_state_input_is_validated(setup): + c, _ = setup + assert c.post("/api/bills/recurring/state", json={"vendor": "X", "state": "deleted"}, headers=bearer(A)).status_code == 422 + assert c.post("/api/bills/recurring/state", json={"vendor": "", "state": "cancelled"}, headers=bearer(A)).status_code == 422 + assert c.get("/api/bills/recurring").status_code == 401 diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md index 29b7975..d628543 100644 --- a/docs/CODEBASE-GUIDE.md +++ b/docs/CODEBASE-GUIDE.md @@ -298,6 +298,15 @@ embeds the demo documents once per server and uploads the files in the backgroun pure in `lib/spending-math.ts` (tests: `e2e/spending-math.spec.ts`); data from `GET /transactions` (which now includes `source`) and `/api/home/summary` (spikes link to `/spending?category=`). The old Analytics and AI Insights pages, their components and the API calls only they used are gone. +- **Bills & subscriptions** (`app/(app)/bills/`, Phase 5c): "what's due, what recurs, what can I cut?" Everything + comes from `GET /api/bills/recurring` (`api/bills.py`): `ai/recurring.find_recurring` over the user's transactions + plus their saved choices in `recurring_states` (keyed by the detection's normalised vendor). `not_a_bill` moves a + vendor to `hidden`; `cancelled` keeps it listed as cancelled, or `charged_again` if a charge lands after the + cancel day; posting `active` forgets the choice (`POST /api/bills/recurring/state`). The page: three tiles (due in + 30 days, recurring per month with weekly/yearly spread, subscriptions that look stopped), a six-row month calendar + with each bill on its projected days (‹ month › switcher), Upcoming grouped This week / Next week / Later, and the + table with tabs, price-rise / looks-stopped / charged-again pills, Mark as cancelled, Not a bill, and How to cancel + (`/ask?draft=How do I cancel X?`). Maths in `lib/bills-math.ts` (tests: `e2e/bills-math.spec.ts`). - **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, @@ -305,9 +314,9 @@ embeds the demo documents once per server and uploads the files in the backgroun outlined, upcoming bill names on future days, always as tall as a six-week month) beside Recent transactions, then Lumen found as a list whose links act ("Ask Lumen" opens `/ask?draft=` with the question typed but not sent). The side column has Budget in (spent of total, % bar, top four categories by spend), Coming up (next four - bills), the draft-budget promo and Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, reminders, forecast, review - queue). `/api/analytics/reminders` sends `title`/`predicted_date`/`metadata`; `toReminders` turns that into bills - once (vendor patterns only, rows without a date skipped). A failed call empties only its card. Skeletons show only on the first load, so changing the chart range + bills), the draft-budget promo and Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, recurring + charges, forecast, review queue). Bills come from `GET /api/bills/recurring`: `toBills` keeps active ones that + don't look stopped and are due in the next 31 days. A failed call empties only its card. Skeletons show only on the first load, so changing the chart range never blanks other cards. All the maths is pure, in `lib/home-math.ts` (unit tests: `e2e/home-math.spec.ts`). **Phase 3 backend:** `GET /api/home/summary` (`api/home.py` over the pure `ai/home_signals.py`, no LLM) supplies Lumen found and Activity: diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md index bceeb27..54d743d 100644 --- a/docs/direction/STORY.md +++ b/docs/direction/STORY.md @@ -424,3 +424,20 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in tests; the e2e fixture now copies its data per test after a PUT leaked between tests. A screenshot against the approved mockup caught the wrapping period chips on phones, which a test then pinned. +## 34. Bills & subscriptions you can act on (UI revamp, Phase 5c) +- **S:** Bills had only the payment calendar and reminders cards, which read fields the backend never sent, and Home's + "Coming up" only had rows after a heavy analysis had run. Lumen already detected recurring charges well, but only the + agent and Home's signals used it. +- **T:** One page answering "what's due, what recurs, what can I cut?" from that detection, where telling Lumen + "cancelled" or "that's not a bill" is remembered, and noticing when a cancel didn't stick. +- **A:** A small `/api/bills` (TDD): recurring charges plus a `recurring_states` table keyed by the detection's own + normalised vendor, so "NETFLIX.com" and "Netflix com" are one choice; a charge after the cancel day flips the row to + "charged again". Home's bills switched to it. Pure maths (`lib/bills-math.ts`: monthly equivalents, projected due days + per cadence, a 30-day agenda, the tiles) under unit tests, then the page: tiles, a six-row month calendar that never + changes height, Upcoming, and a table whose actions save in place, with Undo for cancelled and hidden vendors and + "How to cancel" opening Ask with the question typed. Removed the interim list and the last caller of the old reminders. +- **R:** Backend 986 passed (5 new: per-user states, cancel → charged again, hide/restore, vendor spelling, + validation). Frontend: 10 Bills e2e checks (tiles, the calendar across months without moving, groups, tabs, cancel and + undo, hide and undo, the Ask link, CLS under 0.01, tabs on one line at 375 px) and 4 maths tests. A screenshot against + the mockup caught the wrapping tabs on phones, now pinned by a test. + 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 b8d91ab..24d59a1 100644 --- a/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md +++ b/docs/superpowers/plans/2026-10-22-ui-revamp-phase5.md @@ -238,8 +238,33 @@ Branch: `ui-revamp/phase5b-spending`, stacked on `ui-revamp/phase5-pages` (#97). - STORY 34, codebase guide (Spending section), spec §12 tick (two `

`s). - Full checks (backend, eval, lint, build into `.next-build`, e2e) green, screenshot vs `spending.html` at 1440 and 375, then PR into `ui-revamp/phase5-pages` (retarget to `refactor` as the stack lands). -## Phase 5c — Bills & subscriptions (outline) -Backend (TDD): `RecurringState` table (`user_id`, `vendor_key`, `state`, `changed_at`); `GET /api/bills/recurring` (wraps `find_recurring`, applies states: `not_a_bill` hidden, `cancelled` dimmed unless charged after `changed_at`); `POST /api/bills/recurring/state {vendor, state}`; Home's Coming up switches to it. Frontend: tiles, month calendar + Upcoming agenda (This week / Next week / Later), subscriptions table with tabs and actions (Mark as cancelled, Not a bill + Hidden undo, How to cancel → `/ask?draft=How do I cancel ?`). STORY 34. +## Phase 5c — Bills & subscriptions (detailed) + +Branch: `ui-revamp/phase5c-bills`, stacked on `ui-revamp/phase5b-spending` (#98). Mockup `bills.html`; spec §4.3 and the builder-check decision on saved states. + +### Task C1: recurring API with saved states (backend, TDD) +- Model `RecurringState` (`recurring_states`: id, user_id, vendor_key = `ai.recurring._norm(vendor)`, state `cancelled|not_a_bill`, changed_at; unique (user_id, vendor_key)); created by `create_all` like `budgets`. +- `api/bills.py`, router `/api/bills`, registered in `asgi.py`: + - `GET /recurring` → `{success, items: [...find_recurring fields + "state": "active"|"cancelled"|"charged_again"], hidden: [{vendor, vendor_key}]}`. `not_a_bill` vendors leave `items` and go to `hidden`; `cancelled` shows `state: "cancelled"` unless `last_charge_date` is after `changed_at`'s date, then `"charged_again"`. + - `POST /recurring/state {vendor, state: "cancelled"|"not_a_bill"|"active"}` → upsert (active deletes the row); returns the new GET payload. +- Tests (`tests/test_api_bills.py`, setup like `test_api_budget.py`): per-user isolation; three monthly Netflix charges listed active; cancel → cancelled; a charge after cancel → charged_again; not_a_bill → in hidden only; active restores; vendor key ignores case/punctuation ("NETFLIX.com" vs "Netflix com"). + +### Task C2: Home reads bills from the recurring API +- `billsApi.recurring()` / `billsApi.setState(vendor, state)` in `lib/api/client.ts`; `use-home-data` maps items with `next_due_date` in the next 31 days, not `possibly_stopped`, state active → `Reminder` (`toBills`), replacing `/api/analytics/reminders` (which only had rows after the old analysis ran; spec §12 5c item). +- e2e fixture serves `/api/bills/recurring` built from `home.reminders` (vendor→vendor, date→next_due_date, amount→typical_amount, monthly) and records state POSTs; Home's bill tests stay unchanged in intent. +- Tests: Coming up / bill pins / calendar names come from the recurring route; a cancelled or stopped item is not a bill on Home. + +### Task C3: the Bills page +- `app/(app)/bills/` (replaces the interim list): `PageHeader` "Bills & subscriptions" with a ‹ month › switcher; brief ("Rent of ₹X is due . N subscriptions look stopped, about ₹Y a month." from real items only). +- Three tiles: Due in the next 30 days (sum + count), Recurring every month (monthly equivalents: weekly×52/12, yearly/12; active items only), Subscriptions that look stopped (`possibly_stopped`, count + ₹/month). +- Month calendar (Monday first) with each item's projected due days in the shown month (monthly: next_due_date's day clamped; weekly: every 7 days from next_due_date; yearly: if the month matches), today outlined, past-due days muted; beside it Upcoming (next 30 days) grouped This week / Next week / Later with group totals. +- Table with tabs Subscriptions / Bills / All (kind subscription vs bill+recurring): VendorChip, cadence, last charged, next, amount; pills: price rise (from → to), looks stopped, charged again after you cancelled. Actions per row: Mark as cancelled, Not a bill, How to cancel (→ `/ask?draft=How do I cancel ?`). Cancelled rows dimmed in a group at the bottom with Undo; a "Hidden (N)" footer lists not-a-bill vendors with Undo. +- Tests: tiles' numbers; calendar shows a bill on its projected day for this and next month; tabs filter; Mark as cancelled POSTs and moves the row to Cancelled without the table jumping (bounding box of the table's first row); Not a bill hides it and Undo brings it back; How to cancel href; month switching keeps the calendar box; CLS < 0.01 while loading. + +### Task C4: clean-up, docs, checks, PR +- Remove the interim `bills/upcoming-bills.tsx` and `aiAnalyticsApi.getReminders` if unused. +- STORY 34, guide (Bills), spec §12 (tick the 5c item). +- Full checks green, screenshot vs `bills.html` at 1440 and 375, PR stacked on #98. ## Phase 5d — Budget page (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. 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 820a5ce..b03b370 100644 --- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md +++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md @@ -240,7 +240,7 @@ Small items found while building, parked here so they aren't lost. Tick them off - [x] **Phase 5.** Thread rail: disable Keep/Delete while an answer is in flight; move focus to "Keep" when the confirm row opens; the trash icon is hover-only, so it's invisible on touch (mobile Sheet). *(Phase 4 review)* *(Done in 5a.)* - [x] **Phase 5.** Below 768px the sidebar is a Sheet, and `AskPalette` renders inside it, so Ctrl+K on a tablet keyboard does nothing until the sheet is open. Render the palette outside ``. *(Phase 4 review)* *(Done in 5a: the palette lives in app-shell.)* - [ ] **Later.** Suggested question "What's waiting for my review?": check the agent can answer it (no review-queue tool). `price_rise` could use its subject ("Why did Netflix go up?"). *(Phase 4 review)* -- [ ] **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)* +- [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)* - [ ] **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/bills-math.spec.ts b/frontend/e2e/bills-math.spec.ts new file mode 100644 index 0000000..58bf5d0 --- /dev/null +++ b/frontend/e2e/bills-math.spec.ts @@ -0,0 +1,46 @@ +// Node-only unit tests (no browser) for the Bills page's calculations (UI revamp Phase 5c). +import { test, expect } from "@playwright/test"; +import { dueDays, monthlyEquivalent, tiles, upcoming } from "../src/lib/bills-math"; +import type { RecurringItem } from "../src/lib/api/client"; + +const item = (vendor: string, cadence: RecurringItem["cadence"], next: string, amount: number, extra: Partial = {}): RecurringItem => ({ + vendor, category: "Bills", cadence, typical_amount: amount, last_charge_date: "2026-09-24", next_due_date: next, + charges_count: 3, price_change: null, kind: "bill", possibly_stopped: false, state: "active", ...extra, +}); +const today = new Date(2026, 9, 22); // Thu 22 Oct 2026 + +test("monthlyEquivalent spreads weekly and yearly charges per month", () => { + expect(monthlyEquivalent(item("Rent", "monthly", "2026-10-24", 25000))).toBe(25000); + expect(monthlyEquivalent(item("Maid", "weekly", "2026-10-24", 1200))).toBe(5200); + expect(monthlyEquivalent(item("Prime", "yearly", "2026-11-14", 1500))).toBe(125); +}); + +test("dueDays projects each cadence onto a month", () => { + expect(dueDays(item("Rent", "monthly", "2026-10-24", 1), 2026, 9)).toEqual([24]); + expect(dueDays(item("Rent", "monthly", "2026-10-24", 1), 2026, 10)).toEqual([24]); + expect(dueDays(item("Gym", "monthly", "2026-10-31", 1), 2026, 10)).toEqual([30]); // November has 30 days + expect(dueDays(item("Maid", "weekly", "2026-10-24", 1), 2026, 9)).toEqual([3, 10, 17, 24, 31]); + expect(dueDays(item("Prime", "yearly", "2026-11-14", 1), 2026, 10)).toEqual([14]); + expect(dueDays(item("Prime", "yearly", "2026-11-14", 1), 2026, 9)).toEqual([]); +}); + +test("upcoming groups the next 30 days into this week, next week and later", () => { + const g = upcoming([item("Rent", "monthly", "2026-10-24", 25000), item("Jio", "monthly", "2026-10-27", 999), item("Netflix", "monthly", "2026-11-12", 649), item("Old", "monthly", "2026-12-30", 5)], today); + expect(g.map((x) => [x.label, x.rows.map((r) => r.vendor), x.total])).toEqual([ + ["This week", ["Rent"], 25000], + ["Next week", ["Jio"], 999], + ["Later", ["Netflix"], 649], + ]); +}); + +test("tiles: due soon, recurring per month, and subscriptions that look stopped", () => { + const t = tiles([ + item("Rent", "monthly", "2026-10-24", 25000), + item("Prime", "yearly", "2027-03-14", 1500, { kind: "subscription" }), + item("Spotify", "monthly", "2026-08-02", 119, { kind: "subscription", possibly_stopped: true }), + item("Gym", "monthly", "2026-10-30", 1500, { state: "cancelled" }), + ], today); + expect(t.dueSoon).toEqual({ total: 25000, count: 1 }); + expect(t.perMonth).toBe(25125); // rent + prime/12; stopped and cancelled ones don't count + expect(t.stopped).toEqual({ count: 1, perMonth: 119 }); +}); diff --git a/frontend/e2e/bills.spec.ts b/frontend/e2e/bills.spec.ts new file mode 100644 index 0000000..acf74c7 --- /dev/null +++ b/frontend/e2e/bills.spec.ts @@ -0,0 +1,124 @@ +import { test, expect, type Page } from "@playwright/test"; +import { signIn, type HomeFixture } from "./fixtures"; + +const now = new Date(); +const inDays = (n: number) => { + const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + n); + return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}`; +}; +const item = (vendor: string, next: number, amount: number, kind: string, extra: object = {}) => ({ + vendor, category: kind === "bill" ? "Rent" : "Entertainment", cadence: "monthly", typical_amount: amount, + last_charge_date: inDays(next - 30), next_due_date: inDays(next), charges_count: 3, price_change: null, kind, + possibly_stopped: false, state: "active", ...extra, +}); +const sample: HomeFixture = { + recurring: [ + item("Rent", 2, 25000, "bill"), + item("Netflix", 9, 649, "subscription", { price_change: { previous: 499, latest: 649, percent: 30.1 } }), + item("Spotify", -50, 119, "subscription", { possibly_stopped: true }), + { ...item("Prime", 120, 1500, "subscription"), cadence: "yearly" }, + ], +}; +async function openBills(page: Page, home: HomeFixture = sample) { + await signIn(page, { home }); + await page.goto("/bills"); +} +const table = (page: Page) => page.getByTestId("recurring-table"); + +test("bills: one heading and the three tiles", async ({ page }) => { + await openBills(page); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Bills & subscriptions"); + await expect(page.getByTestId("tile-due")).toContainText("₹25,649"); + await expect(page.getByTestId("tile-due")).toContainText("2 bills"); + await expect(page.getByTestId("tile-monthly")).toContainText("₹25,774"); + await expect(page.getByTestId("tile-stopped")).toContainText("₹119"); + await expect(page.getByTestId("tile-stopped")).toContainText("Spotify"); +}); + +test("the calendar shows each bill on its day, month after month, without moving", async ({ page }) => { + await openBills(page); + const rent = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2); + const cal = page.getByTestId("bill-calendar"); + const before = (await cal.boundingBox())!; + if (rent.getMonth() !== now.getMonth()) await page.getByRole("button", { name: "Next month" }).click(); + await expect(cal.getByTestId(`bill-day-${rent.getDate()}`)).toContainText("Rent"); + await page.getByRole("button", { name: "Next month" }).click(); + await expect(cal.getByTestId(`bill-day-${Math.min(rent.getDate(), 28)}`)).toBeVisible(); + await expect(cal).toContainText("Rent"); + const after = (await cal.boundingBox())!; + expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1); +}); + +test("upcoming groups the next 30 days", async ({ page }) => { + await openBills(page); + const up = page.getByTestId("upcoming"); + await expect(up).toContainText("Rent"); + await expect(up).toContainText("Netflix"); + await expect(up).not.toContainText("Spotify"); // looks stopped +}); + +test("tabs: subscriptions vs bills", async ({ page }) => { + await openBills(page); + await table(page).getByRole("tab", { name: /Subscriptions/ }).click(); + await expect(table(page).getByTestId("recurring-row")).toHaveCount(3); + await expect(table(page)).not.toContainText("Rent"); + await table(page).getByRole("tab", { name: /^Bills/ }).click(); + await expect(table(page).getByTestId("recurring-row")).toHaveCount(1); + await expect(table(page).getByTestId("recurring-row")).toContainText("Rent"); + await expect(table(page).getByRole("tab", { name: /Subscriptions/ })).toContainText("3"); +}); + +test("mark as cancelled moves the row into Cancelled, and undo brings it back", async ({ page }) => { + const posts: unknown[] = []; + page.on("request", (r) => r.method() === "POST" && r.url().includes("/api/bills/recurring/state") && posts.push(r.postDataJSON())); + await openBills(page); + const netflix = table(page).getByTestId("recurring-row").filter({ hasText: "Netflix" }); + await expect(netflix).toContainText("₹499 → ₹649"); + await netflix.getByRole("button", { name: "Mark as cancelled" }).click(); + await expect(page.getByTestId("cancelled-group")).toContainText("Netflix"); + expect(posts).toEqual([{ vendor: "Netflix", state: "cancelled" }]); + await page.getByTestId("cancelled-group").getByRole("button", { name: "Undo" }).click(); + await expect(page.getByTestId("cancelled-group")).toHaveCount(0); + expect(posts.at(-1)).toEqual({ vendor: "Netflix", state: "active" }); +}); + +test("not a bill hides it; hidden lists it with undo", async ({ page }) => { + await openBills(page); + await table(page).getByTestId("recurring-row").filter({ hasText: "Rent" }).getByRole("button", { name: "Not a bill" }).click(); + await expect(table(page).getByTestId("recurring-row").filter({ hasText: "Rent" })).toHaveCount(0); + await page.getByRole("button", { name: "Hidden (1)" }).click(); + await expect(page.getByTestId("hidden-list")).toContainText("Rent"); + await page.getByTestId("hidden-list").getByRole("button", { name: "Undo" }).click(); + await expect(table(page).getByTestId("recurring-row").filter({ hasText: "Rent" })).toHaveCount(1); +}); + +test("how to cancel opens Ask with the question typed", async ({ page }) => { + await openBills(page); + const link = table(page).getByTestId("recurring-row").filter({ hasText: "Netflix" }).getByRole("link", { name: "How to cancel" }); + await expect(link).toHaveAttribute("href", "/ask?draft=How%20do%20I%20cancel%20Netflix%3F"); +}); + +test("bills has no layout shift while loading", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.addInitScript(() => { + (window as unknown as { cls: number }).cls = 0; + new PerformanceObserver((list) => { + for (const e of list.getEntries() as unknown as { value: number; hadRecentInput: boolean }[]) + if (!e.hadRecentInput) (window as unknown as { cls: number }).cls += e.value; + }).observe({ type: "layout-shift", buffered: true }); + }); + await openBills(page, { ...sample, delayMs: 1500 }); + await expect(page.getByTestId("brief")).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as unknown as { cls: number }).cls)).toBeLessThan(0.01); +}); + +test("tabs stay on one line at phone width", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openBills(page); + for (const name of [/^All/, /^Subscriptions/, /^Bills/]) { + const box = (await table(page).getByRole("tab", { name }).boundingBox())!; + expect(box.height, String(name)).toBeLessThan(28); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index b1a2a23..209ceea 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -8,6 +8,8 @@ import type { Page } from "@playwright/test"; export type HomeFixture = { txns?: { id: string; vendor_name: string; date: string; total_amount: number; category: string | null; created_at: string | null }[]; reminders?: { id: string; vendor: string; amount: number; date: string; type: string; category?: string }[]; + /** GET /api/bills/recurring items as the API sends them; default: built from `reminders` (monthly, active) */ + recurring?: Record[]; forecast?: { dates: string[]; values: number[] } | "fail"; delayMs?: number; forecastDelayMs?: number; @@ -40,6 +42,7 @@ export async function signIn( ): Promise { // a copy: routes like the transactions PUT change it, and tests share their fixture objects const home: HomeFixture = structuredClone(opts.home ?? {}); + const billStates = new Map(); // vendor (lower case) → cancelled | not_a_bill | active // a tiny stateful chat backend: POST /chat creates or extends a thread, the list and messages follow const threads = (opts.chat?.threads ?? []).map((t) => ({ ...t, messages: [...(t.messages ?? [])] })); let nextThread = 1; @@ -73,6 +76,24 @@ export async function signIn( const url = new URL(route.request().url()); const path = url.pathname; if (home.delayMs) await new Promise((r) => setTimeout(r, home.delayMs)); + // recurring charges (api/bills.py): the fixture's items, or simple monthly ones built from `reminders`; state + // changes are kept, like the real table + if (path === "/api/bills/recurring" || path === "/api/bills/recurring/state") { + if (route.request().method() === "POST") { + const { vendor, state } = route.request().postDataJSON() as { vendor: string; state: string }; + billStates.set(vendor.toLowerCase(), state); + } + const all = (home.recurring ?? (home.reminders ?? []).filter((r) => r.vendor).map((r) => ({ + vendor: r.vendor, category: r.category ?? null, cadence: "monthly", typical_amount: r.amount, + last_charge_date: r.date, next_due_date: r.date, charges_count: 3, price_change: null, kind: "subscription", + possibly_stopped: false, state: "active", + }))) as { vendor: string; state: string }[]; + const items = all + .map((i) => ({ ...i, state: billStates.get(i.vendor.toLowerCase()) ?? i.state })) + .filter((i) => i.state !== "not_a_bill"); + const hidden = all.filter((i) => billStates.get(i.vendor.toLowerCase()) === "not_a_bill").map((i) => ({ vendor: i.vendor, vendor_key: i.vendor.toLowerCase() })); + return route.fulfill({ json: { success: true, items, hidden } }); + } // category edits from Spending's table (stateful, like the real PUT) const edit = path.match(/^\/transactions\/([^/]+)$/); if (edit && route.request().method() === "PUT") { @@ -91,19 +112,6 @@ export async function signIn( const data = all.slice((page - 1) * size, page * size); return route.fulfill({ json: { success: true, data, pagination: { page, has_next: page * size < all.length, total_items: all.length } } }); } - // the real shape (ai/pattern_detection.generate_reminders), built from the fixture's simpler rows; a row with - // no vendor is a category pattern ("You usually buy Groceries around the 5th") - if (path === "/api/analytics/reminders") { - const reminders = (home.reminders ?? []).map((r) => ({ - title: `Upcoming: ${r.vendor || r.category}`, - description: "", - reminder_type: "pattern_based", - predicted_date: r.date, - pattern_id: r.id, - metadata: { pattern_type: r.vendor ? "recurring" : "day_of_month", vendor_name: r.vendor || null, category: r.category ?? null, average_amount: r.amount }, - })); - return route.fulfill({ json: { success: true, reminders, count: reminders.length } }); - } if (path === "/api/analytics/forecast") { if (home.forecastDelayMs) await new Promise((r) => setTimeout(r, home.forecastDelayMs)); if (home.forecast === "fail") return route.fulfill({ status: 500, json: { error: "boom" } }); diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index 93beeca..66e04fe 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -523,3 +523,16 @@ for (const [day, range] of [["2026-10-26", "1M"], ["2026-10-04", "1M"], ["2026-1 } }); } + +test("home's bills come from the recurring charges, active ones only", async ({ page }) => { + const item = (vendor: string, days: number, extra: object = {}) => ({ + vendor, category: "Entertainment", cadence: "monthly", typical_amount: 649, last_charge_date: inDays(days - 30), + next_due_date: inDays(days), charges_count: 3, price_change: null, kind: "subscription", possibly_stopped: false, state: "active", ...extra, + }); + await openHome(page, { + ...typical, + recurring: [item("Netflix", 2), item("Spotify", 3, { state: "cancelled" }), item("Gym", 4, { possibly_stopped: true }), item("Rent", 45)], + }); + await expect(page.getByTestId("bill-row")).toHaveCount(1); + await expect(page.getByTestId("bill-row")).toContainText("Netflix"); +}); diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 75656f9..d010ecf 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -272,6 +272,6 @@ test("spending and bills show no cards that can never load", async ({ page }) => for (const dead of ["Could not load payment calendar.", "Could not load reminders.", "Spending Trends"]) await expect(page.getByText(dead, { exact: true })).toHaveCount(0); await page.goto("/bills"); - await expect(page.getByTestId("bill-row")).toContainText("Netflix"); + await expect(page.getByTestId("recurring-table")).toContainText("Netflix"); await expect(page.getByText(/Could not load/)).toHaveCount(0); }); diff --git a/frontend/src/app/(app)/bills/bills-content.tsx b/frontend/src/app/(app)/bills/bills-content.tsx new file mode 100644 index 0000000..8a3f74c --- /dev/null +++ b/frontend/src/app/(app)/bills/bills-content.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { PageCard } from "@/components/page/page-card"; +import { PageHeader } from "@/components/page/page-header"; +import { VendorChip } from "@/components/page/vendor-chip"; +import { billsApi, type RecurringItem, type RecurringState } from "@/lib/api/client"; +import { dueDays, tiles, upcoming } from "@/lib/bills-math"; +import { useMoney } from "@/lib/format"; +import { parseDay } from "@/lib/home-math"; +import { toast } from "@/lib/toast"; +import { cn } from "@/lib/utils"; +import { Brief } from "../home/_components/brief"; + +type Tab = "subscriptions" | "bills" | "all"; +const TAB_LABEL: Record = { all: "All", subscriptions: "Subscriptions", bills: "Bills" }; +const monthYear = (y: number, m: number) => new Date(y, m, 1).toLocaleString("en-US", { month: "long", year: "numeric" }); +const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" }); +const weekday = (d: Date) => d.toLocaleString("en-GB", { weekday: "long" }); +const isSubscription = (i: RecurringItem) => i.kind === "subscription"; + +// Bills & subscriptions (UI revamp Phase 5c, spec 2026-10-22 §4.3): what's due, what recurs, what can I cut? +// Everything comes from GET /api/bills/recurring; the user's "cancelled" / "not a bill" choices are saved there. +export default function BillsContent() { + const money = useMoney(); + const today = useMemo(() => { + const n = new Date(); + return new Date(n.getFullYear(), n.getMonth(), n.getDate()); + }, []); + const [data, setData] = useState(null); + const [shown, setShown] = useState({ y: today.getFullYear(), m: today.getMonth() }); + const [tab, setTab] = useState("all"); + const [showHidden, setShowHidden] = useState(false); + + useEffect(() => { + billsApi.recurring().then(setData, () => setData({ items: [], hidden: [] })); + }, []); + + async function choose(vendor: string, state: "cancelled" | "not_a_bill" | "active") { + try { + setData(await billsApi.setState(vendor, state)); + } catch { + toast.error("Couldn't save that. Try again."); + } + } + + const loading = data === null; + const items = data?.items ?? []; + const t = tiles(items, today); + const groups = upcoming(items, today); + const next = groups[0]?.rows[0]; + const brief = loading + ? null + : items.length === 0 + ? "Lumen hasn't spotted any regular payments yet. They show up here after three similar charges." + : [ + next && `${next.vendor} of ${money(next.amount)} is due ${next.date.getTime() === today.getTime() ? "today" : weekday(next.date)}.`, + t.stopped.count > 0 && + `${t.stopped.count} ${t.stopped.count === 1 ? "subscription looks" : "subscriptions look"} stopped, about ${money(t.stopped.perMonth)} a month.`, + ] + .filter(Boolean) + .join(" ") || `${money(t.perMonth)} goes out every month in regular payments.`; + + const listed = items.filter((i) => i.state !== "cancelled"); + const counts = { subscriptions: listed.filter(isSubscription).length, bills: listed.filter((i) => !isSubscription(i)).length, all: listed.length }; + const rows = listed.filter((i) => tab === "all" || (tab === "subscriptions") === isSubscription(i)); + const cancelled = items.filter((i) => i.state === "cancelled"); + + const step = (d: number) => setShown(({ y, m }) => ({ y: new Date(y, m + d, 1).getFullYear(), m: new Date(y, m + d, 1).getMonth() })); + const monthSwitcher = ( +
+ + {monthYear(shown.y, shown.m)} + +
+ ); + + return ( +
+
+
+ } actions={monthSwitcher} /> + +
+ +
{money(t.dueSoon.total)}
+

+ {t.dueSoon.count} {t.dueSoon.count === 1 ? "bill" : "bills"} +

+
+ +
{money(t.perMonth)}
+

Yearly and weekly ones spread per month

+
+ +
+ {money(t.stopped.perMonth)} a month +
+

+ {t.stopped.count === 0 + ? "None right now" + : items.filter((i) => i.possibly_stopped && isSubscription(i) && i.state !== "cancelled").map((i) => i.vendor).join(", ")} +

+
+
+ +
+ + Next 30 days} loading={loading} skeletonHeight={420} data-testid="upcoming"> + {groups.length === 0 ? ( +

Nothing due in the next 30 days.

+ ) : ( + groups.map((g) => ( +
+
+ {g.label} + {money(g.total)} +
+ {g.rows.map((r) => ( +
+ + {r.date.toLocaleString("en-GB", { weekday: "short" })} + {r.date.getDate()} + + + {r.vendor} + {r.item.cadence} + + {money(r.amount)} +
+ ))} +
+ )) + )} +
+
+ + + {(["all", "subscriptions", "bills"] as Tab[]).map((k) => ( + + ))} +
+ } + loading={loading} + skeletonHeight={240} + className="mt-3.5" + data-testid="recurring-table" + > + {rows.length === 0 ? ( +

Nothing here yet.

+ ) : ( + rows.map((i) => ) + )} + {cancelled.length > 0 && ( +
+
Cancelled
+ {cancelled.map((i) => ( +
+ + {i.vendor} + {money(i.typical_amount)} + +
+ ))} +
+ )} + {(data?.hidden.length ?? 0) > 0 && ( +
+ + {showHidden && ( +
+ {data!.hidden.map((h) => ( +
+ {h.vendor} · not a bill + +
+ ))} +
+ )} +
+ )} + +
+
+ ); +} + +function RecurringRow({ item: i, onChoose }: { item: RecurringItem; onChoose: (vendor: string, state: "cancelled" | "not_a_bill") => void }) { + const money = useMoney(); + return ( +
+ + + + + {i.vendor} + {i.price_change && ( + + {money(i.price_change.previous)} → {money(i.price_change.latest)} + + )} + {i.possibly_stopped && looks stopped} + {i.state === "charged_again" && ( + charged again after you cancelled + )} + + + {i.cadence} · {i.kind === "subscription" ? "subscription" : "bill"} + + + + {dayMonth(i.last_charge_date)} + {i.possibly_stopped ? "—" : dayMonth(i.next_due_date)} + + {money(i.typical_amount)} + a {i.cadence === "weekly" ? "week" : i.cadence === "yearly" ? "year" : "month"} + + + + + + How to cancel + + +
+ ); +} + +// The shown month, Monday first and always six rows tall (no jump between months), with each bill on its due days. +function BillCalendar({ items, year, month, today, loading }: { items: RecurringItem[]; year: number; month: number; today: Date; loading: boolean }) { + const money = useMoney(); + const lead = (new Date(year, month, 1).getDay() + 6) % 7; + const days = new Date(year, month + 1, 0).getDate(); + const byDay = new Map(); + for (const i of items.filter((x) => x.state !== "cancelled" && !x.possibly_stopped)) + for (const d of dueDays(i, year, month)) byDay.set(d, [...(byDay.get(d) ?? []), i]); + const isToday = (d: number) => today.getFullYear() === year && today.getMonth() === month && today.getDate() === d; + return ( + +
+ {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => ( +
+ {d} +
+ ))} + {Array.from({ length: 42 }, (_, cell) => { + const d = cell - lead + 1; + if (d < 1 || d > days) return
; + const bills = byDay.get(d) ?? []; + return ( +
+ {d} + {bills.slice(0, 1).map((b) => ( + + {b.vendor} + + ))} + {bills.length > 1 && +{bills.length - 1} more} +
+ ); + })} +
+ + ); +} diff --git a/frontend/src/app/(app)/bills/page.tsx b/frontend/src/app/(app)/bills/page.tsx index 7172d40..52a5b36 100644 --- a/frontend/src/app/(app)/bills/page.tsx +++ b/frontend/src/app/(app)/bills/page.tsx @@ -1,16 +1,8 @@ import type { Metadata } from "next"; -import { DashboardShell } from "@/components/dashboard-shell"; -import { UpcomingBills } from "./upcoming-bills"; +import BillsContent from "./bills-content"; export const metadata: Metadata = { title: "Bills & subscriptions" }; -// Interim until UI revamp Phase 5c redesigns Bills: the upcoming bills Lumen has spotted. export default function Page() { - return ( - -
- -
-
- ); + return ; } diff --git a/frontend/src/app/(app)/bills/upcoming-bills.tsx b/frontend/src/app/(app)/bills/upcoming-bills.tsx deleted file mode 100644 index 5e75088..0000000 --- a/frontend/src/app/(app)/bills/upcoming-bills.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { aiAnalyticsApi } from "@/lib/api/client"; -import { BillsCard } from "../home/_components/bills-card"; -import { toReminders, type Reminder } from "../home/use-home-data"; - -// Interim Bills (until UI revamp Phase 5c): the same upcoming-bills list as Home, over the next two months. -export function UpcomingBills() { - const [reminders, setReminders] = useState(null); - useEffect(() => { - aiAnalyticsApi.getReminders(62).then( - (r: { reminders?: Parameters[0] }) => setReminders(toReminders(r.reminders ?? [])), - () => setReminders([]), - ); - }, []); - const today = new Date(); - return ; -} diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts index 6dbf240..da74c73 100644 --- a/frontend/src/app/(app)/home/use-home-data.ts +++ b/frontend/src/app/(app)/home/use-home-data.ts @@ -1,32 +1,21 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { aiAnalyticsApi, budgetApi, homeApi, transactionApi, type BudgetState, type HomeSummary } from "@/lib/api/client"; -import { periods, type Range, type Txn } from "@/lib/home-math"; +import { aiAnalyticsApi, billsApi, budgetApi, homeApi, transactionApi, type BudgetState, type HomeSummary, type RecurringItem } from "@/lib/api/client"; +import { parseDay, periods, type Range, type Txn } from "@/lib/home-math"; export type Reminder = { id: string | number; vendor: string; amount: number | null; date: string; type: string; category?: string }; -// GET /api/analytics/reminders as the backend sends it (ai/pattern_detection.generate_reminders). -type ApiReminder = { - title?: string; - predicted_date?: string | null; - pattern_id?: string | number; - metadata?: { vendor_name?: string | null; average_amount?: number | null; category?: string | null } | null; -}; -// Bills only: a recurring payment to a vendor with a date. Restock nudges ("You usually buy Groceries around the -// 5th") have no vendor and aren't bills. Rows with no date are skipped rather than crashing the page. -export function toReminders(rows: ApiReminder[]): Reminder[] { - return rows.flatMap((r, i) => - r.predicted_date && r.metadata?.vendor_name - ? [{ - id: r.pattern_id ?? i, - vendor: r.metadata.vendor_name, - amount: r.metadata.average_amount ?? null, - date: r.predicted_date, - type: "upcoming", - category: r.metadata.category ?? undefined, - }] - : [], - ); +// Home's bills: active recurring charges due in the next 31 days (api/bills.py). Cancelled ones and ones that look +// stopped aren't bills any more. +export function toBills(items: RecurringItem[], today: Date): Reminder[] { + const until = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 31); + return items + .filter((r) => r.state === "active" && !r.possibly_stopped) + .filter((r) => { + const due = parseDay(r.next_due_date); + return due >= today && due <= until; + }) + .map((r) => ({ id: r.vendor, vendor: r.vendor, amount: r.typical_amount, date: r.next_due_date, type: "upcoming", category: r.category ?? undefined })); } export type Forecast = { dates: string[]; values: number[] } | null; export type HomeData = { @@ -106,7 +95,7 @@ export function useHomeData(range: Range, today: Date): HomeData { useEffect(() => { let live = true; Promise.allSettled([ - aiAnalyticsApi.getReminders(31).then((r: { reminders?: ApiReminder[] }) => toReminders(r.reminders ?? [])), + billsApi.recurring().then((r) => toBills(r.items, today)), homeApi.summary(), budgetApi.get(), ]).then(([reminders, summary, budget]) => { @@ -121,6 +110,8 @@ export function useHomeData(range: Range, today: Date): HomeData { return () => { live = false; }; + // today is a fresh Date each render; the day is what matters + // eslint-disable-next-line react-hooks/exhaustive-deps }, [day]); const reload = useCallback(() => { diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index bfc67b8..4e638f8 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -558,15 +558,8 @@ export const transactionApi = { }, }; -// Analytics the redesigned pages still read: upcoming bills (Home, interim Bills) and the forecast line (Home). +// Analytics the redesigned pages still read: the forecast line on Home. export const aiAnalyticsApi = { - getReminders: async (daysAhead: number = 7) => { - const response = await apiClient.get("/api/analytics/reminders", { - params: { days_ahead: daysAhead }, - }); - return response.data; - }, - // insights=false skips the LLM-written insight text (Home only draws the numbers); cached like the other reads getForecast: (daysAhead: number = 30, insights = true) => cached(`/api/analytics/forecast ${daysAhead} ${insights}`, async () => @@ -673,6 +666,32 @@ export interface BudgetState { categories: { category: string; limit: number; spent: number }[]; } +/** One recurring charge (backend ai/recurring.find_recurring) with the user's saved choice (api/bills.py). */ +export interface RecurringItem { + vendor: string; + category: string | null; + cadence: "weekly" | "monthly" | "yearly"; + typical_amount: number; + last_charge_date: string; + next_due_date: string; + charges_count: number; + price_change: { previous: number; latest: number; percent: number } | null; + kind: "subscription" | "bill" | "recurring"; + possibly_stopped: boolean; + state: "active" | "cancelled" | "charged_again"; +} +export interface RecurringState { + items: RecurringItem[]; + /** vendors the user marked "not a bill" */ + hidden: { vendor: string; vendor_key: string }[]; +} + +export const billsApi = { + recurring: (): Promise => cached("/api/bills/recurring", async () => (await apiClient.get("/api/bills/recurring")).data), + setState: async (vendor: string, state: "cancelled" | "not_a_bill" | "active"): Promise => + (await apiClient.post("/api/bills/recurring/state", { vendor, state })).data, +}; + export const homeApi = { summary: (): Promise => cached("/api/home/summary", async () => (await apiClient.get("/api/home/summary")).data), }; diff --git a/frontend/src/lib/bills-math.ts b/frontend/src/lib/bills-math.ts new file mode 100644 index 0000000..78bf36f --- /dev/null +++ b/frontend/src/lib/bills-math.ts @@ -0,0 +1,63 @@ +// Pure calculations behind the Bills page (UI revamp Phase 5c). No React, no fetching; unit tests in +// e2e/bills-math.spec.ts. Input is GET /api/bills/recurring's items. +import type { RecurringItem } from "@/lib/api/client"; +import { parseDay } from "@/lib/home-math"; + +const DAY = 86_400_000; +const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); +// still being charged: not cancelled (a "charged again" one is) and not looking stopped +const live = (i: RecurringItem) => i.state !== "cancelled" && !i.possibly_stopped; + +export function monthlyEquivalent(i: RecurringItem): number { + return i.cadence === "weekly" ? (i.typical_amount * 52) / 12 : i.cadence === "yearly" ? i.typical_amount / 12 : i.typical_amount; +} + +// The days of `month` (0-based) a charge is expected on, projected from its next due date by its cadence. +export function dueDays(i: RecurringItem, year: number, month: number): number[] { + const next = parseDay(i.next_due_date); + const last = new Date(year, month + 1, 0).getDate(); + if (i.cadence === "monthly") return [Math.min(next.getDate(), last)]; + if (i.cadence === "yearly") return next.getMonth() === month ? [next.getDate()] : []; + const first = new Date(year, month, 1); + const offset = ((Math.round((next.getTime() - first.getTime()) / DAY) % 7) + 7) % 7; + const out: number[] = []; + for (let d = 1 + offset; d <= last; d += 7) out.push(d); + return out; +} + +type Due = { vendor: string; date: Date; amount: number; item: RecurringItem }; + +// Every expected charge from today to 30 days out (weekly ones more than once), soonest first. +function dueSoon(items: RecurringItem[], today: Date): Due[] { + const until = addDays(today, 30); + const out: Due[] = []; + for (const i of items.filter(live)) { + const step = i.cadence === "weekly" ? 7 : null; + for (let d = parseDay(i.next_due_date); d <= until; d = step ? addDays(d, step) : addDays(until, 1)) + if (d >= today) out.push({ vendor: i.vendor, date: d, amount: i.typical_amount, item: i }); + } + return out.sort((a, b) => a.date.getTime() - b.date.getTime()); +} + +// The next 30 days as This week (to Sunday), Next week and Later, each with its total; empty groups are left out. +export function upcoming(items: RecurringItem[], today: Date): { label: string; rows: Due[]; total: number }[] { + const sunday = addDays(today, 6 - ((today.getDay() + 6) % 7)); + const nextSunday = addDays(sunday, 7); + const groups = [ + { label: "This week", rows: [] as Due[] }, + { label: "Next week", rows: [] as Due[] }, + { label: "Later", rows: [] as Due[] }, + ]; + for (const d of dueSoon(items, today)) groups[d.date <= sunday ? 0 : d.date <= nextSunday ? 1 : 2].rows.push(d); + return groups.filter((g) => g.rows.length).map((g) => ({ ...g, total: g.rows.reduce((s, r) => s + r.amount, 0) })); +} + +export function tiles(items: RecurringItem[], today: Date) { + const soon = dueSoon(items, today); + const stopped = items.filter((i) => i.possibly_stopped && i.kind === "subscription" && i.state !== "cancelled"); + return { + dueSoon: { total: soon.reduce((s, d) => s + d.amount, 0), count: soon.length }, + perMonth: Math.round(items.filter(live).reduce((s, i) => s + monthlyEquivalent(i), 0)), + stopped: { count: stopped.length, perMonth: Math.round(stopped.reduce((s, i) => s + monthlyEquivalent(i), 0)) }, + }; +}