Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions agent/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -721,13 +727,15 @@ 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"]),
"category": r["category"],
"occurrences": r["occurrences"],
"last_date": str(r["last_date"]),
"frequency": frequency,
"next_expected_date": str(next_expected_date),
})
return results

Expand Down Expand Up @@ -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(
"""
Expand Down
10 changes: 2 additions & 8 deletions api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}")
Expand Down
7 changes: 4 additions & 3 deletions frontend/e2e/cashflow.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions frontend/e2e/insights.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
47 changes: 29 additions & 18 deletions frontend/src/components/Chat.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -548,16 +549,26 @@ export default function Chat({ onExpenseChange, className = "", token, username,
</div>
<div className="flex-1 min-w-0 space-y-1">
{visibleInsights.map((i) => (
<div key={i.category} className="flex items-start gap-2">
<div key={i.key} className="flex items-start gap-2">
<p className="flex-1 text-xs text-foreground leading-relaxed">
<span className={`font-semibold ${i.over_budget ? "text-red-600 dark:text-red-400" : "text-amber-600 dark:text-amber-400"}`}>
{i.category}
</span>
{" "}is at {i.pct_used.toFixed(0)}% of budget (${i.spent.toFixed(0)} of ${i.monthly_limit.toFixed(0)})
{i.type === "recurring" ? (
<>
<span className="font-semibold text-foreground">{i.description}</span>
{" "}(${i.amount.toFixed(2)}) renews{" "}
{i.days_until === 0 ? "today" : i.days_until === 1 ? "tomorrow" : `in ${i.days_until} days`}
</>
) : (
<>
<span className={`font-semibold ${i.over_budget ? "text-red-600 dark:text-red-400" : "text-amber-600 dark:text-amber-400"}`}>
{i.category}
</span>
{" "}is at {i.pct_used.toFixed(0)}% of budget (${i.spent.toFixed(0)} of ${i.monthly_limit.toFixed(0)})
</>
)}
</p>
<button
onClick={() => dismissInsight(i.category)}
aria-label={`Dismiss ${i.category} budget insight`}
onClick={() => dismissInsight(i.key)}
aria-label={i.type === "recurring" ? `Dismiss ${i.description} reminder` : `Dismiss ${i.category} budget insight`}
className="shrink-0 text-muted-foreground hover:text-foreground text-sm leading-none"
>×</button>
</div>
Expand Down
19 changes: 19 additions & 0 deletions scripts/seed_e2e_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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 = [
Expand Down
13 changes: 13 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
92 changes: 91 additions & 1 deletion tests/test_db.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import date
from datetime import date, timedelta

from agent import db

Expand Down Expand Up @@ -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):
Expand Down