Skip to content

Commit 3532bd3

Browse files
committed
Release 2.10.0: configurable automatic daily YNAB sync via cron
1 parent d8fe636 commit 3532bd3

4 files changed

Lines changed: 71 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### 2.10.0: 2026-05-29
2+
3+
* Add automatic daily YNAB sync via cron with a configurable hour setting, default 6
4+
15
### 2.9.0: 2026-05-27
26

37
* Add hide button on AI summary cards to mark individual summaries hidden in the shared database

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export default function SettingsPage() {
5858
const [budgetBillsMode, setBudgetBillsMode] = useState("auto");
5959
const [reserveNextMonthSaving, setReserveNextMonthSaving] = useState(false);
6060
const [reserveSaved, setReserveSaved] = useState(false);
61+
const [ynabSyncHour, setYnabSyncHour] = useState("6");
62+
const [syncHourSaved, setSyncHourSaved] = useState(false);
6163
const [billsModeSaved, setBillsModeSaved] = useState(false);
6264
const [thresholds, setThresholds] = useState({ tight: "20", normal: "30", good: "50" });
6365
const [thresholdsSaved, setThresholdsSaved] = useState(false);
@@ -128,6 +130,9 @@ export default function SettingsPage() {
128130
if (householdData.settings?.reserve_next_month_saving !== undefined) {
129131
setReserveNextMonthSaving(householdData.settings.reserve_next_month_saving === "1");
130132
}
133+
if (householdData.settings?.ynab_sync_hour !== undefined) {
134+
setYnabSyncHour(String(householdData.settings.ynab_sync_hour));
135+
}
131136
if (householdData.settings?.budget_threshold_tight) setThresholds((p) => ({ ...p, tight: householdData.settings.budget_threshold_tight }));
132137
if (householdData.settings?.budget_threshold_normal) setThresholds((p) => ({ ...p, normal: householdData.settings.budget_threshold_normal }));
133138
if (householdData.settings?.budget_threshold_good) setThresholds((p) => ({ ...p, good: householdData.settings.budget_threshold_good }));
@@ -777,6 +782,36 @@ export default function SettingsPage() {
777782
: "Use if your largest paycheck arrives in the last days of the month. Reserves next month's full saving goal on payday so the daily budget doesn't look overly generous. Next month skips the proportional saving deduction since it's already reserved."}
778783
</p>
779784
</div>
785+
<div className="form-field">
786+
<Label>{locale === "fi" ? "YNAB-synkronoinnin tunti" : "YNAB sync hour"}</Label>
787+
<div className="settings-row">
788+
<Input
789+
type="number"
790+
min="0"
791+
max="23"
792+
value={ynabSyncHour}
793+
onChange={(e) => setYnabSyncHour(e.target.value)}
794+
className="settings-input"
795+
/>
796+
<Button size="sm" variant="outline" onClick={async () => {
797+
const h = String(Math.min(23, Math.max(0, parseInt(ynabSyncHour, 10) || 6)));
798+
setYnabSyncHour(h);
799+
await fetch("/api/household", {
800+
method: "POST",
801+
headers: { "Content-Type": "application/json" },
802+
body: JSON.stringify({ ynab_sync_hour: h }),
803+
});
804+
setSyncHourSaved(true);
805+
setTimeout(() => setSyncHourSaved(false), 2000);
806+
}}>{t.common.save}</Button>
807+
{syncHourSaved && <span className="settings-saved">{t.common.saved}</span>}
808+
</div>
809+
<p className="settings-help">
810+
{locale === "fi"
811+
? "Tunti (0–23, Helsingin aika), jolloin YNAB synkronoidaan automaattisesti kerran päivässä."
812+
: "Hour (0–23, Helsinki time) when YNAB is synced automatically once a day."}
813+
</p>
814+
</div>
780815
<div className="form-field">
781816
<Label>{locale === "fi" ? "Budjettirajat (€)" : "Budget thresholds (€)"}</Label>
782817
<div className="list-edit-row">

src/app/api/household/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export async function GET() {
2828
budget_include_bills: settings.budget_include_bills || "1",
2929
reserve_next_month_saving: settings.reserve_next_month_saving || "0",
3030
last_reservation_month: settings.last_reservation_month || "",
31+
ynab_sync_hour: settings.ynab_sync_hour || "6",
3132
budget_threshold_tight: settings.budget_threshold_tight || "20",
3233
budget_threshold_normal: settings.budget_threshold_normal || "30",
3334
budget_threshold_good: settings.budget_threshold_good || "50",

src/app/api/ynab/sync/route.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,43 @@ export async function GET() {
6262
}
6363
}
6464

65-
export async function POST() {
65+
export async function POST(request: Request) {
6666
try {
67-
const user = await getSession();
67+
const { getHouseholdSetting } = await import("@/lib/household");
68+
// Allow cron calls with X-Cron-Secret header matching household setting
69+
const cronSecret = request.headers.get("x-cron-secret");
70+
const expectedSecret = getHouseholdSetting("cron_secret");
71+
const isCron = !!(cronSecret && expectedSecret && cronSecret === expectedSecret);
72+
73+
let user = await getSession();
74+
if (!user && isCron) {
75+
const { getDb } = await import("@/lib/db");
76+
const firstUser = getDb().prepare("SELECT id FROM users ORDER BY id LIMIT 1").get() as { id: number } | undefined;
77+
if (firstUser) user = { id: firstUser.id } as Awaited<ReturnType<typeof getSession>>;
78+
}
6879
if (!user) {
6980
console.warn("[api/ynab/sync] Unauthorized sync attempt");
7081
return NextResponse.json({ success: false, error: "Not authenticated" }, { status: 401 });
7182
}
7283

73-
console.info("[api/ynab/sync] Starting sync for user", user.id);
84+
// Cron runs hourly; only perform the full sync at the configured hour (default 6),
85+
// and at most once per day. Manual (session) syncs are never gated.
86+
if (isCron) {
87+
const syncHour = parseInt(getHouseholdSetting("ynab_sync_hour") || "6", 10);
88+
const nowH = new Date();
89+
const todayStr = `${nowH.getFullYear()}-${String(nowH.getMonth() + 1).padStart(2, "0")}-${String(nowH.getDate()).padStart(2, "0")}`;
90+
if (nowH.getHours() !== syncHour) {
91+
console.debug("[api/ynab/sync] Cron skip: hour", nowH.getHours(), "!= scheduled", syncHour);
92+
return NextResponse.json({ success: true, skipped: "not scheduled hour" });
93+
}
94+
if (getHouseholdSetting("last_ynab_cron_date") === todayStr) {
95+
console.debug("[api/ynab/sync] Cron skip: already synced today", todayStr);
96+
return NextResponse.json({ success: true, skipped: "already synced today" });
97+
}
98+
setHouseholdSetting("last_ynab_cron_date", todayStr);
99+
}
100+
101+
console.info("[api/ynab/sync] Starting sync for user", user.id, isCron ? "(cron)" : "");
74102

75103
const token = getYnabToken();
76104
const budgetId = getYnabBudgetId();

0 commit comments

Comments
 (0)