Skip to content

Commit 303b445

Browse files
committed
Release 2.5.8: freeze past target snapshot, fix paid-bill logic, align Dougie with dashboard
1 parent b7dfa18 commit 303b445

10 files changed

Lines changed: 86 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
### 2.5.8: 2026-04-20
2+
3+
* Snapshot discretionary target per day so past chart colors stop drifting
4+
* Count bills as paid only when actually paid, not when due day has passed
5+
* Dougie uses priority bills and debts in without-bills mode to match dashboard
6+
* Dougie no longer states the daily budget is 0 euros when reservations compress it to zero
7+
18
### 2.5.7: 2026-04-19
29

310
* Clamp due day to last day of month so day 31 incomes/bills work in shorter months

src/app/(app)/dashboard/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ export default function DashboardPage() {
285285
// Separate bills from discretionary for accurate projection
286286
const totalBillsAmount = bills.filter((b) => b.is_active).reduce((s, b) => s + b.amount, 0);
287287
const paidBillsAmount = bills
288-
.filter((b) => b.is_active && (matchedBillIds.has(b.id) || b.due_day < today))
288+
.filter((b) => b.is_active && b.is_paid)
289289
.reduce((s, b) => s + b.amount, 0);
290290
const unpaidBillsAmount = totalBillsAmount - paidBillsAmount;
291291
const discretionarySpending = Math.max(0, realSpendingTotal - paidBillsAmount);
@@ -539,7 +539,7 @@ export default function DashboardPage() {
539539
monthExpenses={Math.round((totalBillsAmount + debtMonthly + investmentMonthly + savingRate + (dailyDiscretionary * daysInMonth)) * 100) / 100}
540540
trendPercent={trendPercent}
541541
budgetBreakdown={budgetResult.tightestSegment}
542-
streakProps={{ dailyBudget, todaySpent: todaySpentAll }}
542+
streakProps={{ dailyBudget, todaySpent: todaySpentAll, discretionaryTarget: discretionaryTargetPerDay }}
543543
/>
544544

545545
<div className="page-grid-2">

src/app/api/chat/route.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export async function POST(request: Request) {
9393
.filter((a: any) => a.type === "otherDebt" && a.balance < 0)
9494
.map((a: any) => {
9595
const override = debtOverrideMap[a.id];
96-
return { name: a.name, remaining: Math.abs(a.balance), rate: override?.interest_rate ?? 0, minimumPayment: override?.minimum_payment ?? 0, dueDay: override?.due_day ?? 0 };
96+
return { name: a.name, remaining: Math.abs(a.balance), rate: override?.interest_rate ?? 0, minimumPayment: override?.minimum_payment ?? 0, dueDay: override?.due_day ?? 0, isPriority: !!override?.is_priority };
9797
});
9898

9999
// Get investment accounts with overrides
@@ -224,9 +224,12 @@ export async function POST(request: Request) {
224224
resolveDay,
225225
};
226226

227-
// Apply bills setting: auto, always, or never
227+
// Apply bills setting: auto, always, or never. Priority items always reserved.
228+
const priorityBills = unpaidBills.filter((b: any) => b.isPriority).map((b: any) => ({ amount: b.amount, dueDay: b.dueDay }));
229+
const priorityDebts = allDebtItems.filter((_: unknown, i: number) => debts[i]?.isPriority);
230+
const allPriorityBills = enrichedBills.filter((b: any) => b.isPriority).map((b: any) => ({ amount: b.amount, dueDay: b.dueDay }));
228231
const budgetWithBills = calculateDailyBudget(budgetParams);
229-
const budgetWithoutBills = calculateDailyBudget({ ...budgetParams, unpaidBills: [], debts: [] });
232+
const budgetWithoutBills = calculateDailyBudget({ ...budgetParams, unpaidBills: priorityBills, debts: priorityDebts, allBills: allPriorityBills, allDebts: priorityDebts });
230233
let dailyBudget: number;
231234
if (billsSetting === "auto") {
232235
const totalUnpaid = budgetParams.unpaidBills.reduce((s: number, b: { amount: number }) => s + b.amount, 0) + budgetParams.debts.reduce((s: number, d: { amount: number }) => s + d.amount, 0);

src/app/api/daily-budget-history/route.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export async function GET() {
88
if (!user) return NextResponse.json({ history: [] }, { status: 401 });
99

1010
const db = getDb();
11-
const history = db.prepare("SELECT date, budget, spent FROM daily_budget_history ORDER BY date DESC LIMIT 31").all();
11+
const history = db.prepare("SELECT date, budget, spent, discretionary_target FROM daily_budget_history ORDER BY date DESC LIMIT 31").all();
1212
console.debug("[daily-budget-history] Loaded", (history as unknown[]).length, "entries");
1313
return NextResponse.json({ history });
1414
} catch (error) {
@@ -22,16 +22,16 @@ export async function POST(request: Request) {
2222
const user = await getSession();
2323
if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
2424

25-
const { date, budget, spent } = await request.json();
25+
const { date, budget, spent, discretionary_target } = await request.json();
2626
if (!date || budget === undefined) return NextResponse.json({ error: "date and budget required" }, { status: 400 });
2727

2828
const db = getDb();
2929
db.prepare(`
30-
INSERT INTO daily_budget_history (date, budget, spent) VALUES (?, ?, ?)
31-
ON CONFLICT(date) DO UPDATE SET budget = excluded.budget, spent = excluded.spent
32-
`).run(date, budget, spent || 0);
30+
INSERT INTO daily_budget_history (date, budget, spent, discretionary_target) VALUES (?, ?, ?, ?)
31+
ON CONFLICT(date) DO UPDATE SET budget = excluded.budget, spent = excluded.spent, discretionary_target = excluded.discretionary_target
32+
`).run(date, budget, spent || 0, discretionary_target || 0);
3333

34-
console.debug("[daily-budget-history] Saved", date, "budget:", budget, "spent:", spent);
34+
console.debug("[daily-budget-history] Saved", date, "budget:", budget, "spent:", spent, "target:", discretionary_target);
3535
return NextResponse.json({ success: true });
3636
} catch (error) {
3737
console.error("[daily-budget-history] POST error:", error);

src/app/api/summary/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export async function GET(request: Request) {
167167
// Better projection: separate bills from discretionary spending
168168
const totalBillsAmount = recurringBills.reduce((s, b) => s + b.amount, 0);
169169
const paidBillsAmount = recurringBills
170-
.filter((b) => matchedBillIds.has(b.id) || b.due_day < now.getDate())
170+
.filter((b) => matchedBillIds.has(b.id))
171171
.reduce((s, b) => s + b.amount, 0);
172172
const unpaidBillsAmount = totalBillsAmount - paidBillsAmount;
173173
const discretionarySpending = Math.max(0, monthActivity - paidBillsAmount);

src/components/dashboard/daily-allowance.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ interface DailyAllowanceProps {
2929
streakProps?: {
3030
dailyBudget: number;
3131
todaySpent: number;
32+
discretionaryTarget?: number;
3233
};
3334
thresholds?: { tight: number; normal: number; good: number };
3435
budgetBreakdown?: {

src/components/dashboard/savings-streak.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useState, useEffect, useRef } from "react";
88
interface SavingsStreakProps {
99
dailyBudget: number;
1010
todaySpent: number;
11+
discretionaryTarget?: number;
1112
}
1213

1314
interface HistoryEntry {
@@ -16,7 +17,7 @@ interface HistoryEntry {
1617
spent: number;
1718
}
1819

19-
export function SavingsStreak({ dailyBudget, todaySpent }: SavingsStreakProps) {
20+
export function SavingsStreak({ dailyBudget, todaySpent, discretionaryTarget }: SavingsStreakProps) {
2021
const { locale, fmt } = useLocale();
2122
const [history, setHistory] = useState<HistoryEntry[]>([]);
2223
const savedRef = useRef(false);
@@ -41,9 +42,9 @@ export function SavingsStreak({ dailyBudget, todaySpent }: SavingsStreakProps) {
4142
fetch("/api/daily-budget-history", {
4243
method: "POST",
4344
headers: { "Content-Type": "application/json" },
44-
body: JSON.stringify({ date: todayDate, budget: dailyBudget, spent: todaySpent }),
45+
body: JSON.stringify({ date: todayDate, budget: dailyBudget, spent: todaySpent, discretionary_target: discretionaryTarget || 0 }),
4546
}).catch(() => {});
46-
}, [dailyBudget, todaySpent]);
47+
}, [dailyBudget, todaySpent, discretionaryTarget]);
4748

4849
// Last 7 days from history
4950
const days: { day: number; month: number; status: "fire" | "fail" | "today" | "nodata"; budget: number; spent: number }[] = [];

src/components/dashboard/spending-flow.tsx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { useEffect, useState } from "react";
34
import { useLocale } from "@/lib/locale-context";
45
import {
56
AreaChart,
@@ -21,6 +22,13 @@ interface SpendingFlowProps {
2122
dailyBudget: number;
2223
}
2324

25+
interface SnapshotEntry {
26+
date: string;
27+
budget: number;
28+
spent: number;
29+
discretionary_target: number;
30+
}
31+
2432
function ratioToColor(r: number): string {
2533
// r = actual/target. Under 1.0 = good, over = bad
2634
// 0-0.95 green, 0.95-1.0 green→yellow, 1.0-1.03 yellow→red, 1.03+ red
@@ -51,9 +59,39 @@ export function SpendingFlow({
5159
dailyBudget,
5260
}: SpendingFlowProps) {
5361
const { locale, fmt } = useLocale();
62+
const [snapshots, setSnapshots] = useState<SnapshotEntry[]>([]);
63+
64+
useEffect(() => {
65+
fetch("/api/daily-budget-history")
66+
.then((r) => r.json())
67+
.then((data) => { if (data.history) setSnapshots(data.history); })
68+
.catch(() => {});
69+
}, []);
70+
71+
// Build per-day target lookup from snapshots: day-of-month -> frozen target
72+
const now = new Date();
73+
const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
74+
const snapshotByDay: Record<number, number> = {};
75+
for (const s of snapshots) {
76+
if (s.date.startsWith(monthPrefix) && s.discretionary_target > 0) {
77+
const day = parseInt(s.date.split("-")[2], 10);
78+
snapshotByDay[day] = s.discretionary_target;
79+
}
80+
}
81+
5482
// Target = daily budget (from cash flow simulation) * days in month
5583
const target = dailyBudget * daysInMonth;
5684

85+
// Cumulative target by day from frozen snapshots, falling back to live targetPerDay
86+
// when a day has no snapshot yet (today and future)
87+
const cumulativeTargetByDay: Record<number, number> = {};
88+
let runningTarget = 0;
89+
for (let d = 1; d <= daysInMonth; d++) {
90+
const perDay = snapshotByDay[d] > 0 ? snapshotByDay[d] : targetPerDay;
91+
runningTarget += perDay;
92+
cumulativeTargetByDay[d] = runningTarget;
93+
}
94+
5795
const data: { day: number; label: string; actual?: number; projected?: number; target?: number }[] = [];
5896
let cumulative = 0;
5997

@@ -66,7 +104,7 @@ export function SpendingFlow({
66104
day: d,
67105
label: `${d}.`,
68106
actual: discretionary,
69-
target: targetPerDay > 0 ? Math.round(targetPerDay * d) : undefined,
107+
target: cumulativeTargetByDay[d] > 0 ? Math.round(cumulativeTargetByDay[d]) : undefined,
70108
});
71109
} else {
72110
// Projection based on discretionary daily rate (already excludes bills)
@@ -77,7 +115,7 @@ export function SpendingFlow({
77115
day: d,
78116
label: `${d}.`,
79117
projected: Math.round(projected),
80-
target: targetPerDay > 0 ? Math.round(targetPerDay * d) : undefined,
118+
target: cumulativeTargetByDay[d] > 0 ? Math.round(cumulativeTargetByDay[d]) : undefined,
81119
});
82120
}
83121
}
@@ -89,14 +127,22 @@ export function SpendingFlow({
89127
const lastActual = data[daysPassed - 1]?.actual || 0;
90128
const monthEndTarget = target > 0 ? target : 0;
91129

92-
const todayTarget = targetPerDay > 0 ? Math.round(targetPerDay * daysPassed) : 0;
130+
// Use cumulative snapshot for the "today" comparison so past target shifts don't move the bubble
131+
const cumulativeToToday = (() => {
132+
let sum = 0;
133+
for (let d = 1; d <= daysPassed; d++) {
134+
sum += snapshotByDay[d] > 0 ? snapshotByDay[d] : targetPerDay;
135+
}
136+
return sum;
137+
})();
138+
const todayTarget = cumulativeToToday > 0 ? Math.round(cumulativeToToday) : 0;
93139
const todayDiff = todayTarget - lastActual;
94140
const todayRatio = todayTarget > 0 ? lastActual / todayTarget : 0;
95141
const ballColor = targetPerDay > 0 ? ratioToColor(todayRatio) : "#818cf8";
96142

97143
const gradientStops = data.filter((d) => d.actual !== undefined).map((d, i, arr) => {
98144
const pos = arr.length > 1 ? i / (arr.length - 1) : 0.5;
99-
const dayTarget = targetPerDay > 0 ? targetPerDay * d.day : 0;
145+
const dayTarget = cumulativeTargetByDay[d.day] || 0;
100146
const r = dayTarget > 0 ? (d.actual || 0) / dayTarget : 0;
101147
return { pos, color: ratioToColor(r) };
102148
});

src/lib/ai/finance-advisor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ ADVICE STYLE:
102102
- When asked "can I afford X", consider all unpaid obligations, upcoming bills, and savings goals before answering.
103103
- If spending would leave less than 3 days worth of daily budget as buffer, advise against it.
104104
- Acknowledge must-pay obligations first, then discretionary spending.
105+
- NEVER tell the user their daily budget is 0 euros, even when the number is literally 0. A 0 euro daily budget means tight reservations against current balance, not that they are broke. Reason from balance, upcoming income, and pending obligations. Give a practical euro amount they can safely spend today based on those real factors, not the raw daily budget figure.
105106
106107
FORMATTING RULES (always follow):
107108
- NEVER use em-dashes (—) or en-dashes (–). Use commas, periods, or line breaks instead.

src/lib/db.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ function initializeDb(db: Database.Database) {
160160
date TEXT PRIMARY KEY,
161161
budget REAL NOT NULL DEFAULT 0,
162162
spent REAL NOT NULL DEFAULT 0,
163+
discretionary_target REAL NOT NULL DEFAULT 0,
163164
created_at TEXT NOT NULL DEFAULT (datetime('now'))
164165
);
165166
@@ -369,6 +370,13 @@ function initializeDb(db: Database.Database) {
369370
db.exec("ALTER TABLE payee_matches ADD COLUMN max_amount REAL DEFAULT 0");
370371
}
371372

373+
// Add discretionary_target column to daily_budget_history if missing
374+
const dbhCols = db.prepare("PRAGMA table_info(daily_budget_history)").all() as { name: string }[];
375+
if (dbhCols.length > 0 && !dbhCols.some((c) => c.name === "discretionary_target")) {
376+
console.info("[db] Adding discretionary_target column to daily_budget_history");
377+
db.exec("ALTER TABLE daily_budget_history ADD COLUMN discretionary_target REAL NOT NULL DEFAULT 0");
378+
}
379+
372380
// Add image_thumb column to chat_messages if missing
373381
const chatCols = db.prepare("PRAGMA table_info(chat_messages)").all() as { name: string }[];
374382
if (!chatCols.some((c) => c.name === "image_thumb")) {

0 commit comments

Comments
 (0)