From 6ce36756d458fd11cc565eb5ca9bfe5bd15f545a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:48:45 +0000 Subject: [PATCH] Add proactive nudges: recurring-charge-due-soon reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the in-app half of the "proactive ambient insights" roadmap item (#29) — actual push notifications (#30) are a separate infra project (VAPID keys, a subscription table, a scheduled worker) and stay out of scope here; this reuses data already being computed. get_recurring_expenses() now projects a next_expected_date from each pattern's last occurrence and classified frequency. A new get_insights() combines that with the existing budget-threshold check into one proactive signal list, so a recurring charge due in the next 3 days ("Gym Membership ($45.00) renews in 2 days") surfaces in the same dismissible banner as budget warnings, without waiting to be asked. Both insight types now carry a `type` and a `key` so the frontend can dismiss them independently even when a category collision would otherwise make that ambiguous — the existing budget-insight shape is unchanged, just extended, so no other client of it needed to change. Business logic that was living in the /insights endpoint (the 80% threshold filter) moved into agent/db.py alongside get_insights(), per this repo's stated architecture: no business logic in the API layer beyond auth/rate-limiting. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Qz6JBNFwhgem5GY3i2BEMY --- agent/db.py | 39 ++++++++++++++ api/server.py | 10 +--- frontend/e2e/cashflow.spec.js | 7 +-- frontend/e2e/insights.spec.js | 15 ++++++ frontend/src/components/Chat.jsx | 47 +++++++++------- scripts/seed_e2e_data.py | 19 +++++++ tests/test_api.py | 13 +++++ tests/test_db.py | 92 +++++++++++++++++++++++++++++++- 8 files changed, 212 insertions(+), 30 deletions(-) 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,
{visibleInsights.map((i) => ( -
+

- - {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)}) + + )}

diff --git a/scripts/seed_e2e_data.py b/scripts/seed_e2e_data.py index 4546384..ab415a5 100644 --- a/scripts/seed_e2e_data.py +++ b/scripts/seed_e2e_data.py @@ -64,6 +64,16 @@ def _today_minus(days: int) -> str: return (date.today() - timedelta(days=clamped)).isoformat() +def _days_ago(days: int) -> str: + """Unclamped days back from today — for recurring-pattern seed data, + where get_recurring_expenses() looks across all-time history regardless + of month, so the current-month clamp _today_minus applies would break + the fixed gap a recurring pattern needs to be detected at all.""" + from datetime import date, timedelta + + return (date.today() - timedelta(days=days)).isoformat() + + def seed(): _run("TRUNCATE expenses, income, budgets, users, api_calls RESTART IDENTITY CASCADE") @@ -98,6 +108,15 @@ def seed(): for amount, category, description, day in expenses: save_expense(amount, category, description, day, user_id=user_id) + # A monthly recurring pattern whose next charge is due soon, for the + # proactive "renews in N days" insight — needs 3+ occurrences at a + # consistent ~30-day gap, unrelated to any other spec's category/budget + # assertions (Subscription has no budget configured), so unclamped + # _days_ago is used instead of _today_minus (which would break the fixed + # gap by clamping older occurrences to the 1st of the current month). + for day in (_days_ago(87), _days_ago(57), _days_ago(27)): + save_expense(45.00, "Subscription", "Gym Membership", day, user_id=user_id) + # Income: kept small and distinct from every expense description so e2e # assertions can't accidentally match the wrong list. income = [ diff --git a/tests/test_api.py b/tests/test_api.py index b57df42..80e039c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -168,6 +168,19 @@ def test_insights_endpoint_includes_category_at_exactly_the_threshold(auth_heade assert result[0]["pct_used"] == 80.0 +def test_insights_endpoint_includes_a_recurring_charge_due_soon(auth_headers, user_id): + from datetime import date, timedelta + for offset in (87, 57, 27): + day = (date.today() - timedelta(days=offset)).isoformat() + add_expense(user_id, 45, "Subscription", "Gym Membership", day) + + result = client.get("/insights", headers=auth_headers).json() + + assert len(result) == 1 + assert result[0]["type"] == "recurring" + assert result[0]["description"] == "Gym Membership" + + # --- update/delete expense -------------------------------------------------- def test_update_expense_with_invalid_category_returns_422(auth_headers, user_id): diff --git a/tests/test_db.py b/tests/test_db.py index 070709f..cf491c1 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, timedelta from agent import db @@ -495,6 +495,96 @@ def test_recurring_treats_different_amounts_as_separate_unconfirmed_groups(user_ assert db.get_recurring_expenses() == [] +def test_recurring_next_expected_date_uses_the_frequency_label_not_measured_avg_gap(user_id): + for day in ["2026-01-05", "2026-01-12", "2026-01-19", "2026-01-26"]: + add_expense(user_id, 5, "Drinks", "Coffee Subscription", day) + + result = db.get_recurring_expenses() + + assert result[0]["frequency"] == "weekly" + assert result[0]["next_expected_date"] == "2026-02-02" # 2026-01-26 + 7 days + + +def test_recurring_next_expected_date_for_monthly_pattern(user_id): + for day in ["2026-03-05", "2026-04-05", "2026-05-05", "2026-06-05"]: + add_expense(user_id, 1850, "Rent", "Monthly Rent", day) + + result = db.get_recurring_expenses() + + assert result[0]["next_expected_date"] == "2026-07-05" # 2026-06-05 + 30 days + + +# --- get_insights ------------------------------------------------------------ + +def test_insights_includes_budget_categories_at_or_over_threshold(user_id): + today = date.today().isoformat() + db.set_budget("Dining", 100) + add_expense(user_id, 85, "Dining", "Dinner", today) + + result = db.get_insights() + + assert len(result) == 1 + assert result[0]["type"] == "budget" + assert result[0]["category"] == "Dining" + assert result[0]["key"] == "budget:Dining" + + +def test_insights_omits_budget_categories_comfortably_under_threshold(user_id): + db.set_budget("Dining", 300) + add_expense(user_id, 50, "Dining", "Dinner", date.today().isoformat()) + + assert db.get_insights() == [] + + +def test_insights_includes_a_recurring_charge_due_within_the_window(user_id): + # Monthly (30-day) pattern whose last occurrence was 27 days ago -> next + # expected in 3 days, right at the edge of the 3-day window (inclusive). + for offset in (87, 57, 27): + day = (date.today() - timedelta(days=offset)).isoformat() + add_expense(user_id, 45, "Subscription", "Gym Membership", day) + + result = db.get_insights() + + assert len(result) == 1 + assert result[0]["type"] == "recurring" + assert result[0]["description"] == "Gym Membership" + assert result[0]["days_until"] == 3 + assert result[0]["key"] == "recurring:Gym Membership" + + +def test_insights_excludes_a_recurring_charge_further_out_than_the_window(user_id): + # Same monthly pattern, but the last occurrence was only 20 days ago -> + # next expected in 10 days, outside the 3-day window. + for offset in (80, 50, 20): + day = (date.today() - timedelta(days=offset)).isoformat() + add_expense(user_id, 45, "Subscription", "Gym Membership", day) + + assert db.get_insights() == [] + + +def test_insights_excludes_an_overdue_recurring_charge_not_seen_again(user_id): + # Last occurrence was 40 days ago on a 30-day pattern -> "next expected" + # is 10 days in the past. Silently missing/cancelled, not "coming up". + for offset in (100, 70, 40): + day = (date.today() - timedelta(days=offset)).isoformat() + add_expense(user_id, 45, "Subscription", "Gym Membership", day) + + assert db.get_insights() == [] + + +def test_insights_combines_budget_and_recurring_signals_with_distinct_keys(user_id): + db.set_budget("Dining", 100) + add_expense(user_id, 85, "Dining", "Dinner", date.today().isoformat()) + for offset in (87, 57, 27): + day = (date.today() - timedelta(days=offset)).isoformat() + add_expense(user_id, 45, "Subscription", "Gym Membership", day) + + result = db.get_insights() + + keys = {r["key"] for r in result} + assert keys == {"budget:Dining", "recurring:Gym Membership"} + + # --- get_expenses description_contains --------------------------------- def test_get_expenses_description_contains_matches_substring(user_id):