diff --git a/agent/db.py b/agent/db.py index e41af26..58364c5 100644 --- a/agent/db.py +++ b/agent/db.py @@ -677,6 +677,12 @@ def get_weekday_pattern(start_date: str = None, end_date: str = None, category: ("yearly", 365, 15), ] +# Canonical day-count per frequency label, for projecting the next expected +# charge date from the last one seen — the classified label, not the group's +# own (possibly slightly off) avg_gap, since the label is what a human means +# by "monthly". +_FREQUENCY_DAYS = {label: days for label, days, _ in _RECURRING_FREQUENCIES} + def _classify_frequency(avg_gap_days: float) -> str | None: best_label, best_diff = None, None @@ -721,6 +727,7 @@ def get_recurring_expenses() -> list[dict]: frequency = _classify_frequency(avg_gap) if not frequency: continue + next_expected_date = r["last_date"] + timedelta(days=_FREQUENCY_DAYS[frequency]) results.append({ "description": r["description"], "amount": float(r["amount"]), @@ -728,6 +735,7 @@ def get_recurring_expenses() -> list[dict]: "occurrences": r["occurrences"], "last_date": str(r["last_date"]), "frequency": frequency, + "next_expected_date": str(next_expected_date), }) return results @@ -865,6 +873,37 @@ def get_budget_status(category: str = None, month: str = None) -> list[dict]: return status +# Matches the "near budget" color threshold already used in the frontend +# (BreakdownRow / BudgetSettings) — an insight is only worth surfacing +# unprompted once a category is at least this close to its limit. +INSIGHT_THRESHOLD_PCT = 80 + +# How many days ahead a recurring charge counts as "coming up" — a reminder +# further out than this isn't actionable yet and would just be noise. +UPCOMING_RECURRING_WINDOW_DAYS = 3 + + +def get_insights() -> list[dict]: + """Proactive, unprompted signals: budget categories at/over threshold, and + recurring charges due soon. Each item carries type + key so the frontend + can render and dismiss budget vs. recurring insights independently, even + when they'd otherwise collide (e.g. two recurring charges in the same + category) — key is unique within a single call's result set.""" + insights = [] + + for s in get_budget_status(): + if s["pct_used"] >= INSIGHT_THRESHOLD_PCT: + insights.append({**s, "type": "budget", "key": f"budget:{s['category']}"}) + + today = date.today() + for r in get_recurring_expenses(): + days_until = (date.fromisoformat(r["next_expected_date"]) - today).days + if 0 <= days_until <= UPCOMING_RECURRING_WINDOW_DAYS: + insights.append({**r, "type": "recurring", "days_until": days_until, "key": f"recurring:{r['description']}"}) + + return insights + + def set_budget(category: str, monthly_limit: float) -> dict: _run( """ diff --git a/api/server.py b/api/server.py index 7ab80cb..18bdec7 100644 --- a/api/server.py +++ b/api/server.py @@ -19,10 +19,10 @@ delete_expense, delete_income, get_api_call_count, - get_budget_status, get_budgets, get_expenses, get_income, + get_insights, get_recurring_expenses, get_user_by_username, increment_api_call_count, @@ -154,15 +154,9 @@ def budgets_endpoint(user_id: int = Depends(get_current_user)): return get_budgets() -# Matches the "near budget" color threshold already used in the frontend -# (BreakdownRow / BudgetSettings) — an insight is only worth surfacing -# unprompted once a category is at least this close to its limit. -INSIGHT_THRESHOLD_PCT = 80 - - @app.get("/insights") def insights_endpoint(user_id: int = Depends(get_current_user)): - return [s for s in get_budget_status() if s["pct_used"] >= INSIGHT_THRESHOLD_PCT] + return get_insights() @app.put("/budgets/{category}") diff --git a/frontend/e2e/cashflow.spec.js b/frontend/e2e/cashflow.spec.js index c22c38d..8f504ae 100644 --- a/frontend/e2e/cashflow.spec.js +++ b/frontend/e2e/cashflow.spec.js @@ -6,9 +6,10 @@ import { goToExpensesTab, login } from "./fixtures"; // income = 2500.00 + 42.75 + 30.00 = 2572.75 // expense = 1850.00 + 142.37 + 38.50 + 9.50 + 54.20 + 210.00 + 120.00 // + 310.00 + 89.99 + 12.50 + 22.00 + 15.99 + 60.00 (e2e_test) -// + 9.99 (e2e_housemate's Cloud Storage) = 2945.04 -// net = 2572.75 - 2945.04 = -372.29 -const EXPECTED_NET = "-$372.29"; +// + 9.99 (e2e_housemate's Cloud Storage) +// + 45.00 * 3 (recurring Gym Membership pattern) = 3080.04 +// net = 2572.75 - 3080.04 = -507.29 +const EXPECTED_NET = "-$507.29"; test.beforeEach(async ({ page }) => { await login(page); diff --git a/frontend/e2e/insights.spec.js b/frontend/e2e/insights.spec.js index df911a4..9655dbc 100644 --- a/frontend/e2e/insights.spec.js +++ b/frontend/e2e/insights.spec.js @@ -41,3 +41,18 @@ test("dismissal persists per-category across a reload on the same day", async ({ // Never-dismissed categories still show up after the reload. await expect(page.getByText("Driving is at", { exact: false })).toBeVisible(); }); + +// Seed data also plants a 3-occurrence monthly "Gym Membership" pattern whose +// next charge lands 3 days out — inside the proactive reminder window — +// independent of the budget insights above (Subscription has no budget set). + +test("shows a 'renews soon' reminder for a recurring charge due within the window, dismissible independently of budget insights", async ({ page }) => { + await expect(page.getByText("Gym Membership", { exact: false })).toBeVisible(); + await expect(page.getByText("renews in 3 days", { exact: false })).toBeVisible(); + + await page.getByRole("button", { name: "Dismiss Gym Membership reminder" }).click(); + await expect(page.getByText("Gym Membership", { exact: false })).toHaveCount(0); + + // Budget insights are unaffected by dismissing the recurring reminder. + await expect(page.getByText("Dining is at", { exact: false })).toBeVisible(); +}); diff --git a/frontend/src/components/Chat.jsx b/frontend/src/components/Chat.jsx index e6949b2..de7e0db 100644 --- a/frontend/src/components/Chat.jsx +++ b/frontend/src/components/Chat.jsx @@ -185,15 +185,16 @@ export default function Chat({ onExpenseChange, className = "", token, username, fetch("/chat/commands").then((r) => r.json()).then(setCommands).catch(() => {}); }, []); - // Proactive budget insights: a calm, dismissible note (not a modal, not an - // agent message — no LLM call involved) for categories at or near their - // monthly limit. Dismissal is per-category and remembered for the day, not - // forever, so acknowledging one still-relevant warning doesn't hide a - // different category's warning, and neither vanishes once and never comes - // back. + // Proactive insights: a calm, dismissible note (not a modal, not an agent + // message — no LLM call involved) for budget categories at/near their + // monthly limit, and recurring charges due in the next few days. Dismissal + // is per-item (keyed by the server-provided `key`, unique across both + // insight types) and remembered for the day, not forever, so acknowledging + // one still-relevant warning doesn't hide a different one, and neither + // vanishes once and never comes back. const [insights, setInsights] = useState([]); const insightsDismissKey = `insights_dismissed_${username}_${localDateString()}`; - const [dismissedCategories, setDismissedCategories] = useState(() => { + const [dismissedKeys, setDismissedKeys] = useState(() => { try { return new Set(JSON.parse(localStorage.getItem(insightsDismissKey) || "[]")); } catch { @@ -208,11 +209,11 @@ export default function Chat({ onExpenseChange, className = "", token, username, .catch(() => {}); }, [token]); - const visibleInsights = insights.filter((i) => !dismissedCategories.has(i.category)); + const visibleInsights = insights.filter((i) => !dismissedKeys.has(i.key)); - const dismissInsight = (category) => { - setDismissedCategories((prev) => { - const next = new Set(prev).add(category); + const dismissInsight = (key) => { + setDismissedKeys((prev) => { + const next = new Set(prev).add(key); try { localStorage.setItem(insightsDismissKey, JSON.stringify([...next])); } catch { @@ -548,16 +549,26 @@ export default function Chat({ onExpenseChange, className = "", token, username,
- - {i.category} - - {" "}is at {i.pct_used.toFixed(0)}% of budget (${i.spent.toFixed(0)} of ${i.monthly_limit.toFixed(0)}) + {i.type === "recurring" ? ( + <> + {i.description} + {" "}(${i.amount.toFixed(2)}) renews{" "} + {i.days_until === 0 ? "today" : i.days_until === 1 ? "tomorrow" : `in ${i.days_until} days`} + > + ) : ( + <> + + {i.category} + + {" "}is at {i.pct_used.toFixed(0)}% of budget (${i.spent.toFixed(0)} of ${i.monthly_limit.toFixed(0)}) + > + )}