Skip to content

Commit cd1e4cb

Browse files
committed
Add save-by-date targets and fix dashboard scrollbar, mobile payee list, sticky topbar, hidden-category filtering and budget nav alert (2.14.0)
1 parent 75abff4 commit cd1e4cb

18 files changed

Lines changed: 378 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
### 2.14.0: 2026-06-11
2+
3+
* Add a save-by-date target type: set a goal amount and a date, and the monthly need is the amount still missing spread across the months left until that date
4+
* Fix the horizontal scrollbar on the dashboard by bounding the chart grid tracks and chart wrapper so charts shrink to fit, instead of clipping the overflow
5+
* Show the payee (Saaja) suggestions on mobile by replacing the native datalist with a custom dropdown, in both the add-expense and edit-transaction forms
6+
* Keep the sticky budget topbar below the app top bar across iOS Safari toolbar states by deriving its offset from the app top bar height including the safe area
7+
* Show hidden and snoozed categories only under the Kaikki filter, not in the overspent or money-available views
8+
* Add a notice dot on the Budjetti nav item that stays until no category is overspent, refreshing live as you assign money
9+
110
### 2.13.6: 2026-06-11
211

312
* Budget topbar on mobile: drop the side padding, keep the boxes on one line, and sit below the app top bar instead of behind it

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dough",
3-
"version": "2.13.6",
3+
"version": "2.14.0",
44
"private": true,
55
"scripts": {
66
"dev": "next dev -H 0.0.0.0 -p 3030",

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

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
66
import { Button } from "@/components/ui/button";
77
import { Input } from "@/components/ui/input";
88
import { Label } from "@/components/ui/label";
9+
import { DateField } from "@/components/ui/date-field";
910
import {
1011
Dialog,
1112
DialogContent,
@@ -42,6 +43,7 @@ interface BudgetCategory {
4243
target_monthly: number;
4344
target_amount: number;
4445
target_cadence: string;
46+
target_date: string;
4547
snooze_until_month: string;
4648
target_active: boolean;
4749
}
@@ -97,11 +99,27 @@ function availEps(decimals: number): number {
9799
const CADENCES = ["daily", "weekly", "monthly", "yearly"] as const;
98100

99101
function cadenceLabel(cadence: string, locale: string): string {
100-
const fi: Record<string, string> = { daily: "Päivä", weekly: "Viikko", monthly: "Kuukausi", yearly: "Vuosi" };
101-
const en: Record<string, string> = { daily: "Day", weekly: "Week", monthly: "Month", yearly: "Year" };
102+
const fi: Record<string, string> = { daily: "Päivä", weekly: "Viikko", monthly: "Kuukausi", yearly: "Vuosi", by_date: "Päivään mennessä" };
103+
const en: Record<string, string> = { daily: "Day", weekly: "Week", monthly: "Month", yearly: "Year", by_date: "By date" };
102104
return (locale === "fi" ? fi : en)[cadence] || cadence;
103105
}
104106

107+
// Months from the viewed month to the target date, inclusive (mirrors lib/budget-math).
108+
function monthsUntilInclusive(month: string, targetDate: string): number {
109+
const [cy, cm] = month.split("-").map(Number);
110+
const ty = Number(targetDate.slice(0, 4));
111+
const tm = Number(targetDate.slice(5, 7));
112+
if (!ty || !tm) return 1;
113+
return Math.max(1, (ty * 12 + tm) - (cy * 12 + cm) + 1);
114+
}
115+
116+
// Format an ISO date (YYYY-MM-DD) as Finnish d.m.yyyy for display.
117+
function formatIsoDate(iso: string): string {
118+
const p = iso.split("-");
119+
if (p.length !== 3) return iso;
120+
return `${Number(p[2])}.${Number(p[1])}.${p[0]}`;
121+
}
122+
105123
function cadenceSuffix(cadence: string, locale: string): string {
106124
const fi: Record<string, string> = { daily: "/ pv", weekly: "/ vko", monthly: "/ kk", yearly: "/ v" };
107125
const en: Record<string, string> = { daily: "/ day", weekly: "/ wk", monthly: "/ mo", yearly: "/ yr" };
@@ -133,6 +151,7 @@ export default function BudgetPage() {
133151
const [targetEditing, setTargetEditing] = useState(false);
134152
const [targetDraft, setTargetDraft] = useState<string>("");
135153
const [targetCadence, setTargetCadence] = useState<string>("monthly");
154+
const [targetDate, setTargetDate] = useState<string>("");
136155
const [moveOpen, setMoveOpen] = useState(false);
137156
const [moveDir, setMoveDir] = useState<"in" | "out">("out");
138157
const [moveOther, setMoveOther] = useState<string>("");
@@ -366,14 +385,25 @@ export default function BudgetPage() {
366385
if (!inspectorCat) return;
367386
const value = evalExpression(targetDraft);
368387
if (value === null) return;
388+
// A by_date target is meaningless without a date — keep the editor open until one is given.
389+
if (targetCadence === "by_date" && !targetDate) {
390+
console.warn("[budget] by_date target needs a target date");
391+
return;
392+
}
369393
try {
370394
await fetch("/api/targets", {
371395
method: "PUT",
372396
headers: { "Content-Type": "application/json" },
373-
body: JSON.stringify({ category_id: inspectorCat.id, monthly_amount: value, cadence: targetCadence }),
397+
body: JSON.stringify({
398+
category_id: inspectorCat.id,
399+
monthly_amount: value,
400+
cadence: targetCadence,
401+
target_date: targetCadence === "by_date" ? targetDate : "",
402+
}),
374403
});
375404
setTargetEditing(false);
376405
setTargetDraft("");
406+
setTargetDate("");
377407
load(month);
378408
} catch (err) {
379409
console.error("[budget] Save target error:", err);
@@ -846,6 +876,9 @@ export default function BudgetPage() {
846876
{bdrag?.type === "group" && dropGroupAt === localGroups.length && <div className="budget-group-drop-line" aria-hidden="true" />}
847877

848878
{(() => {
879+
// Hidden/snoozed categories belong only under the "Kaikki" (all) filter, not the
880+
// overspent / money-available views which should show active budget rows only.
881+
if (filter !== "all") return null;
849882
const snoozedCats = (data?.categories || []).filter((c) => c.is_active && c.snoozed);
850883
if (snoozedCats.length === 0) return null;
851884
return (
@@ -866,6 +899,7 @@ export default function BudgetPage() {
866899
})()}
867900

868901
{(() => {
902+
if (filter !== "all") return null;
869903
const hiddenCats = (data?.categories || []).filter((c) => !c.is_active);
870904
if (hiddenCats.length === 0) return null;
871905
return (
@@ -910,9 +944,12 @@ export default function BudgetPage() {
910944
{inspectorCat && (() => {
911945
const c = inspectorCat;
912946
const availState = c.available > eps ? "is-positive" : c.available < -eps ? "is-negative" : "is-zero";
913-
const hasTarget = c.target_monthly > 0;
947+
const isByDate = c.target_cadence === "by_date";
948+
// Use the configured amount, not the monthly need: a by_date goal that is already
949+
// met has a zero monthly need but should still show as a (completed) target.
950+
const hasTarget = c.target_amount > 0;
914951
const isSnoozed = hasTarget && c.snooze_until_month >= month;
915-
const progress = hasTarget ? Math.min(1, c.budgeted / c.target_monthly) : 0;
952+
const progress = c.target_monthly > 0 ? Math.min(1, c.budgeted / c.target_monthly) : (hasTarget ? 1 : 0);
916953
return (
917954
<>
918955
<SheetHeader className="insp-header">
@@ -980,11 +1017,28 @@ export default function BudgetPage() {
9801017
{CADENCES.map((cad) => (
9811018
<SelectItem key={cad} value={cad}>{cadenceLabel(cad, locale)}</SelectItem>
9821019
))}
1020+
<SelectItem value="by_date">{cadenceLabel("by_date", locale)}</SelectItem>
9831021
</SelectContent>
9841022
</Select>
9851023
</div>
1024+
{targetCadence === "by_date" && (
1025+
<div className="insp-target-date">
1026+
<Label className="insp-target-date-label">{locale === "fi" ? "Mihin päivään mennessä" : "By which date"}</Label>
1027+
<DateField value={targetDate} onChange={setTargetDate} />
1028+
</div>
1029+
)}
9861030
<p className="settings-help">
987-
{targetCadence === "monthly"
1031+
{targetCadence === "by_date"
1032+
? (() => {
1033+
const goal = evalExpression(targetDraft) || 0;
1034+
if (!targetDate) return locale === "fi" ? "Säästä tämä summa valittuun päivään mennessä." : "Save this amount by the chosen date.";
1035+
const n = monthsUntilInclusive(month, targetDate);
1036+
const need = Math.max(0, (goal - c.carryover) / n);
1037+
return locale === "fi"
1038+
? `Tarvitaan noin ${fmt(Math.round(need * 100) / 100)} € / kk, jotta ${fmt(goal)} € on kasassa ${formatIsoDate(targetDate)}.`
1039+
: `Needs about ${fmt(Math.round(need * 100) / 100)} € / mo to reach ${fmt(goal)} € by ${formatIsoDate(targetDate)}.`;
1040+
})()
1041+
: targetCadence === "monthly"
9881042
? (locale === "fi" ? "Summa, joka varataan tälle joka kuukausi." : "Amount assigned here every month.")
9891043
: (() => {
9901044
const amt = evalExpression(targetDraft) || 0;
@@ -995,22 +1049,29 @@ export default function BudgetPage() {
9951049
<div className="insp-actions">
9961050
<Button type="button" size="sm" onClick={saveTarget}>{locale === "fi" ? "Tallenna" : "Save"}</Button>
9971051
{hasTarget && <Button type="button" variant="destructive" size="sm" onClick={() => clearTarget(c.id)}>{locale === "fi" ? "Poista" : "Clear"}</Button>}
998-
<Button type="button" variant="ghost" size="sm" onClick={() => { setTargetEditing(false); setTargetDraft(""); }}>{locale === "fi" ? "Peruuta" : "Cancel"}</Button>
1052+
<Button type="button" variant="ghost" size="sm" onClick={() => { setTargetEditing(false); setTargetDraft(""); setTargetDate(""); }}>{locale === "fi" ? "Peruuta" : "Cancel"}</Button>
9991053
</div>
10001054
</div>
10011055
) : hasTarget ? (
10021056
<div className="insp-target">
10031057
<div className="budget-target-progress insp-target-bar"><span className="budget-target-progress-fill" style={{ width: `${Math.round(progress * 100)}%` }} /></div>
10041058
<p className="insp-target-text">
1005-
{isSnoozed ? (locale === "fi" ? "Tauolla tässä kuussa" : "Paused this month") : (
1059+
{isSnoozed ? (locale === "fi" ? "Tauolla tässä kuussa" : "Paused this month") : isByDate ? (
1060+
<>
1061+
<F v={c.target_amount} s=" €" /> {locale === "fi" ? "→" : "by"} {c.target_date ? formatIsoDate(c.target_date) : ""}
1062+
{c.target_monthly > 0
1063+
? <span className="text-muted"> · <F v={c.target_monthly} s=" €" /> {cadenceSuffix("monthly", locale)}</span>
1064+
: <span className="text-positive"> · {locale === "fi" ? "valmis" : "funded"}</span>}
1065+
</>
1066+
) : (
10061067
<>
10071068
<F v={c.target_amount} s=" €" /> {cadenceSuffix(c.target_cadence, locale)}
10081069
{c.target_cadence !== "monthly" && <span className="text-muted"> · <F v={c.target_monthly} s=" €" /> {cadenceSuffix("monthly", locale)}</span>}
10091070
</>
10101071
)}
10111072
</p>
10121073
<div className="insp-actions">
1013-
<Button type="button" variant="outline" size="sm" onClick={() => { setTargetDraft(c.target_amount ? fmt(c.target_amount) : ""); setTargetCadence(c.target_cadence || "monthly"); setTargetEditing(true); }}>{locale === "fi" ? "Muokkaa" : "Edit"}</Button>
1074+
<Button type="button" variant="outline" size="sm" onClick={() => { setTargetDraft(c.target_amount ? fmt(c.target_amount) : ""); setTargetCadence(c.target_cadence || "monthly"); setTargetDate(c.target_date || ""); setTargetEditing(true); }}>{locale === "fi" ? "Muokkaa" : "Edit"}</Button>
10141075
{isSnoozed ? (
10151076
<Button type="button" variant="outline" size="sm" onClick={() => unsnoozeTarget(c.id)}>{locale === "fi" ? "Jatka" : "Resume"}</Button>
10161077
) : (
@@ -1019,7 +1080,7 @@ export default function BudgetPage() {
10191080
</div>
10201081
</div>
10211082
) : (
1022-
<Button type="button" variant="outline" size="sm" onClick={() => { setTargetDraft(""); setTargetCadence("monthly"); setTargetEditing(true); }}>{locale === "fi" ? "Aseta tavoite" : "Set a target"}</Button>
1083+
<Button type="button" variant="outline" size="sm" onClick={() => { setTargetDraft(""); setTargetCadence("monthly"); setTargetDate(""); setTargetEditing(true); }}>{locale === "fi" ? "Aseta tavoite" : "Set a target"}</Button>
10231084
)}
10241085
</div>
10251086

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
Plus,
2828
} from "lucide-react";
2929
import { AddExpenseDialog } from "@/components/shared/add-expense-dialog";
30+
import { PayeeInput } from "@/components/shared/payee-input";
3031
import { F } from "@/components/ui/f";
3132

3233
type FilterType = "all" | "income" | "expenses" | "transfers";
@@ -47,6 +48,7 @@ export default function TransactionsPage() {
4748
const [addOpen, setAddOpen] = useState(false);
4849
const [allAccounts, setAllAccounts] = useState<{ id: string; name: string }[]>([]);
4950
const [allCategories, setAllCategories] = useState<string[]>([]);
51+
const [payees, setPayees] = useState<string[]>([]);
5052
const [editTx, setEditTx] = useState<{ id: string; payee: string; amount: number; category: string; memo: string | null; account_id: string; date: string } | null>(null);
5153
const [editSaving, setEditSaving] = useState(false);
5254
const [splitMode, setSplitMode] = useState(false);
@@ -61,6 +63,9 @@ export default function TransactionsPage() {
6163
fetch("/api/categories").then((r) => r.json()).then((data) => {
6264
if (Array.isArray(data.categories)) setAllCategories(data.categories.filter((c: { is_active: number }) => c.is_active).map((c: { name: string }) => c.name));
6365
}).catch(() => {});
66+
fetch("/api/payees").then((r) => r.json()).then((data) => {
67+
if (Array.isArray(data.payees)) setPayees(data.payees);
68+
}).catch(() => {});
6469
}, []);
6570

6671
const handleEditSave = async () => {
@@ -287,7 +292,7 @@ export default function TransactionsPage() {
287292
<div className="form-stack">
288293
<div className="form-field">
289294
<Label>{locale === "fi" ? "Saaja" : "Payee"}</Label>
290-
<Input value={editTx.payee} onChange={(e) => setEditTx({ ...editTx, payee: e.target.value })} />
295+
<PayeeInput value={editTx.payee} onChange={(v) => setEditTx({ ...editTx, payee: v })} payees={payees} />
291296
</div>
292297
<div className="form-field">
293298
<Label>{locale === "fi" ? "Summa" : "Amount"}</Label>

src/app/api/budget/alerts/route.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { NextResponse } from "next/server";
2+
import { getSession } from "@/lib/auth";
3+
import { getDb } from "@/lib/db";
4+
import { availableForCategory } from "@/lib/budget-math";
5+
6+
// Actionable budget state for the current month, used to show a notice dot on the Budjetti nav
7+
// item. "Overspent" = a category whose available balance has gone negative (YNAB's red
8+
// overbudgeted state). Reuses the canonical per-category available math so it matches the
9+
// budget page exactly. Snoozed categories and Ready-to-Assign are excluded.
10+
export async function GET() {
11+
try {
12+
const user = await getSession();
13+
if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
14+
15+
const db = getDb();
16+
const now = new Date();
17+
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
18+
console.debug("[budget/alerts] Computing overspent categories for", month);
19+
20+
const cats = db
21+
.prepare("SELECT id, name FROM categories WHERE is_active = 1")
22+
.all() as { id: number; name: string }[];
23+
const snoozed = new Set(
24+
(db.prepare("SELECT category_id FROM category_snoozes WHERE month = ?").all(month) as { category_id: number }[])
25+
.map((r) => r.category_id)
26+
);
27+
28+
let overspent = 0;
29+
for (const c of cats) {
30+
if (snoozed.has(c.id) || c.name === "Inflow: Ready to Assign") continue;
31+
const available = availableForCategory(db, c.id, c.name, month);
32+
if (available < -0.005) overspent++;
33+
}
34+
35+
console.info("[budget/alerts] overspent categories:", overspent);
36+
return NextResponse.json({ overspent });
37+
} catch (err) {
38+
console.error("[budget/alerts] error:", err);
39+
return NextResponse.json({ overspent: 0 });
40+
}
41+
}

src/app/api/budget/auto-assign/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
22
import { getSession } from "@/lib/auth";
33
import { getDb } from "@/lib/db";
44
import { eventBus } from "@/lib/event-bus";
5-
import { availableForCategory, monthlyTargetEquivalent, monthBudgetNumbers, assignedForMonth, CATEGORY_ACTIVITY_PREDICATE } from "@/lib/budget-math";
5+
import { availableForCategory, monthlyTargetEquivalent, byDateMonthlyTarget, monthBudgetNumbers, assignedForMonth, CATEGORY_ACTIVITY_PREDICATE } from "@/lib/budget-math";
66

77
/* eslint-disable @typescript-eslint/no-explicit-any */
88

@@ -26,14 +26,19 @@ function computeAutoAssign(db: ReturnType<typeof getDb>, month: string, mode: Mo
2626
// Per-mode "desired" amount each category should receive this month
2727
const desired = new Map<number, number>();
2828
if (mode === "underfunded") {
29-
const targets = db.prepare("SELECT category_id, monthly_amount, COALESCE(cadence,'monthly') AS cadence, snooze_until_month FROM category_targets").all() as { category_id: number; monthly_amount: number; cadence: string; snooze_until_month: string }[];
29+
const targets = db.prepare("SELECT category_id, monthly_amount, COALESCE(cadence,'monthly') AS cadence, COALESCE(target_date,'') AS target_date, snooze_until_month FROM category_targets").all() as { category_id: number; monthly_amount: number; cadence: string; target_date: string; snooze_until_month: string }[];
3030
const tMap = new Map(targets.map((t) => [t.category_id, t]));
3131
const snoozed = new Set((db.prepare("SELECT category_id FROM category_snoozes WHERE month = ?").all(month) as { category_id: number }[]).map((r) => r.category_id));
3232
for (const c of cats) {
3333
const t = tMap.get(c.id);
3434
if (!t || t.monthly_amount <= 0 || snoozed.has(c.id)) continue;
3535
if (t.snooze_until_month && t.snooze_until_month >= month) continue;
36-
const need = round(monthlyTargetEquivalent(t.monthly_amount, t.cadence, month) - availableForCategory(db, c.id, c.name, month));
36+
const available = availableForCategory(db, c.id, c.name, month);
37+
// by_date: fund this month's share of what is still missing toward the goal. Other
38+
// cadences refill the category up to their per-month equivalent.
39+
const need = t.cadence === "by_date" && t.target_date
40+
? byDateMonthlyTarget(t.monthly_amount, available, month, t.target_date)
41+
: round(monthlyTargetEquivalent(t.monthly_amount, t.cadence, month) - available);
3742
if (need > 0.005) desired.set(c.id, need);
3843
}
3944
} else {

0 commit comments

Comments
 (0)