@@ -19,12 +98,67 @@ export function DraftBudgetPromo() {
+
);
}
diff --git a/frontend/src/app/(app)/home/_components/calendar-card.tsx b/frontend/src/app/(app)/home/_components/calendar-card.tsx
index 85013b2..f4da6f4 100644
--- a/frontend/src/app/(app)/home/_components/calendar-card.tsx
+++ b/frontend/src/app/(app)/home/_components/calendar-card.tsx
@@ -2,12 +2,14 @@ import { useMoney } from "@/lib/format";
import { calendarBuckets, heat, parseDay, type Period, type Txn } from "@/lib/home-math";
import { HomeCard } from "./card";
-const kShort = (n: number) => (n >= 1000 ? `₹${(n / 1000).toFixed(1)}k` : `₹${Math.round(n)}`);
+// "₹2.2k" in the user's own currency: take the symbol from how useMoney prints zero
+const kShort = (n: number, symbol: string) => (n >= 1000 ? `${symbol}${(n / 1000).toFixed(1)}k` : `${symbol}${Math.round(n)}`);
const dayMonth = (s: string) => parseDay(s).toLocaleString("en-GB", { day: "numeric", month: "short" });
// This month as a heat grid (darker = more spent) beside the latest transactions.
export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: Period; loading: boolean }) {
const money = useMoney();
+ const symbol = money(0).replace(/[\d.,\s]/g, "");
const days = calendarBuckets(txns, period);
const max = Math.max(...days.map((d) => d.amount), 0);
const lead = (period.start.getDay() + 6) % 7; // Monday first
@@ -30,7 +32,7 @@ export function CalendarCard({ txns, period, loading }: { txns: Txn[]; period: P
style={h ? { background: `color-mix(in srgb, var(--primary) ${Math.round(h * 100)}%, transparent)`, color: h > 0.5 ? "#fff" : undefined } : undefined}
>
{day}
- {amount > 0 && {kShort(amount)}}
+ {amount > 0 && {kShort(amount, symbol)}}
);
})}
diff --git a/frontend/src/app/(app)/home/_components/lumen-found.tsx b/frontend/src/app/(app)/home/_components/lumen-found.tsx
index 75a3910..8c1e987 100644
--- a/frontend/src/app/(app)/home/_components/lumen-found.tsx
+++ b/frontend/src/app/(app)/home/_components/lumen-found.tsx
@@ -1,28 +1,42 @@
import Link from "next/link";
+import type { HomeSummary } from "@/lib/api/client";
+import { useMoney } from "@/lib/format";
+import { fill } from "@/lib/home-math";
import { HomeCard } from "./card";
-// "Lumen found": things worth your attention. Phase 2 has one real source (the review queue); Phase 3 adds
-// subscriptions, spending spikes and duplicate charges, so the tile grid is already three wide.
-export function LumenFound({ toReview, loading }: { toReview: number; loading: boolean }) {
+// "Lumen found": duplicates, receipts to review, spending spikes and price rises (GET /api/home/summary, rule-based).
+// A money "worth" is only claimed for things you may get back (duplicates).
+export function LumenFound({ summary, loading }: { summary: HomeSummary | null; loading: boolean }) {
+ const money = useMoney();
+ const found = summary?.found ?? [];
+ const total = found.length + (summary?.more ?? 0);
return (
- {toReview > 0 ? (
-
+ >
)}
);
diff --git a/frontend/src/app/(app)/home/homeContent.tsx b/frontend/src/app/(app)/home/homeContent.tsx
index 2a07975..4962a8b 100644
--- a/frontend/src/app/(app)/home/homeContent.tsx
+++ b/frontend/src/app/(app)/home/homeContent.tsx
@@ -5,8 +5,9 @@ import { useSearchParams } from "next/navigation";
import { useProfile } from "@/components/profile/profile-provider";
import { DialogDemo } from "@/components/uploadDialog";
import { ExportDialog } from "@/components/exportDialog";
+import type { FoundItem } from "@/lib/api/client";
import { useMoney } from "@/lib/format";
-import { briefSentence, cumulative, dayIndex, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math";
+import { briefSentence, cumulative, dayIndex, fill, parseDay, periods, sameDayDelta, type Range } from "@/lib/home-math";
import { Brief } from "./_components/brief";
import { ActivityCard } from "./_components/activity-card";
import { BillsCard } from "./_components/bills-card";
@@ -19,6 +20,11 @@ import { useHomeData, type Reminder } from "./use-home-data";
const partOfDay = (h: number) => (h < 12 ? "morning" : h < 17 ? "afternoon" : "evening");
const monthName = (d: Date) => d.toLocaleString("en-IN", { month: "long" });
+// The brief's second sentence: the top "Lumen found" item, if there is one.
+function worthALook(item: FoundItem | undefined, money: (n: number) => string) {
+ return item ? ` The one thing worth a look: ${fill(item.headline, item.amounts, money).replace(/\.$/, "")}.` : "";
+}
+
export function nextBill(reminders: Reminder[], today: Date) {
return reminders
.filter((r) => r.type === "upcoming" && parseDay(r.date) >= today)
@@ -53,7 +59,7 @@ export default function HomeContent() {
nextBill: bill ? { vendor: bill.vendor, date: parseDay(bill.date) } : undefined,
money: (n) => money(n),
today,
- });
+ }) + worthALook(data.summary?.found[0], money);
const firstName = profile?.display_name?.split(" ")[0];
const chart = periods(range, today);
@@ -88,14 +94,15 @@ export default function HomeContent() {
today={today}
loading={data.status === "loading"}
/>
-
+
-
+
-
-
+ {/* once you have category budgets, the promo has done its job */}
+ {!data.budget?.categories.length && }
+
diff --git a/frontend/src/app/(app)/home/use-home-data.ts b/frontend/src/app/(app)/home/use-home-data.ts
index f0613f6..19590de 100644
--- a/frontend/src/app/(app)/home/use-home-data.ts
+++ b/frontend/src/app/(app)/home/use-home-data.ts
@@ -1,7 +1,7 @@
"use client";
-import { useEffect, useState } from "react";
-import { aiAnalyticsApi, reviewApi, transactionApi } from "@/lib/api/client";
+import { useCallback, useEffect, useState } from "react";
+import { aiAnalyticsApi, budgetApi, homeApi, transactionApi, type BudgetState, type HomeSummary } from "@/lib/api/client";
import { periods, type Range, type Txn } from "@/lib/home-math";
export type Reminder = { id: string | number; vendor: string; amount: number | null; date: string; type: string; category?: string };
@@ -14,7 +14,9 @@ export type HomeData = {
prevMonthTxns: Txn[];
reminders: Reminder[];
forecast: Forecast;
- toReview: number;
+ summary: HomeSummary | null;
+ budget: BudgetState | null;
+ reloadBudget: () => void;
};
const ymd = (d: Date) =>
@@ -43,10 +45,10 @@ const value = (r: PromiseSettledResult, fallback: T) => (r.status === "fu
// A failed call empties its card instead of breaking the page. Three independent loads:
// - transactions, per chart range (skeletons only on the very first load; later range changes keep the old
// numbers until the new ones arrive, so a click on the chart never blanks the brief or another card)
-// - reminders + review count, once
+// - reminders, the Home summary (Lumen found + activity) and the budget, once
// - the forecast, once, numbers only (no LLM text); it never holds up the page, the dotted line just appears
type Txns = Pick;
-type Extras = Pick;
+type Extras = Pick;
export function useHomeData(range: Range, today: Date): HomeData {
const [txns, setTxns] = useState(null);
@@ -81,9 +83,10 @@ export function useHomeData(range: Range, today: Date): HomeData {
let live = true;
Promise.allSettled([
aiAnalyticsApi.getReminders(31).then((r: { reminders?: Reminder[] }) => r.reminders ?? []),
- reviewApi.list().then((items) => items.length),
- ]).then(([reminders, toReview]) => {
- if (live) setExtras({ reminders: value(reminders, []), toReview: value(toReview, 0) });
+ homeApi.summary(),
+ budgetApi.get(),
+ ]).then(([reminders, summary, budget]) => {
+ if (live) setExtras({ reminders: value(reminders, []), summary: value(summary, null), budget: value(budget, null) });
});
aiAnalyticsApi
.getForecast(31, false)
@@ -96,6 +99,10 @@ export function useHomeData(range: Range, today: Date): HomeData {
};
}, [day]);
+ const reloadBudget = useCallback(() => {
+ budgetApi.get().then((budget) => setExtras((e) => (e ? { ...e, budget } : e)), () => {});
+ }, []);
+
return {
status: txns && extras ? "ready" : "loading",
txns: txns?.txns ?? [],
@@ -103,7 +110,9 @@ export function useHomeData(range: Range, today: Date): HomeData {
monthTxns: txns?.monthTxns ?? [],
prevMonthTxns: txns?.prevMonthTxns ?? [],
reminders: extras?.reminders ?? [],
- toReview: extras?.toReview ?? 0,
+ summary: extras?.summary ?? null,
+ budget: extras?.budget ?? null,
+ reloadBudget,
forecast,
};
}
diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx
index 2af97cd..0231ac8 100644
--- a/frontend/src/components/app-sidebar.tsx
+++ b/frontend/src/components/app-sidebar.tsx
@@ -62,7 +62,7 @@ export function AppSidebar(props: React.ComponentProps) {
// refreshed on navigation and after each review decision (which happens without leaving /receipts)
React.useEffect(() => {
- const refresh = () => reviewApi.list().then((items) => setToReview(items.length), () => setToReview(0));
+ const refresh = () => reviewApi.count().then(setToReview, () => setToReview(0));
void refresh();
return eventBus.on(EVENTS.REVIEW_DECIDED, refresh);
}, [pathname]);
diff --git a/frontend/src/components/ask/proposal-card.tsx b/frontend/src/components/ask/proposal-card.tsx
index 78f6637..8e5af04 100644
--- a/frontend/src/components/ask/proposal-card.tsx
+++ b/frontend/src/components/ask/proposal-card.tsx
@@ -11,6 +11,7 @@ const TITLES: Record = {
mark_paid: "Mark this as paid?",
update_category: "Change the category?",
follow_up_vendor: "Follow up with this vendor?",
+ set_budget: "Set this monthly budget?",
};
const RISK: Record = {
@@ -33,7 +34,8 @@ export function ProposalCard({ proposal }: { proposal: Proposal }) {
const [decided, setDecided] = useState<"approve" | "reject" | null>(null);
const [busy, setBusy] = useState(false);
const risk = RISK[proposal.risk] ?? RISK.medium;
- const details = Object.entries(proposal.payload);
+ // nested values (e.g. a set_budget's "basis") aren't readable as chips
+ const details = Object.entries(proposal.payload).filter(([, v]) => v === null || typeof v !== "object");
async function decide(decision: "approve" | "reject") {
setBusy(true);
diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts
index 80fe037..509405f 100644
--- a/frontend/src/lib/api/client.ts
+++ b/frontend/src/lib/api/client.ts
@@ -720,6 +720,47 @@ export interface Proposal {
created_at: string | null;
}
+// Home (UI revamp Phase 3). Text carries {0}/{1} placeholders filled from `amounts` in the user's currency (fill()).
+export interface FoundItem {
+ kind: "duplicate" | "review" | "spike" | "price_rise";
+ tag: string;
+ headline: string;
+ detail: string;
+ amounts: number[];
+ worth: number | null;
+ href: string | null;
+}
+export interface ActivityItem {
+ kind: "statement_import" | "receipt" | "decision";
+ text: string;
+ amounts: number[];
+ at: string;
+}
+export interface HomeSummary {
+ found: FoundItem[];
+ more: number;
+ worth_total: number;
+ activity: ActivityItem[];
+}
+export interface BudgetState {
+ month: string;
+ monthly_budget: number | null;
+ spent: number;
+ categories: { category: string; limit: number; spent: number }[];
+}
+
+export const homeApi = {
+ summary: (): Promise => cached("/api/home/summary", async () => (await apiClient.get("/api/home/summary")).data),
+};
+
+export const budgetApi = {
+ get: async (): Promise => (await apiClient.get("/api/budget")).data,
+ put: async (body: { monthly_budget?: number | null; categories?: { category: string; limit: number | null }[] }): Promise =>
+ (await apiClient.put("/api/budget", body)).data,
+ /** Files set_budget proposals (nothing is saved until each is approved); [] = not enough history. */
+ draft: async (): Promise => (await apiClient.post("/api/budget/draft")).data.proposals,
+};
+
// The agent's proposals (SPEC-AGENT), served by the FastAPI routes under /api/agent. Questions go through chatApi.
export const agentApi = {
proposals: async (): Promise => {
@@ -758,6 +799,12 @@ export const reviewApi = {
return response.data.items;
},
+ /** Just the number waiting (the sidebar badge). */
+ count: async (): Promise => {
+ const response = await apiClient.get("/api/review", { params: { status: "flagged", count: 1 } });
+ return response.data.count ?? 0;
+ },
+
decide: async (id: string, decision: "approve" | "reject", body: { edits?: Record; note?: string } = {}): Promise => {
const response = await apiClient.post(`/api/review/${encodeURIComponent(id)}/${decision}`, body);
return response.data.item;
diff --git a/frontend/src/lib/home-math.ts b/frontend/src/lib/home-math.ts
index 6b5eef9..fbae055 100644
--- a/frontend/src/lib/home-math.ts
+++ b/frontend/src/lib/home-math.ts
@@ -59,7 +59,7 @@ export function sameDayDelta(cur: number[], prev: number[], index: number): { ab
if (!cur.length || !prev.length) return null;
const now = cur[Math.min(index, cur.length - 1)];
const then = prev[Math.min(index, prev.length - 1)];
- if (!then) return null;
+ if (then <= 0) return null; // nothing (or net refunds) to compare against
return { abs: now - then, pct: ((now - then) / then) * 100 };
}
@@ -93,6 +93,11 @@ export function briefSentence(a: {
return s;
}
+/** Fill "{0}"-style placeholders with formatted money (the server keeps amounts numeric). */
+export function fill(text: string, amounts: number[], money: (n: number) => string): string {
+ return text.replace(/\{(\d+)\}/g, (_, i) => (amounts[Number(i)] == null ? "" : money(amounts[Number(i)])));
+}
+
export function calendarBuckets(txns: Txn[], p: Period): { day: number; amount: number }[] {
const out = Array.from({ length: p.days }, (_, i) => ({ day: i + 1, amount: 0 }));
for (const t of txns) {
From a96b8da0570663f6bd3285b35979adbfbac3b47e Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Wed, 21 Oct 2026 22:37:14 +0530
Subject: [PATCH 08/12] docs: Phase 3 codebase guide, backlog ticks, STORY
---
docs/CODEBASE-GUIDE.md | 19 +++++++++++++++++--
docs/direction/STORY.md | 15 +++++++++++++++
.../specs/2026-10-20-ui-revamp-design.md | 10 +++++-----
3 files changed, 37 insertions(+), 7 deletions(-)
diff --git a/docs/CODEBASE-GUIDE.md b/docs/CODEBASE-GUIDE.md
index ebb9ca2..3d989a9 100644
--- a/docs/CODEBASE-GUIDE.md
+++ b/docs/CODEBASE-GUIDE.md
@@ -285,8 +285,23 @@ embeds the demo documents once per server and uploads the files in the backgroun
Activity. `use-home-data.ts` fetches today's endpoints in parallel (transactions, reminders, forecast, review
queue). A failed call empties only its card. Skeletons show only on the first load, so changing the chart range
never blanks other cards. All the maths is pure, in `lib/home-math.ts` (unit tests: `e2e/home-math.spec.ts`).
- **Interim until Phase 3:** the brief is one rule-based sentence, Lumen found only shows the review queue, the
- Budget card is an empty state, and Activity is derived from transactions' `created_at`. `(app)/layout.tsx` is a
+ **Phase 3 backend:** `GET /api/home/summary` (`api/home.py` over the pure `ai/home_signals.py`, no LLM) supplies
+ Lumen found and Activity:
+ - Lumen found shows double charges (the only items with a "you may get back" ₹), receipts to review, category spikes
+ vs the same day last month, and subscription price rises from `ai.recurring`.
+ - Activity shows statement imports grouped into one line, receipts read, and your approve/reject decisions.
+
+ Text carries `{0}` placeholders plus `amounts`, which the client fills in the user's currency (`fill` in
+ `lib/home-math.ts`). The brief adds "The one thing worth a look" from the top item.
+
+ Budgets: the overall budget is `User.monthly_budget` (onboarding); per-category limits are the `budgets` table.
+ - `GET/PUT /api/budget` (`api/budget.py`).
+ - `POST /api/budget/draft` runs the deterministic `ai/budget.py` (3-month mean, rounded up) and files `set_budget`
+ proposals. Approving one (`/api/agent/proposals/{id}/approve`) upserts the row. A new draft retires pending ones.
+ - Ask Lumen's `draft_budget` tool files the same proposals.
+ - Home's draft dialog approves lines one by one or all at once.
+
+ The sidebar badge uses `GET /api/review?count=1`. `(app)/layout.tsx` is a
tiny server file exporting the light `theme-color`; the client shell is `components/app-shell.tsx`.
- **Signed-in e2e:** `e2e/fixtures.ts` `signIn(page)` fakes a Supabase session (localStorage) plus the
`lumen_session` cookie, and stubs every backend call, so `e2e/shell.spec.ts` drives the real shell without servers.
diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md
index 3dda25d..7d37f4e 100644
--- a/docs/direction/STORY.md
+++ b/docs/direction/STORY.md
@@ -340,3 +340,18 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in
- **R:** `/home` is static at 15.7 kB of route JS. 65 checks pass, including zero layout shift with a 1.5 s slow
backend, a failed forecast, a brand-new user with no data, and 15-lakh amounts with long vendor names. Story point:
an honest empty state beats a convincing placeholder.
+
+## 29. "Lumen found" and budgets you approve line by line (UI revamp, Phase 3)
+- **S:** Home's smart cards were placeholders: Lumen found only knew the review queue, Activity was guessed from
+ transactions, and Budget said "coming soon".
+- **T:** Back them with real, cheap, honest data. Use no LLM on every Home visit, never invent a saving, and keep Lumen's
+ rule that nothing changes without the user's approval.
+- **A:** Wrote pure, tested rules for double charges, category spikes measured by the same day last month (refunds
+ can't fake one), and subscription price rises. Built an activity feed from tables that already exist. Budgets: a
+ deterministic draft (3-month mean, rounded up) files one `set_budget` proposal per category, and approving one
+ upserts the limit. The same path is exposed as an agent tool, so "make me a budget" in Ask Lumen gives the same
+ approvable lines. Money travels as numbers with placeholders, so every amount shows in the user's currency.
+- **R:** 44 new backend tests (967 total passing) and 81 e2e checks, including per-user isolation, a double approve
+ returning 409 with one row, re-drafts retiring stale suggestions, and a user with no history. Home is still static
+ (17.5 kB of route JS). Owner follow-up logged: make the rules smarter about one-off big purchases, measured against an
+ eval set.
diff --git a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
index dd41082..cbb0d9c 100644
--- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
+++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
@@ -215,18 +215,18 @@ Small items found while building, parked here so they aren't lost. Tick them off
- [ ] **Phase 6.** The e2e suite is local and Chromium-only. Add Firefox once Windows allows Playwright's build (`E2E_FIREFOX=1`), and consider running it in CI with the `signIn` fixture. *(Phase 0)*
- [x] ~~**Phase 6.** `(app)/layout` reads the sidebar cookie on the server.~~ Not needed: the sidebar only renders client-side (behind the auth guard), so Phase 1 reads the cookie there and the app routes stay static. *(Phase 1)*
- [ ] **Phase 5.** Spending and Bills both show the payment calendar and reminders cards in the interim. Remove the duplicate when the pages are redesigned. *(Phase 1 decision)*
-- [ ] **Phase 3.** The sidebar's review badge downloads every flagged item just to count them. Add a count-only endpoint (e.g. `GET /api/review?status=flagged&count=1`). *(Phase 1 review)*
+- [x] **Phase 3.** The sidebar's review badge downloads every flagged item just to count them. Add a count-only endpoint (e.g. `GET /api/review?status=flagged&count=1`). *(Phase 1 review)*
- [ ] **Phase 5.** `/spending` stacks two old pages, so it has two `
`s and fetches the calendar and reminders twice. It goes away with the redesign. *(Phase 1 review)*
- [ ] **Phase 6.** iOS Safari 15 and older don't support `overflow-x: clip`, so wide landing marquees could scroll sideways there. If that matters, add an `@supports not (overflow-x: clip)` fallback (not `hidden` on body). *(Phase 1 review)*
- [ ] **Phase 4.** Ctrl/⌘+K checks `e.key === "k"`, so Caps Lock or Shift misses it. Use `e.code === "KeyK"`. *(Phase 1 review)*
- [ ] **Phase 6.** The sidebar cookie expires after 7 days (shadcn default), so a collapsed rail reopens after a week away. Extend it to about a year. *(Phase 1 review)*
-- [ ] **Phase 3.** Replace Home's interim parts: the brief (summary endpoint), Lumen found (subscriptions, spikes, duplicates), Activity (a real feed, including statement imports dated earlier than this month), and wire up Budget and the draft-budget promo. *(Phase 2)*
+- [x] **Phase 3.** Replace Home's interim parts: the brief (summary endpoint), Lumen found (subscriptions, spikes, duplicates), Activity (a real feed, including statement imports dated earlier than this month), and wire up Budget and the draft-budget promo. *(Phase 2)*
- [ ] **Later.** Home sub-tabs (Overview / This month / Year). The chart's range chips cover this for now. *(Phase 2 decision)*
- [ ] **Phase 5.** The Export and Upload buttons on Home still use the old loud blue `Button` and the dark upload dialog. Restyle them with the dialogs. *(Phase 2)*
-- [ ] **Phase 3.** The calendar's `kShort` hardcodes `₹`; use the user's currency symbol like `useMoney`. *(Phase 2 review)*
-- [ ] **Phase 3.** `sameDayDelta` only guards a zero previous total. A refund-heavy start of month (negative running total) flips the brief's "lighter"/"heavier". Use `then <= 0`. *(Phase 2 review)*
+- [x] **Phase 3.** The calendar's `kShort` hardcodes `₹`; use the user's currency symbol like `useMoney`. *(Phase 2 review)*
+- [x] **Phase 3.** `sameDayDelta` only guards a zero previous total. A refund-heavy start of month (negative running total) flips the brief's "lighter"/"heavier". Use `then <= 0`. *(Phase 2 review)*
- [ ] **Phase 6.** `CountUp` paints the final number for one frame before counting from 0. Start at 0 when it will animate. *(Phase 2 review)*
- [ ] **Phase 6.** Measure each Home card's real content height against its `skeletonHeight` (the spend card's is estimated at about 270px vs 262px); the CLS test threshold of 0.01 can hide a small shift. *(Phase 2 review)*
-- [ ] **Phase 3.** Confirm statement imports write rows before the POST returns. Otherwise a Home visit in that window caches stale totals for 5 minutes. *(Phase 2 review)*
+- [x] **Phase 3.** Confirm statement imports write rows before the POST returns. Confirmed: `api/statements.py` commits inside the request. *(Phase 2 review)*
- [ ] **Later (owner request).** Make "Lumen found" and the budget draft smarter. A plain 3-month mean is skewed by one-off big purchases (a phone, a laptop). Ideas: a median or trimmed mean per category; treat single large purchases as one-offs and leave them out of the limit (or suggest a separate "planned purchases" line); use seasonality and income; let the agent (LLM) explain or adjust a draft line, while approval still stays with the user. Measure any new rule against an eval set before switching. *(Phase 3)*
- [ ] **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)*
From f17b4345ed3d6c94a047fb32a8735a7b6a083997 Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Wed, 21 Oct 2026 22:39:04 +0530
Subject: [PATCH 09/12] docs: correct Phase 3 test counts
---
docs/direction/STORY.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/direction/STORY.md b/docs/direction/STORY.md
index 7d37f4e..d969b98 100644
--- a/docs/direction/STORY.md
+++ b/docs/direction/STORY.md
@@ -351,7 +351,7 @@ with the numbers and the files that prove them. Newest last. Raw numbers live in
deterministic draft (3-month mean, rounded up) files one `set_budget` proposal per category, and approving one
upserts the limit. The same path is exposed as an agent tool, so "make me a budget" in Ask Lumen gives the same
approvable lines. Money travels as numbers with placeholders, so every amount shows in the user's currency.
-- **R:** 44 new backend tests (967 total passing) and 81 e2e checks, including per-user isolation, a double approve
+- **R:** 38 new backend tests (941 passing) and 81 e2e checks, including per-user isolation, a double approve
returning 409 with one row, re-drafts retiring stale suggestions, and a user with no history. Home is still static
(17.5 kB of route JS). Owner follow-up logged: make the rules smarter about one-off big purchases, measured against an
eval set.
From b23ce7858533e1578d3757f2f34d2c2d86d99485 Mon Sep 17 00:00:00 2001
From: coderconnoisseur <142653086+coderconnoisseur@users.noreply.github.com>
Date: Wed, 21 Oct 2026 22:47:48 +0530
Subject: [PATCH 10/12] fix: validate agent budget limits; 2-month drafts;
refunds can't fake spikes; habits aren't duplicates; dialog double-submit
guard
---
backend/ai/budget.py | 8 +++---
backend/ai/home_signals.py | 9 ++++++-
backend/api/agent.py | 13 +++++++---
backend/tests/test_api_budget.py | 24 ++++++++++++++++++
backend/tests/test_budget.py | 5 ++++
backend/tests/test_home_signals.py | 10 ++++++++
.../specs/2026-10-20-ui-revamp-design.md | 5 ++++
frontend/e2e/home.spec.ts | 11 ++++++++
.../(app)/home/_components/budget-card.tsx | 25 ++++++++++++-------
9 files changed, 94 insertions(+), 16 deletions(-)
diff --git a/backend/ai/budget.py b/backend/ai/budget.py
index 0d1c6bc..7192861 100644
--- a/backend/ai/budget.py
+++ b/backend/ai/budget.py
@@ -55,7 +55,8 @@ def _round_up(avg: float) -> int:
def draft_budget(txns: list[dict], today: date) -> list[dict]:
- """One line per category: the mean of the 3 complete months before today's, rounded up.
+ """One line per category: the mean over the 3 complete months before today's (only those with any spend),
+ rounded up.
Empty when fewer than 2 of those months have any spend (not enough history to suggest limits)."""
months = []
y, m = today.year, today.month
@@ -64,12 +65,13 @@ def draft_budget(txns: list[dict], today: date) -> list[dict]:
months.append((y, m))
months.reverse()
totals = _totals(txns, set(months))
- if len({mo for (_, mo), v in totals.items() if v}) < 2:
+ active = len({mo for (_, mo), v in totals.items() if v}) # months with any spend (a new user may have only 2)
+ if active < 2:
return []
names = _display_names(txns)
lines = []
for key in {k for k, _ in totals}:
- avg = sum(totals.get((key, mo), 0.0) for mo in months) / 3
+ avg = sum(totals.get((key, mo), 0.0) for mo in months) / active
if avg >= MIN_MONTHLY:
lines.append({"category": names[key], "limit": _round_up(avg),
"basis": {"months": [f"{yy}-{mm:02d}" for yy, mm in months], "average": round(avg, 2)}})
diff --git a/backend/ai/home_signals.py b/backend/ai/home_signals.py
index fb8e546..f8d6f74 100644
--- a/backend/ai/home_signals.py
+++ b/backend/ai/home_signals.py
@@ -14,6 +14,7 @@
from ai.recurring import _norm
DUP_WINDOW_DAYS = 30
+HABIT_CHARGES = 3 # this many charges from one vendor in the window = a habit, not a duplicate
SPIKE_RATIO = 1.3
SPIKE_FLOOR = 1000
PRICE_RISE_PCT = 5
@@ -40,6 +41,11 @@ def _duplicates(txns: list[dict], today: date) -> list[dict]:
d, amount = _day(t.get("date")), t.get("total_amount")
if d and amount and amount > 0 and today - timedelta(days=DUP_WINDOW_DAYS) <= d <= today and t.get("vendor_name"):
recent.append((_norm(t["vendor_name"]), d, float(amount), t))
+ # a vendor you pay most days (coffee, metro) isn't double-charging you
+ per_vendor = defaultdict(int)
+ for v, *_ in recent:
+ per_vendor[v] += 1
+ recent = [r for r in recent if per_vendor[r[0]] < HABIT_CHARGES]
recent.sort(key=lambda r: (r[0], r[1]))
out, used = [], set()
for i, (v, d, amount, t) in enumerate(recent):
@@ -63,10 +69,11 @@ def _spikes(txns: list[dict], today: date) -> list[dict]:
prev_end = first - timedelta(days=1)
prev_first = prev_end.replace(day=1)
same_day = prev_first.replace(day=min(today.day, prev_end.day))
+ # spending only: a refund in either month would shrink the base and fake a huge percentage
now, then, prev_total = defaultdict(float), defaultdict(float), 0.0
for t in txns:
d, amount = _day(t.get("date")), t.get("total_amount")
- if not d or amount is None:
+ if not d or not amount or amount <= 0:
continue
if first <= d <= today:
now[_key(t.get("category"))] += amount
diff --git a/backend/api/agent.py b/backend/api/agent.py
index a15fa51..f53aadb 100644
--- a/backend/api/agent.py
+++ b/backend/api/agent.py
@@ -7,6 +7,7 @@
from __future__ import annotations
import json
+import math
import threading
from dataclasses import dataclass
from datetime import date, datetime
@@ -152,9 +153,15 @@ def _decide(proposal_id: str, user_id: str, decision: str, deps: AgentDeps) -> d
if decision == "approved" and p.type == "set_budget":
from api.budget import upsert_budget
- limit = float(json.loads(p.payload or "{}")["limit"])
- upsert_budget(session, user_id, p.target, limit)
- applied = {"category": p.target, "limit": limit}
+ # the agent can file set_budget with any payload: only a real positive limit is ever applied
+ try:
+ limit = float(json.loads(p.payload or "{}")["limit"])
+ except (KeyError, TypeError, ValueError):
+ limit = math.nan
+ if not math.isfinite(limit) or limit <= 0 or not p.target.strip():
+ raise HTTPException(422, "This budget suggestion has no valid limit; reject it instead.")
+ upsert_budget(session, user_id, p.target.strip(), limit)
+ applied = {"category": p.target.strip(), "limit": limit}
if decision == "approved" and p.type == "update_category":
category = json.loads(p.payload or "{}").get("category")
if category:
diff --git a/backend/tests/test_api_budget.py b/backend/tests/test_api_budget.py
index 9aecd5d..9e1b3a2 100644
--- a/backend/tests/test_api_budget.py
+++ b/backend/tests/test_api_budget.py
@@ -151,3 +151,27 @@ def test_agent_tool_draft_budget(setup):
assert [p["target"] for p in out["proposals"]] == ["Food", "Travel"]
pending = c.get("/api/agent/proposals?status=pending", headers=bearer(A)).json()["proposals"]
assert {p["type"] for p in pending} == {"set_budget"} and len(pending) == 2
+
+
+@pytest.mark.parametrize("payload", [{"amount": 5000}, {"limit": 0}, {"limit": -5}, {"limit": "lots"}, {"limit": float("inf")}])
+def test_agent_set_budget_with_a_bad_limit_is_rejected_not_applied(setup, payload):
+ """The LLM can file set_budget with any payload: approving a bad one must be a 422, never a broken budget."""
+ from agent.tools import TOOLS, ProposeActionArgs, ToolContext
+
+ c, _ = setup
+ out = TOOLS["propose_action"].run(ToolContext(user_id=A, engine=setup[1], sql_agent=None), ProposeActionArgs(
+ type="set_budget", target=" Food ", risk="low", reason="r", payload=payload))
+ r = c.post(f"/api/agent/proposals/{out['proposal_id']}/approve", headers=bearer(A))
+ assert r.status_code == 422
+ assert c.get("/api/budget", headers=bearer(A)).status_code == 200
+ assert c.get("/api/budget", headers=bearer(A)).json()["categories"] == []
+
+
+def test_agent_set_budget_target_is_trimmed(setup):
+ from agent.tools import TOOLS, ProposeActionArgs, ToolContext
+
+ c, engine = setup
+ out = TOOLS["propose_action"].run(ToolContext(user_id=A, engine=engine, sql_agent=None), ProposeActionArgs(
+ type="set_budget", target=" Food ", risk="low", reason="r", payload={"limit": 5000}))
+ c.post(f"/api/agent/proposals/{out['proposal_id']}/approve", headers=bearer(A))
+ assert c.get("/api/budget", headers=bearer(A)).json()["categories"][0]["category"] == "Food"
diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py
index f0aeee0..be19c35 100644
--- a/backend/tests/test_budget.py
+++ b/backend/tests/test_budget.py
@@ -72,3 +72,8 @@ def test_budget_unique_per_category(tmp_path):
flask_db.metadata.create_all(engine)
with pytest.raises(IntegrityError), Session(engine) as s, s.begin():
s.add_all([Budget(user_id="u", category="Food", monthly_limit=1), Budget(user_id="u", category="Food", monthly_limit=2)])
+
+
+def test_draft_with_two_months_averages_over_two():
+ txns = [t("2026-08-10", 6000), t("2026-09-10", 6000)] # no July data
+ assert draft_budget(txns, TODAY)[0]["limit"] == 6000
diff --git a/backend/tests/test_home_signals.py b/backend/tests/test_home_signals.py
index 859d088..793b29a 100644
--- a/backend/tests/test_home_signals.py
+++ b/backend/tests/test_home_signals.py
@@ -109,3 +109,13 @@ def test_activity_keeps_newest_six():
rows = [t("2026-10-01", i, vendor=f"V{i}", created_at=datetime(2026, 10, 21, i)) for i in range(10)]
out = activity(rows, [], NOW)
assert len(out) == 6 and out[0]["at"] == "2026-10-21T09:00:00Z"
+
+
+def test_spike_ignores_partial_refund_month():
+ rows = [t("2026-09-05", 2000, "X", "Food"), t("2026-09-06", -1800, "X", "Food"), t("2026-10-05", 1500, "X", "Food")]
+ assert find_signals(rows, TODAY, 0, []) == []
+
+
+def test_daily_habit_is_not_a_duplicate():
+ coffee = [t(f"2026-10-{d:02d}", 180, "Blue Tokai", "Food") for d in range(10, 18)]
+ assert find_signals(coffee, TODAY, 0, []) == []
diff --git a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
index cbb0d9c..3ecf003 100644
--- a/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
+++ b/docs/superpowers/specs/2026-10-20-ui-revamp-design.md
@@ -229,4 +229,9 @@ Small items found while building, parked here so they aren't lost. Tick them off
- [ ] **Phase 6.** Measure each Home card's real content height against its `skeletonHeight` (the spend card's is estimated at about 270px vs 262px); the CLS test threshold of 0.01 can hide a small shift. *(Phase 2 review)*
- [x] **Phase 3.** Confirm statement imports write rows before the POST returns. Confirmed: `api/statements.py` commits inside the request. *(Phase 2 review)*
- [ ] **Later (owner request).** Make "Lumen found" and the budget draft smarter. A plain 3-month mean is skewed by one-off big purchases (a phone, a laptop). Ideas: a median or trimmed mean per category; treat single large purchases as one-offs and leave them out of the limit (or suggest a separate "planned purchases" line); use seasonality and income; let the agent (LLM) explain or adjust a draft line, while approval still stays with the user. Measure any new rule against an eval set before switching. *(Phase 3)*
+- [ ] **Phase 4.** `_decide` (api/agent.py) checks `status == pending` without a conditional UPDATE. Two concurrent approvals on Postgres can both apply (duplicate audit lines), and two approvals for the same new category can return 500 on the unique constraint. Use the conditional `update(...).where(status=="pending")` pattern from `_save_invoice`. *(Phase 3 review)*
+- [ ] **Phase 4.** Drafting a budget (HTTP or the agent tool) retires all pending `set_budget` proposals. A draft from Ask while Home's dialog is open makes that dialog's approvals return 409. Two quick drafts can leave two pending sets. Consider scoping by draft id and a per-user lock. *(Phase 3 review)*
+- [ ] **Phase 4.** After approving draft lines, Home reloads only the budget. Also reload the summary so Activity shows "You approved…" at once. *(Phase 3 review)*
+- [ ] **Phase 6.** `/api/home/summary` and `/api/budget` each scan all of a user's transactions in Python on every Home visit. Fine for a few thousand rows; filter by date in SQL (and cache) if a user grows past that. *(Phase 3 review)*
+- [ ] **Phase 5.** Draft dialog: per-line buttons are named just "Approve" / "Skip". Add the category to their accessible names. The `basis.months` list always shows 3 months even when only 2 had spend. *(Phase 3 review)*
- [ ] **Owner, manual.** In Firefox, check that the profile/avatar menu doesn't shift the Ask Lumen column, and click through the old pages on light in a demo session. *(Phase 0 PR #89)*
diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts
index 42c1945..556cd58 100644
--- a/frontend/e2e/home.spec.ts
+++ b/frontend/e2e/home.spec.ts
@@ -300,3 +300,14 @@ test("sidebar badge uses the count endpoint", async ({ page }) => {
await counted;
await expect(page.locator('[data-sidebar="menu-badge"]')).toHaveText("2");
});
+
+test("draft dialog ignores a double click", async ({ page }) => {
+ await openHome(page, { ...typical, draft: [draftLine("p1", "Food", 4000), draftLine("p2", "Travel", 1500)] });
+ let approvals = 0;
+ page.on("request", (r) => r.url().includes("/proposals/") && r.url().endsWith("/approve") && approvals++);
+ await page.getByRole("button", { name: "DRAFT BUDGET" }).click();
+ await page.getByRole("dialog").getByRole("button", { name: "Approve all" }).dblclick();
+ await expect(page.getByRole("dialog").getByText("Approved")).toHaveCount(2);
+ await page.waitForTimeout(300);
+ expect(approvals).toBe(2);
+});
diff --git a/frontend/src/app/(app)/home/_components/budget-card.tsx b/frontend/src/app/(app)/home/_components/budget-card.tsx
index fc4590e..b8bf4ef 100644
--- a/frontend/src/app/(app)/home/_components/budget-card.tsx
+++ b/frontend/src/app/(app)/home/_components/budget-card.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useRef, useState } from "react";
import { agentApi, budgetApi, type BudgetState, type Proposal } from "@/lib/api/client";
import { useMoney } from "@/lib/format";
import { toast } from "@/lib/toast";
@@ -63,6 +63,8 @@ export function DraftBudgetPromo({ onChanged }: { onChanged: () => void }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [lines, setLines] = useState(null);
+ const [deciding, setDeciding] = useState(false);
+ const inFlight = useRef(false); // a ref, so a double click is blocked before React re-renders
async function draft() {
setBusy(true);
@@ -77,14 +79,19 @@ export function DraftBudgetPromo({ onChanged }: { onChanged: () => void }) {
}
async function decide(ids: string[], decision: "approve" | "reject") {
- for (const id of ids) {
- try {
+ if (inFlight.current) return;
+ inFlight.current = true;
+ setDeciding(true);
+ try {
+ for (const id of ids) {
await agentApi.decide(id, decision);
setLines((ls) => ls?.map((l) => (l.id === id ? { ...l, decided: decision } : l)) ?? ls);
- } catch {
- toast.error("Couldn't save that decision.");
- return;
}
+ } catch {
+ toast.error("Couldn't save that decision.");
+ } finally {
+ inFlight.current = false;
+ setDeciding(false);
}
}
@@ -135,10 +142,10 @@ export function DraftBudgetPromo({ onChanged }: { onChanged: () => void }) {
) : (
<>
-