Files
LibrePortal 2cffc3ab07 Start LibreLedger: an encrypted money ledger that stores only ciphertext
LibreLedger is a single-person money ledger. The browser encrypts
everything (PBKDF2-HMAC-SHA256 with 250k iterations, then AES-256-GCM),
and a small standard-library Python server stores only the resulting
{v, salt, iv, ct} blob. Ledger files from the earlier Money Ledger version
open unchanged.

- On plain http to a LAN or VPN address, where browsers hide Web Crypto,
  the app switches to vendored @noble/hashes and @noble/ciphers 2.2.0 and
  says so on the lock screen. tests/crypto-interop.test.mjs shows both
  paths read each other's files.
- The server hands out only the app's own files, sends a self-only CSP,
  nosniff, frame denial and no-referrer, checks the shape of each blob,
  refuses cross-site writes and writes atomically. It keeps the last 10
  backups plus one per day for 30 days.
- The container runs that server from a digest-pinned python alpine
  image, as an unprivileged user, with its data in /data and a health
  check on /api/health.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:17:16 +01:00

2692 lines
128 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/* ============================================================
LibreLedger — single-page, client-side encrypted.
Everything is encrypted in this browser with AES-256-GCM,
using a key derived from your passphrase (PBKDF2). The
server (server.py) only ever stores the ciphertext.
============================================================ */
// Storage names keep the app's original "money-ledger" prefix so existing
// browsers keep their cached ledger, theme and layout after the rename.
const STORAGE_KEY = "money-ledger-v1";
const PBKDF2_ITERATIONS = 250000;
let book = null; // the whole decrypted document: { version, activeId, budgets: [...] }
let state = null; // the ACTIVE budget — a reference into book.budgets (keeps all rendering code unchanged)
let cryptoKey = null; // CryptoKey, only present while unlocked
let currentSalt = null; // Uint8Array(16)
let mode = "unlock"; // "unlock" | "create"
let saveTimer = null;
let serverMode = false; // true when served by server.py (persists to data/ledger.enc on disk; works in any browser)
let fileHandle = null; // FileSystemFileHandle when a save file is actively linked (write permission held)
let rememberedName = null; // name of a remembered save file (from IndexedDB) even if not active this session
// File System Access API — only Chromium desktop. Everything degrades to localStorage when absent.
const FS_SUPPORTED = !!(window.showSaveFilePicker && window.showOpenFilePicker);
/* ---------- element refs ---------- */
const lock = document.getElementById("lock");
const lockForm = document.getElementById("lock-form");
const lockMsg = document.getElementById("lock-msg");
const lockErr = document.getElementById("lock-err");
const lockBtn = document.getElementById("lock-btn");
const pass1 = document.getElementById("pass1");
const pass2 = document.getElementById("pass2");
const app = document.getElementById("app");
const accountsBody = document.getElementById("accounts-body");
const recurringBody = document.getElementById("recurring-body");
const monthsBody = document.getElementById("months-body");
const savingsBody = document.getElementById("savings-body");
const affordBody = document.getElementById("afford-body");
const totalsBody = document.getElementById("totals-body");
const budgetBar = document.getElementById("budget-bar");
const linkBtn = document.getElementById("btn-linkfile");
const lockFs = document.getElementById("lock-fs");
const currencyEl = document.getElementById("currency");
/* ---------- small helpers ---------- */
const enc = new TextEncoder();
const dec = new TextDecoder();
// crypto.randomUUID only exists in a secure context; getRandomValues works everywhere.
function uid() {
if (crypto.randomUUID) return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
const h = Array.from(b, x => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
function num(v) { const n = parseFloat(v); return isNaN(n) ? 0 : n; }
function fmt(v) {
const cur = (state && state.currency) || "";
const neg = v < 0;
const s = Math.abs(v).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return (neg ? "-" : "") + cur + s;
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/"/g, "&quot;")
.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function setOne(sel, text) { const el = document.querySelector(sel); if (el) el.textContent = text; }
function setAll(sel, text) { document.querySelectorAll(sel).forEach(el => (el.textContent = text)); }
function b64encode(buf) {
const bytes = new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function b64decode(str) { return Uint8Array.from(atob(str), c => c.charCodeAt(0)); }
/* ---------- category tags (the little colour dots) ---------- */
const TAG_COLORS = [
{ key: "", css: "transparent" },
{ key: "blue", css: "#3b82f6" },
{ key: "cyan", css: "#06b6d4" },
{ key: "violet", css: "#8b5cf6" },
{ key: "red", css: "#ef4444" },
{ key: "green", css: "#22c55e" },
{ key: "amber", css: "#f59e0b" },
{ key: "pink", css: "#ec4899" },
];
function tagCss(key) { const t = TAG_COLORS.find(t => t.key === key); return t ? t.css : "transparent"; }
function nextTag(key) {
const i = TAG_COLORS.findIndex(t => t.key === key);
return TAG_COLORS[(i < 0 ? 0 : i + 1) % TAG_COLORS.length].key;
}
/* ---------- categories (icon + name + colour) ---------- */
// The category is keyed by its emoji. Picking one stamps the icon and a default colour.
const CATEGORIES = [
{ icon: "💰", name: "Income", tag: "green" },
{ icon: "💼", name: "Work", tag: "blue" },
{ icon: "🏠", name: "Housing", tag: "blue" },
{ icon: "💡", name: "Utilities", tag: "amber" },
{ icon: "🌐", name: "Internet", tag: "cyan" },
{ icon: "📱", name: "Phone", tag: "cyan" },
{ icon: "🛒", name: "Groceries", tag: "green" },
{ icon: "🍔", name: "Eating out", tag: "amber" },
{ icon: "☕", name: "Coffee", tag: "amber" },
{ icon: "🍺", name: "Going out", tag: "violet" },
{ icon: "🚗", name: "Transport", tag: "violet" },
{ icon: "⛽", name: "Fuel", tag: "violet" },
{ icon: "🛡️", name: "Insurance", tag: "blue" },
{ icon: "🏥", name: "Health", tag: "red" },
{ icon: "💊", name: "Pharmacy", tag: "red" },
{ icon: "🏋️", name: "Fitness", tag: "green" },
{ icon: "🎬", name: "Entertainment", tag: "pink" },
{ icon: "📺", name: "Subscriptions", tag: "violet" },
{ icon: "🛍️", name: "Shopping", tag: "pink" },
{ icon: "🎁", name: "Gifts", tag: "pink" },
{ icon: "✈️", name: "Travel", tag: "cyan" },
{ icon: "🐷", name: "Savings", tag: "green" },
{ icon: "🏦", name: "Loans", tag: "red" },
{ icon: "💳", name: "Debt / Card", tag: "red" },
{ icon: "🧾", name: "Tax", tag: "amber" },
{ icon: "🎓", name: "Education", tag: "blue" },
{ icon: "👶", name: "Childcare", tag: "pink" },
{ icon: "🐶", name: "Pets", tag: "amber" },
];
const CAT_BY_ICON = new Map(CATEGORIES.map(c => [c.icon, c]));
const SAVINGS_ICON = "🐷"; // rows tagged with this category feed the Savings panel
const INCOME_ICON = "💰"; // rows tagged Income feed the affordability calculator's income
const HOUSING_ICON = "🏠"; // rows tagged Housing feed the affordability calculator's cost
function catOf(icon) { return CAT_BY_ICON.get(icon) || null; }
function catLabel(icon) { const c = catOf(icon); return c ? c.name : (icon ? icon : "Other"); }
// Stamp a category (icon + its colour) onto an entry. force=false only fills a colour that's empty.
function applyCat(entry, icon, force) {
entry.icon = icon || "";
const c = catOf(entry.icon);
if (c && (force || !entry.tag)) entry.tag = c.tag;
}
/* ---------- banks (account logos, with badge fallback) ---------- */
// logo:true → bundled SVG at banks/<key>.svg (brand-coloured); otherwise a brand-colour badge with `short`.
const BANKS = [
{ key: "monzo", name: "Monzo", color: "#FF4F40", logo: true },
{ key: "starlingbank", name: "Starling Bank", color: "#6935FF", logo: true },
{ key: "revolut", name: "Revolut", color: "#0666EB", logo: true },
{ key: "barclays", name: "Barclays", color: "#00AEEF", logo: true },
{ key: "hsbc", name: "HSBC", color: "#DB0011", logo: true },
{ key: "chase", name: "Chase", color: "#117ACA", logo: true },
{ key: "wise", name: "Wise", color: "#163300", logo: true },
{ key: "tide", name: "Tide", color: "#3C3CFF", logo: true },
{ key: "santander", name: "Santander", color: "#EC0000", logo: true },
{ key: "lloyds", name: "Lloyds Bank", color: "#024731", logo: true },
{ key: "natwest", name: "NatWest", color: "#5A287D", short: "NW" },
{ key: "nationwide", name: "Nationwide", color: "#1B0088", short: "N" },
{ key: "halifax", name: "Halifax", color: "#005EB8", short: "H" },
{ key: "tsb", name: "TSB", color: "#1B3A6B", logo: true },
{ key: "cooperative", name: "Co-operative Bank", color: "#00B6F1", short: "Co" },
{ key: "metro", name: "Metro Bank", color: "#E51937", short: "M" },
{ key: "virginmoney", name: "Virgin Money", color: "#E10A0A", logo: true },
{ key: "rbs", name: "Royal Bank of Scotland", color: "#142E64", logo: true },
{ key: "bankofscotland",name: "Bank of Scotland", color: "#002B6D", logo: true },
{ key: "firstdirect", name: "first direct", color: "#1A1A1A", short: "fd" },
{ key: "monese", name: "Monese", color: "#00B0A8", short: "Mo" },
{ key: "atom", name: "Atom Bank", color: "#E4002B", short: "A" },
{ key: "chip", name: "Chip", color: "#1A1A2E", short: "C" },
{ key: "amex", name: "American Express", color: "#2E77BC", logo: true },
{ key: "visa", name: "Visa", color: "#1A1F71", logo: true },
{ key: "mastercard", name: "Mastercard", color: "#EB001B", logo: true },
{ key: "paypal", name: "PayPal", color: "#003087", logo: true },
{ key: "cash", name: "Cash / other", color: "#16A34A", short: "£" },
];
const BANK_BY_KEY = new Map(BANKS.map(b => [b.key, b]));
function bankOf(key) { return BANK_BY_KEY.get(key) || null; }
function bankShort(b) { return b.short || (b.name || "?").slice(0, 1).toUpperCase(); }
function bankGlyph(key) {
const b = bankOf(key);
if (!b) return `<span class="bank-glyph bank-none" title="Choose bank">🏦</span>`;
if (b.logo) return `<span class="bank-glyph bank-logo" title="${esc(b.name)}"><img src="banks/${b.key}.svg" alt="${esc(b.name)}" loading="lazy"></span>`;
return `<span class="bank-glyph bank-badge" style="--bk:${b.color}" title="${esc(b.name)}">${esc(bankShort(b))}</span>`;
}
/* ---------- icons (emoji) ---------- */
// Keyword → category emoji for auto-suggesting from a description.
const ICON_RULES = [
[/rent|mortgage|landlord|housing|lease/i, "🏠"],
[/phone|mobile|cell|sim/i, "📱"],
[/internet|broadband|wifi|wi-fi|fibre|fiber/i, "🌐"],
[/electric|power|energy|\bgas\b|water|utilit/i, "💡"],
[/salary|wage|payroll|paycheck|payslip|income|dividend/i, "💰"],
[/grocer|supermarket|\bfood\b|tesco|aldi|lidl|sainsbury/i, "🛒"],
[/restaurant|dining|takeaway|takeout|cafe|coffee|costa|starbucks|mcdonald|deliveroo|uber eats/i, "🍔"],
[/\bcar\b|fuel|petrol|diesel|parking|bus|train|tube|uber|taxi|transport/i, "🚗"],
[/insurance/i, "🛡️"],
[/netflix|spotify|disney|stream|subscription|prime video/i, "🎬"],
[/gym|fitness|peloton/i, "🏋️"],
[/health|doctor|medical|pharmacy|dentist|optician|nhs/i, "🏥"],
[/saving|invest|isa|pension/i, "🐷"],
[/gift|present|birthday|christmas|xmas/i, "🎁"],
[/holiday|travel|flight|hotel|airbnb|vacation/i, "✈️"],
[/loan|debt|overdraft|repay/i, "🏦"],
[/\btax\b|hmrc|vat|council/i, "🧾"],
[/tuition|school|course|university|udemy/i, "🎓"],
[/tv licen|tv\b|cable/i, "📺"],
[/\bdog\b|\bcat\b|\bpet\b|vet/i, "🐶"],
];
function iconFor(desc) {
const s = desc || "";
for (const [re, emoji] of ICON_RULES) if (re.test(s)) return emoji;
return "";
}
/* ---------- date helpers (for the calendar month + recurring dates) ---------- */
function pad2(n) { return String(n).padStart(2, "0"); }
function currentYm() { const d = new Date(); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`; }
function daysInMonth(y, m) { return new Date(y, m, 0).getDate(); } // m is 1-12
function addMonthYm(ym) {
if (!/^\d{4}-\d{2}$/.test(ym)) return currentYm();
let [y, m] = ym.split("-").map(Number);
m++; if (m > 12) { m = 1; y++; }
return `${y}-${pad2(m)}`;
}
function ymName(ym) {
if (!/^\d{4}-\d{2}$/.test(ym)) return "";
const [y, m] = ym.split("-").map(Number);
return new Date(y, m - 1, 1).toLocaleString(undefined, { month: "long", year: "numeric" });
}
// True when a month's calendar month is strictly before the current one — a settled,
// historic month. YYYY-MM strings sort lexicographically, so a plain string compare works.
function isPastYm(ym) { return /^\d{4}-\d{2}$/.test(ym || "") && ym < currentYm(); }
// Ordinal suffix for a day number: 1→st, 2→nd, 3→rd, 9→th, 11→th …
function ordSuffix(n) {
n = parseInt(n, 10);
if (!n || n < 1) return "";
const v = n % 100;
if (v >= 11 && v <= 13) return "th";
return { 1: "st", 2: "nd", 3: "rd" }[n % 10] || "th";
}
/* ---------- data shape ---------- */
function newRow() { return { id: uid(), date: "", desc: "", inc: "", out: "", tag: "", icon: "" }; }
function newMonth(ym, rows) {
return {
id: uid(),
title: ym ? ymName(ym) : "",
ym: ym || "",
opening: "",
rows: rows && rows.length ? rows : [newRow()],
};
}
// A recurring item: income/expense that repeats. `dir` in|out, `freq` daily|weekly|monthly|yearly.
function newRecurring() {
return {
id: uid(), desc: "", dir: "out", amount: "", tag: "", icon: "", enabled: true,
freq: "monthly",
day: "1", // monthly: day of month
every: "2", anchor: "", until: "", // daily/weekly: every N days/weeks, from anchor date, optional end (until) date
ymonth: "1", yday: "1", // yearly: month + day
};
}
// A single budget (one "tab"). Holds everything a ledger needs, self-contained.
function newBudget(name) {
return {
id: uid(),
name: name || "Budget",
currency: "£",
accounts: [{ id: uid(), name: "", balance: "", bank: "" }],
recurring: [],
months: [newMonth(currentYm())],
};
}
// The whole document: a book of budgets plus which one is active.
function defaultBook() {
const b = newBudget("My Budget");
return { version: 2, activeId: b.id, budgets: [b] };
}
// Accept either a new book or an old single-budget document and always return a book.
function migrateToBook(obj) {
if (obj && Array.isArray(obj.budgets)) return obj; // already a book
const b = {
id: uid(),
name: "My Budget",
currency: (obj && obj.currency) || "£",
accounts: (obj && obj.accounts) || [{ id: uid(), name: "", balance: "", bank: "" }],
recurring: (obj && obj.recurring) || [],
months: (obj && obj.months) || [newMonth(currentYm())],
};
return { version: 2, activeId: b.id, budgets: [b] };
}
function activeBudget() { return book.budgets.find(b => b.id === book.activeId) || book.budgets[0]; }
// Point `state` at the active budget and keep activeId consistent.
function syncActive() { state = activeBudget(); book.activeId = state.id; }
// Deep copy + brand-new ids throughout, remapping rows' recId links to the cloned recurring items.
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
function reassignIds(b) {
b.id = uid();
(b.accounts || []).forEach(a => a.id = uid());
const recMap = {};
(b.recurring || []).forEach(it => { const old = it.id; it.id = uid(); recMap[old] = it.id; });
(b.months || []).forEach(m => {
m.id = uid();
(m.rows || []).forEach(r => {
r.id = uid();
if (r.recId) { if (recMap[r.recId]) r.recId = recMap[r.recId]; else delete r.recId; }
});
});
return b;
}
// Optional From/To date window shared by all frequencies (anchor = earliest, until = latest, both inclusive).
function withinBounds(item, iso) {
const t = Date.parse(iso);
const a = Date.parse(item.anchor), u = Date.parse(item.until);
if (!isNaN(a) && t < a) return false;
if (!isNaN(u) && t > u) return false;
return true;
}
/* ---------- recurring → concrete dated rows for a given month ---------- */
function recurOccurrences(item, ym) {
if (!/^\d{4}-\d{2}$/.test(ym)) return [];
const y = +ym.slice(0, 4), mo = +ym.slice(5, 7);
const dim = daysInMonth(y, mo);
const mk = (day) => {
const r = newRow();
r.date = `${ym}-${pad2(day)}`;
r.desc = item.desc;
r.tag = item.tag || "";
r.icon = item.icon || "";
r.recId = item.id; // link back to the recurring template so edits can propagate
if (item.dir === "in") r.inc = item.amount; else r.out = item.amount;
return r;
};
if (item.freq === "monthly") {
const day = Math.min(Math.max(parseInt(item.day, 10) || 1, 1), dim);
return withinBounds(item, `${ym}-${pad2(day)}`) ? [mk(day)] : [];
}
if (item.freq === "yearly") {
if ((parseInt(item.ymonth, 10) || 0) !== mo) return [];
const day = Math.min(Math.max(parseInt(item.yday, 10) || 1, 1), dim);
return withinBounds(item, `${ym}-${pad2(day)}`) ? [mk(day)] : [];
}
if (item.freq === "weekly" || item.freq === "daily") {
const unitDays = item.freq === "weekly" ? 7 : 1; // step in days: 7 per "week", 1 per "day"
const stepMs = Math.max(parseInt(item.every, 10) || 1, 1) * unitDays * 86400000;
let anchorMs = Date.parse(item.anchor);
if (isNaN(anchorMs)) anchorMs = Date.parse(`${ym}-01`);
const untilMs = Date.parse(item.until); // optional end date (inclusive); NaN = runs forever
const out = [];
for (let d = 1; d <= dim; d++) {
const cur = Date.parse(`${ym}-${pad2(d)}`);
if (!isNaN(untilMs) && cur > untilMs) break; // stop once past the end date
const diff = cur - anchorMs;
if (diff >= 0 && diff % stepMs === 0) out.push(mk(d));
}
return out;
}
return [];
}
// Order rows chronologically; within a single day, money-IN rows list before money-OUT
// (and blank) rows. Array.sort is stable, so same-day/same-direction rows keep their order.
function cmpRows(a, b) {
const byDate = (a.date || "9999-99-99").localeCompare(b.date || "9999-99-99");
if (byDate) return byDate;
return (num(a.inc) > 0 ? 0 : 1) - (num(b.inc) > 0 ? 0 : 1);
}
// Group recurring items so income (In) lists above expenses (Out). Stable, so the user's
// manual order within each group is preserved. Groups stay contiguous, which the up/down
// move buttons rely on to keep reordering inside a single direction group.
function sortRecurring(b) {
if (Array.isArray(b.recurring)) b.recurring.sort((x, y) => (x.dir === "in" ? 0 : 1) - (y.dir === "in" ? 0 : 1));
}
// Append every recurring occurrence into a month, skipping duplicates, then sort by date.
function fillRecurring(m) {
if (!m.ym) return 0;
const seen = new Set(m.rows.map(r => r.desc + "|" + r.date));
let added = 0;
state.recurring.forEach(item => {
if (!item.desc && !item.amount) return; // skip blank templates
if (item.enabled === false) return; // skip paused items
recurOccurrences(item, m.ym).forEach(r => {
const key = r.desc + "|" + r.date;
if (!seen.has(key)) { seen.add(key); m.rows.push(r); added++; }
});
});
m.rows.sort(cmpRows);
return added;
}
// Push an edit to a recurring item out to every row it has already placed in the months.
// regenDates = true when the schedule changed (dates/counts differ → rebuild that item's rows).
function syncRecurringItem(it, regenDates) {
state.months.forEach(m => {
if (!m.rows.some(r => r.recId === it.id)) return; // item isn't applied to this month — leave it
if (regenDates) {
m.rows = m.rows.filter(r => r.recId !== it.id);
recurOccurrences(it, m.ym).forEach(r => m.rows.push(r));
m.rows.sort(cmpRows);
} else {
m.rows.forEach(r => {
if (r.recId !== it.id) return;
r.desc = it.desc;
r.tag = it.tag || "";
r.icon = it.icon || "";
if (it.dir === "in") { r.inc = it.amount; r.out = ""; }
else { r.out = it.amount; r.inc = ""; }
});
}
});
}
// Drop a single recurring item into every calendar month that doesn't already have it.
function applyItemToAllMonths(it) {
if (it.enabled === false) return;
state.months.forEach(m => {
if (!m.ym) return;
const seen = new Set(m.rows.map(r => r.desc + "|" + r.date));
recurOccurrences(it, m.ym).forEach(r => {
const key = r.desc + "|" + r.date;
if (!seen.has(key)) { seen.add(key); m.rows.push(r); }
});
m.rows.sort(cmpRows);
});
}
// Pull a recurring item's rows out of every month (used when pausing it).
function removeItemFromAllMonths(it) {
state.months.forEach(m => { m.rows = m.rows.filter(r => r.recId !== it.id); });
}
// Order a month's rows chronologically (undated rows sink to the bottom).
function sortMonthRows(m) {
m.rows.sort(cmpRows);
}
// Append one new month, calendar-advanced from the last month, pre-filled with recurring.
function addOneMonth() {
const last = state.months[state.months.length - 1];
const m = newMonth(last && last.ym ? addMonthYm(last.ym) : currentYm());
m.rows = [];
fillRecurring(m);
if (!m.rows.length) m.rows.push(newRow());
state.months.push(m);
return m;
}
// Backfill fields that older saved data may not have, for one budget.
function normalizeBudget(b) {
if (!b) return;
b.id = b.id || uid();
b.name = b.name || "Budget";
b.currency = b.currency || "£";
if (!Array.isArray(b.accounts) || !b.accounts.length) b.accounts = [{ id: uid(), name: "", balance: "", bank: "" }];
b.accounts.forEach(a => { a.bank = a.bank || ""; });
if (!Array.isArray(b.recurring)) b.recurring = [];
if (!Array.isArray(b.months) || !b.months.length) b.months = [newMonth(currentYm())];
b.recurring.forEach(it => {
it.dir = it.dir || "out"; it.tag = it.tag || ""; it.freq = it.freq || "monthly";
it.day = it.day || "1"; it.every = it.every || "2"; it.anchor = it.anchor || ""; it.until = it.until || "";
it.ymonth = it.ymonth || "1"; it.yday = it.yday || "1";
it.amount = it.amount || ""; it.desc = it.desc || ""; it.enabled = it.enabled !== false;
if (it.icon === undefined) applyCat(it, iconFor(it.desc), false); // auto-suggest once for pre-category data
});
sortRecurring(b); // income grouped above expenses (stable — manual order within a group survives)
b.months.forEach(m => { m.ym = m.ym || ""; (m.rows || []).forEach(r => {
r.tag = r.tag || "";
if (r.icon === undefined) applyCat(r, iconFor(r.desc), false);
}); if (Array.isArray(m.rows)) m.rows.sort(cmpRows); });
// Link rows generated before recId existed, so editing a recurring item propagates to them.
b.months.forEach(m => (m.rows || []).forEach(r => {
if (r.recId) return;
const match = b.recurring.find(it => it.enabled !== false && it.desc && it.desc === r.desc && (
it.dir === "in" ? (num(it.amount) === num(r.inc) && num(r.out) === 0)
: (num(it.amount) === num(r.out) && num(r.inc) === 0)));
if (match) r.recId = match.id;
}));
}
function normalizeState() { normalizeBudget(state); } // the active budget
function normalizeBook() { if (book) book.budgets.forEach(normalizeBudget); }
/* ---------- crypto ----------
Web Crypto when the page is a secure context (HTTPS, localhost). On plain
http to a LAN or VPN address browsers hide crypto.subtle, so the same
PBKDF2-SHA256 + AES-256-GCM runs from crypto-fallback.js (vendored
@noble/hashes + @noble/ciphers) instead. Both write identical blobs.
A key is either a CryptoKey or { fallback: true, bytes } from the fallback. */
const HAS_SUBTLE = !!(window.isSecureContext && window.crypto && crypto.subtle);
let fallbackCrypto = null;
function loadFallbackCrypto() {
if (!fallbackCrypto) fallbackCrypto = import("./crypto-fallback.js");
return fallbackCrypto;
}
function wipeKey(key) { if (key && key.fallback) key.bytes.fill(0); }
async function deriveKey(passphrase, salt) {
if (!HAS_SUBTLE) {
const fb = await loadFallbackCrypto();
return { fallback: true, bytes: await fb.deriveKeyBytes(enc.encode(passphrase), salt, PBKDF2_ITERATIONS) };
}
const km = await crypto.subtle.importKey("raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
km,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
// Encrypt any object into a self-contained blob { v, salt, iv, ct }.
async function encryptObj(obj, key, salt) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const pt = enc.encode(JSON.stringify(obj));
const ct = key.fallback
? (await loadFallbackCrypto()).encrypt(key.bytes, iv, pt)
: await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, pt);
return { v: 1, salt: b64encode(salt), iv: b64encode(iv), ct: b64encode(ct) };
}
function encryptBook() { return encryptObj(book, cryptoKey, currentSalt); }
async function decryptBlob(stored, key) {
const iv = b64decode(stored.iv);
const ct = b64decode(stored.ct);
const pt = key.fallback
? (await loadFallbackCrypto()).decrypt(key.bytes, iv, ct)
: await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
return JSON.parse(dec.decode(pt));
}
/* ---------- linked save file (File System Access API) ----------
A tiny IndexedDB store keeps the FileSystemFileHandle between sessions so we
can offer one-click "Reconnect". localStorage stays the in-browser cache and
the unlock source; the file is the durable, portable copy. */
const FS_DB = "money-ledger-fs", FS_STORE = "handles", FS_KEY = "saveFile";
function fsIdb() {
return new Promise((res, rej) => {
const r = indexedDB.open(FS_DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore(FS_STORE);
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
async function fsGet() {
try {
const db = await fsIdb();
return await new Promise((res, rej) => {
const rq = db.transaction(FS_STORE, "readonly").objectStore(FS_STORE).get(FS_KEY);
rq.onsuccess = () => res(rq.result || null); rq.onerror = () => rej(rq.error);
});
} catch (e) { return null; }
}
async function fsPut(handle) {
try {
const db = await fsIdb();
await new Promise((res, rej) => {
const tx = db.transaction(FS_STORE, "readwrite");
tx.objectStore(FS_STORE).put(handle, FS_KEY);
tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error);
});
} catch (e) { /* ignore — feature is best-effort */ }
}
// Confirm we hold (or can get) read/write permission. requestPermission needs a user gesture.
async function fsPermission(handle) {
const opts = { mode: "readwrite" };
if ((await handle.queryPermission(opts)) === "granted") return true;
return (await handle.requestPermission(opts)) === "granted";
}
async function fsWrite(text) {
if (!fileHandle) return;
const w = await fileHandle.createWritable();
await w.write(text);
await w.close();
}
/* ---------- persistence ---------- */
function loadStored() {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
}
async function save() {
if (!cryptoKey || !book) return;
const text = JSON.stringify(await encryptBook());
try { localStorage.setItem(STORAGE_KEY, text); } catch (e) { /* quota — disk copies still tried below */ }
if (serverMode) {
// Persist the ciphertext to the server's on-disk file (survives clearing the browser).
try { await fetch("/api/data", { method: "PUT", headers: { "Content-Type": "application/json" }, body: text }); }
catch (e) { updateFileStatus("error"); } // server down — localStorage still holds it
}
if (fileHandle) {
try { await fsWrite(text); updateFileStatus(); }
catch (e) { fileHandle = null; updateFileStatus(); } // permission lapsed → fall back, nudge a reconnect
}
}
// On boot: if served by server.py, pull the on-disk blob into the localStorage cache (server is the source of truth).
async function bootLoadFromServer() {
try {
const r = await fetch("/api/data", { cache: "no-store" });
if (r.status === 200) {
const text = await r.text();
JSON.parse(text); // sanity-check it parses
localStorage.setItem(STORAGE_KEY, text); // refresh the cache from disk
serverMode = true;
} else if (r.status === 204) {
serverMode = true; // server present, nothing stored yet
}
} catch (e) {
serverMode = false; // opened statically / via file:// → localStorage only
}
}
function scheduleSave() { clearTimeout(saveTimer); saveTimer = setTimeout(save, 400); }
// Mirroring a recurring edit into the months is a full renderMonths() (rebuild every
// month's DOM) + recompute() (a querySelector per row). Doing that on every keystroke is
// what makes typing a recurring amount/description laggy on a big ledger. The state is
// already updated synchronously in onInput, so we just coalesce the repaint to the trailing
// edge — the months catch up a beat after typing settles, and typing itself stays smooth.
let recurPaintTimer = null;
function scheduleRecurRepaint() {
clearTimeout(recurPaintTimer);
recurPaintTimer = setTimeout(() => { recurPaintTimer = null; renderMonths(); recompute(); }, 90);
}
/* ---------- linked-file actions + status UI ---------- */
const FS_TYPES = [{ description: "LibreLedger (encrypted)", accept: { "application/json": [".mlg", ".json"] } }];
// Reflect file-link state on the toolbar button: active / needs-reconnect / not-linked.
function updateFileStatus(stateHint) {
if (!linkBtn) return;
if (serverMode) { // persistence is handled on the server; show it as a status pill
linkBtn.hidden = false;
linkBtn.classList.remove("needs");
linkBtn.classList.toggle("linked", stateHint !== "error");
linkBtn.classList.toggle("needs", stateHint === "error");
linkBtn.textContent = stateHint === "error" ? "⚠️ Save server offline" : "💾 Saved to disk";
linkBtn.title = stateHint === "error"
? "Couldn't reach the save server — your edits are still cached in this browser. Is the LibreLedger server running?"
: "Auto-saving your encrypted ledger to the server (ledger.enc in its data folder). Survives clearing your browser.";
return;
}
if (!FS_SUPPORTED) { linkBtn.hidden = true; return; }
linkBtn.hidden = false;
linkBtn.classList.remove("linked", "needs");
if (fileHandle) {
linkBtn.textContent = "🔗 Saving to file";
linkBtn.title = `Auto-saving your encrypted ledger to "${fileHandle.name}". Click to switch files.`;
linkBtn.classList.add("linked");
} else if (rememberedName) {
linkBtn.textContent = "🔗 Reconnect file";
linkBtn.title = `File-saving is paused. Click to reconnect "${rememberedName}" and resume auto-saving.`;
linkBtn.classList.add("needs");
} else {
linkBtn.textContent = "🔗 Link file";
linkBtn.title = "Auto-save your encrypted ledger to a file you choose (durable & portable).";
}
}
// Pick a file and start auto-saving the current data to it.
async function linkSaveFile() {
if (!FS_SUPPORTED) { alert("Linking a file needs Chrome or Edge on desktop. Elsewhere, use Backup / Restore."); return; }
try {
const handle = await window.showSaveFilePicker({ suggestedName: "libreledger.mlg", types: FS_TYPES });
if (!(await fsPermission(handle))) return;
fileHandle = handle;
rememberedName = handle.name;
await fsPut(handle);
await save(); // write current ledger into the file right away
updateFileStatus();
alert(`Linked. Your ledger now auto-saves to "${handle.name}".`);
} catch (e) { if (e && e.name !== "AbortError") alert("Could not link that file."); }
}
// Already unlocked, but file-saving was paused (new session) — re-grant and resume, pushing current data out.
async function resumeFileSave() {
const handle = await fsGet();
if (!handle) { return linkSaveFile(); }
try {
if (!(await fsPermission(handle))) { alert("Permission for the file was denied."); return; }
fileHandle = handle;
rememberedName = handle.name;
await save();
updateFileStatus();
} catch (e) { alert("Could not reconnect the file."); }
}
// From the LOCK screen: read a ledger file into the unlock buffer, then unlock it with its passphrase.
async function loadFileIntoUnlock(handle) {
const text = await (await handle.getFile()).text();
const obj = JSON.parse(text);
if (!obj.salt || !obj.iv || !obj.ct) throw new Error("not a ledger file");
localStorage.setItem(STORAGE_KEY, text); // stage it so the unlock flow decrypts the file's contents
fileHandle = handle;
rememberedName = handle.name;
await fsPut(handle);
showLock("unlock");
lockMsg.textContent = `Unlock "${handle.name}" with its passphrase`;
pass1.focus();
}
async function reconnectFromLock() {
const handle = await fsGet();
if (!handle) return;
try {
if (!(await fsPermission(handle))) { alert("Permission for the file was denied."); return; }
await loadFileIntoUnlock(handle);
} catch (e) { alert("That linked file looks corrupt or unreadable."); }
}
async function openFromFile() {
if (!FS_SUPPORTED) { alert("Opening a file needs Chrome or Edge on desktop. Elsewhere, use Restore."); return; }
try {
const [handle] = await window.showOpenFilePicker({ types: FS_TYPES, multiple: false });
if (!(await fsPermission(handle))) return;
await loadFileIntoUnlock(handle);
} catch (e) { if (e && e.name !== "AbortError") alert("That file isn't a valid ledger."); }
}
// Lock-screen buttons: Reconnect (if a file is remembered) and Open-a-file.
async function renderLockFs() {
if (!lockFs) return;
if (!FS_SUPPORTED) { lockFs.innerHTML = ""; return; }
const remembered = await fsGet();
rememberedName = remembered ? remembered.name : null;
const parts = [];
if (remembered) parts.push(`<button type="button" class="lock-fs-btn primary-soft" id="lock-reconnect">🔗 Reconnect “${esc(remembered.name)}”</button>`);
parts.push(`<button type="button" class="lock-fs-btn" id="lock-open">📂 Open a ledger file…</button>`);
lockFs.innerHTML = parts.join("");
const rc = document.getElementById("lock-reconnect"); if (rc) rc.onclick = reconnectFromLock;
document.getElementById("lock-open").onclick = openFromFile;
}
if (linkBtn) linkBtn.addEventListener("click", () => {
if (serverMode) { alert("Your ledger auto-saves (encrypted) to the server, as ledger.enc in its data folder, with dated backups beside it.\n\nIt lives on disk, so clearing your browser cache does not affect it. Back that folder up to keep a copy."); return; }
return fileHandle ? linkSaveFile() : resumeFileSave();
});
/* ---------- rendering ---------- */
/* ---------- section nav: tabs (focus one) + per-section show/hide in the "All" view ---------- */
const sectionNav = document.getElementById("section-nav");
const SECTIONS = [
{ key: "totals", icon: "📊", label: "Totals" },
{ key: "savings", icon: "🐷", label: "Savings" },
{ key: "affordability", icon: "🏡", label: "Affordability" },
{ key: "balances", icon: "💼", label: "Balances" },
{ key: "recurring", icon: "🔁", label: "Recurring" },
{ key: "ledger", icon: "🌊", label: "Ledger" },
];
let activeTab = "all"; // "all" → stacked view; otherwise a single section key
let hiddenSections = new Set(); // sections hidden within the "All" view
try { activeTab = localStorage.getItem("money-ledger-tab") || "all"; } catch (e) {}
try { hiddenSections = new Set(JSON.parse(localStorage.getItem("money-ledger-hidden") || "[]")); } catch (e) {}
function saveSectionPrefs() {
try {
localStorage.setItem("money-ledger-tab", activeTab);
localStorage.setItem("money-ledger-hidden", JSON.stringify([...hiddenSections]));
} catch (e) {}
}
function renderSectionNav() {
if (!sectionNav) return;
sectionNav.innerHTML =
`<div class="sec-tabs">` +
`<button type="button" class="sec-tab sec-tab-all" data-action="sec-tab" data-key="all">▦ <span>All</span></button>` +
SECTIONS.map(s => `<button type="button" class="sec-tab" data-action="sec-tab" data-key="${s.key}">${s.icon} <span>${s.label}</span></button>`).join("") +
`</div>` +
`<div class="sec-hidden" data-sec-hidden hidden></div>`;
// Each section gets a ✕ in its header (shown only in the All view) to hide it.
SECTIONS.forEach(s => {
const head = document.querySelector(`.panel[data-section="${s.key}"] .panel-head`);
if (head && !head.querySelector(".panel-hide")) {
const b = document.createElement("button");
b.className = "panel-hide";
b.dataset.action = "sec-hide";
b.dataset.key = s.key;
b.title = `Hide the ${s.label} section`;
b.setAttribute("aria-label", `Hide ${s.label}`);
b.textContent = "✕";
head.appendChild(b);
}
});
applySectionView();
}
function applySectionView() {
const all = activeTab === "all";
SECTIONS.forEach(s => {
const panel = document.querySelector(`.panel[data-section="${s.key}"]`);
if (panel) panel.classList.toggle("section-hidden", all ? hiddenSections.has(s.key) : activeTab !== s.key);
});
if (app) app.classList.toggle("view-all", all); // gates the per-panel ✕ hide buttons
if (!sectionNav) return;
sectionNav.querySelectorAll("[data-action='sec-tab']").forEach(b => b.classList.toggle("active", b.dataset.key === activeTab));
// "Hidden" restore strip — only in the All view, and only when something is hidden.
const strip = sectionNav.querySelector("[data-sec-hidden]");
if (strip) {
const hidden = SECTIONS.filter(s => hiddenSections.has(s.key));
strip.hidden = !(all && hidden.length);
if (all && hidden.length) {
strip.innerHTML = `<span class="sec-hidden-lab">Hidden</span>` +
hidden.map(s => `<button type="button" class="sec-restore" data-action="sec-show" data-key="${s.key}" title="Show ${s.label} again">${s.icon} <span>${s.label}</span> ✕</button>`).join("");
}
}
}
function render() {
normalizeState();
renderBudgets();
renderSectionNav();
currencyEl.value = state.currency || "";
renderAccounts();
renderRecurring();
renderMonths();
renderSavings();
renderAffordability();
renderTotals();
recompute();
}
// The budget "tabs" strip: one tab per budget + actions on the active one.
function renderBudgets() {
if (!book) return;
const tabs = book.budgets.map(b => {
const active = b.id === book.activeId;
const name = esc(b.name) || "Untitled";
return `<button class="budget-tab${active ? " active" : ""}" data-action="switch-budget" data-id="${b.id}" title="${name}">${name}</button>`;
}).join("");
const multi = book.budgets.length > 1;
budgetBar.innerHTML = `
<div class="budget-tabs">${tabs}</div>
<div class="budget-actions">
<button class="bz" data-action="rename-budget" title="Rename the current budget">✎ Rename</button>
<button class="bz" data-action="dup-budget" title="Duplicate the current budget into a new tab">⧉ Duplicate</button>
${multi ? `<button class="bz danger" data-action="del-budget" title="Delete the current budget">🗑 Delete</button>` : ""}
<button class="bz add" data-action="new-budget" title="Start a new, empty budget">+ New budget</button>
</div>`;
}
// Short human summary of a recurring item's schedule — shown on the chip; full editing is in the popover.
const MON_SHORT = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function shortDate(iso) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso || "")) return "";
return `${parseInt(iso.slice(8, 10), 10)} ${MON_SHORT[parseInt(iso.slice(5, 7), 10)] || ""}`;
}
// Trailing "· from X", "· until Y" or "· X→Y" for an item's optional date window.
function boundText(it) {
const from = shortDate(it.anchor), to = shortDate(it.until);
if (from && to) return ` · ${from}${to}`;
if (to) return ` · until ${to}`;
if (from) return ` · from ${from}`;
return "";
}
function whenSummary(it) {
if (it.freq === "daily") {
return (+it.every === 1 ? "Daily" : `Every ${it.every || 1} days`) + boundText(it);
}
if (it.freq === "weekly") {
return `Every ${it.every || 1} wk${+it.every === 1 ? "" : "s"}${boundText(it)}`;
}
if (it.freq === "yearly") {
const m = parseInt(it.ymonth, 10) || 1, d = parseInt(it.yday, 10) || 1;
return `Yearly · ${d} ${MON_SHORT[m] || ""}${boundText(it)}`;
}
const d = parseInt(it.day, 10) || 1;
return `Monthly · ${d}${ordSuffix(d)}${boundText(it)}`;
}
function renderRecurring() {
const sel = (v, opts) => opts.map(([val, label]) =>
`<option value="${val}" ${v === val ? "selected" : ""}>${label}</option>`).join("");
const arr = state.recurring;
const rows = arr.map((it, idx) => {
const on = it.enabled !== false;
// Reordering is confined to an item's own direction group (In stays above Out), so the
// arrows disable at each group's edge, not just the whole list's edge.
const upOff = idx === 0 || arr[idx - 1].dir !== it.dir;
const downOff = idx === arr.length - 1 || arr[idx + 1].dir !== it.dir;
return `
<tr class="${on ? "" : "rec-off"}" data-recid="${it.id}">
<td class="c-drag"><span class="drag-handle" title="Drag to reorder">&#x2630;</span></td>
<td class="c-tag"><button class="tagdot${it.icon ? " has-icon" : ""}" data-action="open-tags" data-scope="recurring" data-id="${it.id}" data-has="${it.tag ? 1 : 0}" style="--dot:${tagCss(it.tag)}" title="Category — click to set">${catDotInner(it.icon)}</button></td>
<td class="c-desc"><input data-scope="recurring" data-field="desc" data-id="${it.id}" value="${esc(it.desc)}" placeholder="e.g. Rent, Phone bill, Salary"></td>
<td class="c-pick"><select data-scope="recurring" data-field="dir" data-id="${it.id}">${sel(it.dir, [["out", "💸 Out"], ["in", "💰 In"]])}</select></td>
<td class="num c-amt">${withStepper(`<input type="number" step="0.01" class="amt ${it.dir === "in" ? "in" : "out"}" data-scope="recurring" data-field="amount" data-id="${it.id}" value="${esc(it.amount)}" placeholder="0.00">`)}</td>
<td class="c-when"><button class="when-chip" data-action="edit-sched" data-id="${it.id}" title="Click to set how often this repeats">🗓 ${esc(whenSummary(it))}</button></td>
<td class="c-switch"><button class="switch ${on ? "on" : "off"}" data-action="toggle-recurring" data-id="${it.id}" role="switch" aria-checked="${on}" title="${on ? "Pause — removes it from all months" : "Resume — adds it back to all months"}"><span class="knob"></span></button></td>
<td class="actions c-move">
<button class="icon move"${upOff ? " disabled" : ` data-action="move-recurring" data-dir="up" data-id="${it.id}"`} title="Move up">&uarr;</button>
<button class="icon move"${downOff ? " disabled" : ` data-action="move-recurring" data-dir="down" data-id="${it.id}"`} title="Move down">&darr;</button>
<button class="icon" data-action="del-recurring" data-id="${it.id}" title="Remove">&times;</button>
</td>
</tr>`;
}).join("");
const empty = `<tr><td colspan="8" class="empty">No recurring items yet — add the bills &amp; income that repeat, and they'll flow into your months. 🔁</td></tr>`;
recurringBody.innerHTML = `
<div class="card">
<div class="hscroll">
<table class="grid">
<thead><tr>
<th class="c-drag"></th>
<th class="c-tag"></th>
<th>Description</th>
<th>In / Out</th>
<th class="num">Amount</th>
<th>Schedule</th>
<th class="c-switch"></th>
<th class="actions c-move"></th>
</tr></thead>
<tbody>${rows || empty}</tbody>
</table>
</div>
<div class="rec-foot">
<button class="add" data-action="add-recurring">+ Add recurring</button>
${state.recurring.length ? `<button class="add" data-action="fill-all-months" title="Insert these into every month that has a calendar month (📅) set">🔁 Fill all months</button>` : ""}
</div>
</div>`;
}
// Wrap a number <input> with custom rounded ▲/▼ stepper buttons.
function withStepper(inputHTML) {
return `<span class="stepper">${inputHTML}<span class="spin">` +
`<button class="step" data-action="step" data-dir="up" tabindex="-1" aria-label="Increase">▲</button>` +
`<button class="step" data-action="step" data-dir="down" tabindex="-1" aria-label="Decrease">▼</button>` +
`</span></span>`;
}
function renderAccounts() {
const rows = state.accounts.map(a => `
<tr>
<td class="acct-name"><div class="acct-row"><button class="bankpick" data-action="open-bank" data-id="${a.id}" title="Bank — click to choose">${bankGlyph(a.bank)}</button><input data-scope="account" data-field="name" data-id="${a.id}" value="${esc(a.name)}" placeholder="Account name"></div></td>
<td class="num">${withStepper(`<input type="number" step="0.01" class="amt" data-scope="account" data-field="balance" data-id="${a.id}" value="${esc(a.balance)}" placeholder="0.00">`)}</td>
<td class="actions"><button class="icon" data-action="del-account" data-id="${a.id}" title="Remove account">&times;</button></td>
</tr>`).join("");
accountsBody.innerHTML = `
<div class="card">
<table class="grid">
<thead><tr><th>Account</th><th class="num">Balance</th><th class="actions"></th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><th>Total balance</th><th class="num"><span class="grand-total" data-acc-total></span></th><th class="actions"></th></tr></tfoot>
</table>
<button class="add" data-action="add-account">+ Add account</button>
</div>`;
}
// Date cell: a compact day-of-month ("9th") when the month has a calendar month set
// (the month/year already live in the block header), or the full date picker otherwise.
function dateCellHTML(m, r, locked) {
const hasYm = /^\d{4}-\d{2}$/.test(m.ym || "");
if (!hasYm) {
return `<input type="date" value="${esc(r.date)}"${locked ? ' readonly tabindex="-1"' : ` data-scope="row" data-field="date" data-month="${m.id}" data-id="${r.id}"`}>`;
}
const day = /^\d{4}-\d{2}-\d{2}$/.test(r.date || "") ? parseInt(r.date.slice(8, 10), 10) : "";
const ord = ordSuffix(day);
if (locked) {
return `<span class="day-cell ro" title="${esc(r.date)}"><span class="day-num">${day || "—"}</span><sup class="day-ord">${ord}</sup></span>`;
}
return `<span class="day-cell"><input type="number" class="day-num" min="1" max="31" inputmode="numeric" placeholder=""` +
` data-scope="row" data-field="day" data-month="${m.id}" data-id="${r.id}" value="${day}">` +
`<sup class="day-ord" data-dayord="${r.id}">${ord}</sup></span>`;
}
function monthHTML(m, idx) {
const recurIds = new Set(state.recurring.map(it => it.id));
const rows = m.rows.map(r => {
// a row generated from a (still-existing) recurring item is locked here — edit it in Recurring
const locked = !!(r.recId && recurIds.has(r.recId));
const amtCell = (cls, field, val) => {
const input = locked
? `<input type="number" class="amt ${cls}" value="${esc(val)}" placeholder="0.00" readonly tabindex="-1">`
: `<input type="number" step="0.01" class="amt ${cls}" data-scope="row" data-field="${field}" data-month="${m.id}" data-id="${r.id}" value="${esc(val)}" placeholder="0.00">`;
return `<td class="num">${withStepper(input)}</td>`;
};
return `
<tr class="led-row${locked ? " locked" : ""}" data-row="${r.id}">
<td class="actions">${locked
? `<span class="lock-mark" title="Recurring — edit it in the Recurring section above">🔒</span>`
: `<button class="icon" data-action="insert-row" data-month="${m.id}" data-id="${r.id}" title="Insert row below">+</button>`}</td>
<td class="c-tag"><button class="tagdot${r.icon ? " has-icon" : ""}"${locked
? ` data-action="open-tags" data-scope="recurring" data-id="${r.recId}" title="Recurring category — opens the recurring item"`
: ` data-action="open-tags" data-scope="row" data-month="${m.id}" data-id="${r.id}" title="Category — click to set"`} data-has="${r.tag ? 1 : 0}" style="--dot:${tagCss(r.tag)}">${catDotInner(r.icon)}</button></td>
<td class="c-day">${dateCellHTML(m, r, locked)}</td>
<td><input value="${esc(r.desc)}" placeholder="Description"${locked ? ' readonly tabindex="-1"' : ` data-scope="row" data-field="desc" data-month="${m.id}" data-id="${r.id}"`}></td>
${amtCell("in", "inc", r.inc)}
${amtCell("out", "out", r.out)}
<td class="num bal" data-balance="${r.id}"></td>
<td class="actions"><button class="icon" data-action="del-row" data-month="${m.id}" data-id="${r.id}" title="${locked ? "Remove this recurring entry from this month" : "Remove row"}">&times;</button></td>
</tr>`;
}).join("");
return `
<section class="month${isPastYm(m.ym) ? " past" : ""}">
${monthHeadHTML(m, idx)}
<table class="grid">
<thead><tr>
<th class="actions"></th>
<th class="c-tag"></th>
<th>Date</th>
<th>Description</th>
<th class="num">In</th>
<th class="num">Out</th>
<th class="num">Balance</th>
<th class="actions"></th>
</tr></thead>
<tbody>${rows}</tbody>
<tfoot>
<tr class="net-row">
<th colspan="4" class="tot-head">Month totals &middot; net <span class="net-chip" data-mnet="${m.id}">&mdash;</span> &middot; 🐷 saved <span class="sav-chip" data-msav="${m.id}">&mdash;</span></th>
<th class="num tot"><span class="tot-lab">Money in</span><span class="tot-val" data-min="${m.id}"></span></th>
<th class="num tot"><span class="tot-lab">Money out</span><span class="tot-val" data-mout="${m.id}"></span></th>
<th class="num tot tot-end"><span class="tot-lab">Closing balance</span><span class="tot-val" data-mend="${m.id}"></span></th>
<th class="actions"></th>
</tr>
</tfoot>
</table>
<div class="month-actions">
<button class="add" data-action="add-row" data-month="${m.id}">+ Add row</button>
<button class="add" data-action="fill-recurring" data-id="${m.id}" title="Insert your recurring payments into this month">🔁 Add recurring</button>
</div>
</section>`;
}
// Shared month header (title, calendar month, opening, delete) + the per-category summary chips strip.
function monthHeadHTML(m, idx) {
const openingLabel = idx === 0 ? "Opening &middot; from balances" : "Opening &middot; carried";
const openingTitle = idx === 0
? "Sum of your account balances above — the ledger opens from here"
: "Carried from the previous month's ending balance";
return `
<div class="month-head">
<input class="month-title" data-scope="month" data-field="title" data-id="${m.id}" value="${esc(m.title)}" placeholder="Month (e.g. June 2026)">
<input type="month" class="month-ym" data-scope="month" data-field="ym" data-id="${m.id}" value="${esc(m.ym || "")}" title="Calendar month — sets the dates used when adding recurring payments">
${isPastYm(m.ym) ? `<span class="hist-badge" title="Historic month — it's in the past, so it's shown dimmed. Hover or click into it to view and edit.">🕓 Past</span>` : ""}
<label class="opening">${openingLabel}
<span class="opening-carry" data-opening="${m.id}" title="${openingTitle}"></span>
</label>
<button class="bz viewtoggle${grouped ? " on" : ""}" data-action="toggle-grouped" title="${grouped ? "Show the full editable ledger" : "Group each month by category — a read-only overview"}">${grouped ? "📋 Chronological" : "🗂 Group by category"}</button>
<button class="icon del-month" data-action="del-month" data-id="${m.id}" title="Remove this month">Delete month</button>
</div>
<div class="month-chips" data-chips="${m.id}"></div>`;
}
// Group a month's rows by icon → [{icon,label,inSum,outSum,rows}], biggest swing first.
function groupRows(m) {
const map = new Map();
m.rows.forEach(r => {
const icon = r.icon || "";
let g = map.get(icon);
if (!g) { g = { icon, label: catLabel(icon), inSum: 0, outSum: 0, rows: [] }; map.set(icon, g); }
g.inSum += num(r.inc); g.outSum += num(r.out); g.rows.push(r);
});
return [...map.values()].sort((a, b) => Math.abs(b.inSum - b.outSum) - Math.abs(a.inSum - a.outSum));
}
// Per-category summary chips for a month (live-rendered from recompute).
// Each chip is a toggle: click to filter the month's rows to the categories you
// pick; the leading "All" chip clears the filter (and is highlighted when no
// filter is active). Selection lives in catFilter, keyed by month id.
function monthChipsHTML(m) {
const groups = groupRows(m).filter(g => g.inSum || g.outSum);
if (!groups.length) return "";
const sel = catFilter.get(m.id);
const noneState = !!(sel && sel.has(CAT_NONE));
const allActive = !sel || sel.size === 0;
let totIn = 0, totOut = 0;
groups.forEach(g => { totIn += g.inSum; totOut += g.outSum; });
const net = totIn - totOut;
const allChip =
`<button type="button" class="mchip mchip-all${allActive ? " active" : ""}${noneState ? " off" : ""}" ` +
`data-action="filter-cat" data-month="${m.id}" data-icon="${CAT_ALL}" ` +
`title="${allActive ? "Hide every category" : "Show every category"}">` +
`${noneState ? "▢" : "▦"} <span class="mchip-nm">All</span> ` +
`<span class="mchip-amt ${net >= 0 ? "in" : "out"}">${net >= 0 ? "+" : ""}${fmt(Math.abs(net))}</span></button>`;
const catChips = groups.map(g => {
const income = g.inSum >= g.outSum;
const icon = g.icon || "";
const active = !!(sel && sel.has(icon));
return `<button type="button" class="mchip ${income ? "in" : "out"}${active ? " active" : ""}" ` +
`data-action="filter-cat" data-month="${m.id}" data-icon="${esc(icon)}" title="Filter to ${esc(g.label)}">` +
`${icon || "▫"} <span class="mchip-nm">${esc(g.label)}</span> ` +
`<span class="mchip-amt">${fmt(income ? g.inSum : g.outSum)}</span></button>`;
}).join("");
return allChip + catChips;
}
// Per-month category filter: month id → Set of selected icons (empty/absent = show all).
const catFilter = new Map();
const CAT_ALL = "__all__"; // sentinel data-icon for the "All" chip (toggles show-all / show-none)
const CAT_NONE = "__none__"; // sentinel held inside the filter Set to mean "hide every category"
// Show/hide a month's rows (chronological) and groups (grouped view) per its filter.
function applyMonthFilter(m) {
const sel = catFilter.get(m.id);
const filtering = !!(sel && sel.size > 0);
const chipEl = document.querySelector(`[data-chips="${m.id}"]`);
if (chipEl) chipEl.classList.toggle("filtering", filtering);
m.rows.forEach(r => {
const tr = document.querySelector(`tr.led-row[data-row="${r.id}"]`);
if (tr) tr.classList.toggle("filtered-out", filtering && !sel.has(r.icon || ""));
});
document.querySelectorAll(`.grp[data-grp-month="${m.id}"]`).forEach(g => {
g.classList.toggle("filtered-out", filtering && !sel.has(g.dataset.grpIcon || ""));
});
}
function groupedDayLabel(r) {
if (/^\d{4}-\d{2}-\d{2}$/.test(r.date || "")) { const d = parseInt(r.date.slice(8, 10), 10); return `${d}${ordSuffix(d)}`; }
return "";
}
// Read-only "grouped by category" view of a month (collapsible groups + subtotals; edit in the flat view).
function monthGroupedHTML(m, idx) {
const body = groupRows(m).map(g => {
const key = m.id + "|" + (g.icon || "_none");
const open = !collapsedGroups.has(key);
const sub = g.inSum - g.outSum;
const entries = g.rows.map(r => {
const amt = num(r.inc) || num(r.out);
return `<div class="grp-row">
<span class="grp-day">${groupedDayLabel(r)}</span>
<span class="grp-desc">${esc(r.desc) || "—"}</span>
<span class="grp-amt ${num(r.inc) ? "in" : "out"}">${num(r.inc) ? "+" : ""}${fmt(amt)}</span>
</div>`;
}).join("");
return `<div class="grp${open ? " open" : ""}" data-grp-month="${m.id}" data-grp-icon="${esc(g.icon || "")}">
<button class="grp-head" data-action="toggle-group" data-key="${esc(key)}">
<span class="grp-caret">${open ? "▾" : "▸"}</span>
<span class="grp-ico">${g.icon || "▫"}</span>
<span class="grp-label">${esc(g.label)}</span>
<span class="grp-count">${g.rows.length}</span>
<span class="grp-sub ${sub >= 0 ? "in" : "out"}">${sub >= 0 ? "+" : ""}${fmt(Math.abs(sub))}</span>
</button>
${open ? `<div class="grp-body">${entries}</div>` : ""}
</div>`;
}).join("");
return `
<section class="month grouped${isPastYm(m.ym) ? " past" : ""}">
${monthHeadHTML(m, idx)}
<div class="groups">${body || `<div class="grp-empty">No entries yet — switch to Chronological to add some.</div>`}</div>
<div class="month-totals">
<span class="mt net" data-mnet="${m.id}">&mdash;</span>
<span class="mt sav"><b>🐷 Saved</b> <span data-msav="${m.id}"></span></span>
<span class="mt"><b>In</b> <span data-min="${m.id}"></span></span>
<span class="mt"><b>Out</b> <span data-mout="${m.id}"></span></span>
<span class="mt end"><b>Closing</b> <span data-mend="${m.id}"></span></span>
</div>
<div class="grouped-note">Read-only overview — switch to <strong>📋 Chronological</strong> to add or edit entries.</div>
</section>`;
}
let grouped = false; // global view mode: chronological (edit) vs grouped (overview)
const collapsedGroups = new Set(); // "<monthId>|<icon>" keys that are collapsed in grouped view
function renderMonths() {
const render = grouped ? monthGroupedHTML : monthHTML;
monthsBody.innerHTML =
state.months.map(render).join("") +
`<div class="months-foot">
<button class="add big" data-action="add-month">+ Add month</button>
<span class="bulk">or generate
<input type="number" min="1" max="60" id="bulk-count" class="when-num" value="6"> months ahead
<button class="add" data-action="add-months-bulk">Go →</button>
</span>
</div>`;
syncHeaderHeight(); // measure the strip height + re-arm the pinned-shadow observer
}
// Give each month header a shadow only while it's pinned to the top (cosmetic).
let stickyObserver = null;
function observeStickyHeads() {
if (!("IntersectionObserver" in window)) return;
if (stickyObserver) stickyObserver.disconnect();
const top = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--header-h"), 10) || 60;
stickyObserver = new IntersectionObserver(
entries => entries.forEach(e => e.target.classList.toggle("stuck", e.intersectionRatio < 1)),
{ rootMargin: `-${top + 1}px 0px 0px 0px`, threshold: [1] }
);
monthsBody.querySelectorAll(".month-head").forEach(h => stickyObserver.observe(h));
}
function recompute() {
// accounts
let accTotal = 0;
state.accounts.forEach(a => (accTotal += num(a.balance)));
setAll("[data-acc-total]", fmt(accTotal));
document.querySelectorAll("[data-acc-total]").forEach(el => el.classList.toggle("neg", accTotal < 0));
// months — the first month opens from the accounts total, then the balance
// carries continuously month to month (one running ledger).
let carry = accTotal, cumSaved = 0, totalIncome = 0;
const savings = []; // {id, label, saved, cumulative} per month, for the Savings panel
const flow = []; // {label, inc, out, net, saved} per month, for the Totals charts
state.months.forEach((m, idx) => {
const opening = carry;
const oEl = document.querySelector(`[data-opening="${m.id}"]`);
if (oEl) { oEl.textContent = fmt(opening); oEl.classList.toggle("neg", opening < 0); }
let bal = opening, tin = 0, tout = 0, saved = 0;
m.rows.forEach(r => {
const inc = num(r.inc), out = num(r.out);
tin += inc;
tout += out;
bal += inc - out;
if (r.icon === SAVINGS_ICON) saved += out - inc; // money set aside (a withdrawal back in counts negative)
const cell = document.querySelector(`[data-balance="${r.id}"]`);
if (cell) { cell.textContent = fmt(bal); cell.classList.toggle("neg", bal < 0); }
// row income/expense tint (Money = income, Cost = expense)
const isIn = inc > 0 && out === 0;
const isOut = out > 0 && inc === 0;
const tr = document.querySelector(`tr[data-row="${r.id}"]`);
if (tr) { tr.classList.toggle("is-in", isIn); tr.classList.toggle("is-out", isOut); }
});
const chipEl = document.querySelector(`[data-chips="${m.id}"]`);
if (chipEl) chipEl.innerHTML = monthChipsHTML(m);
applyMonthFilter(m); // keep row/group visibility in sync with the active filter
setOne(`[data-min="${m.id}"]`, fmt(tin));
setOne(`[data-mout="${m.id}"]`, fmt(tout));
const net = tin - tout;
const netEl = document.querySelector(`[data-mnet="${m.id}"]`);
if (netEl) {
netEl.textContent = (net > 0 ? "📈 " : net < 0 ? "📉 " : "") + fmt(net);
netEl.classList.toggle("neg", net < 0);
}
const endEl = document.querySelector(`[data-mend="${m.id}"]`);
if (endEl) { endEl.textContent = fmt(bal); endEl.classList.toggle("neg", bal < 0); }
totalIncome += tin;
cumSaved += saved;
const mLabel = monthLabel(m, idx);
savings.push({ id: m.id, label: mLabel, saved, cumulative: cumSaved });
flow.push({ label: mLabel, inc: tin, out: tout, net, saved });
document.querySelectorAll(`[data-msav="${m.id}"]`).forEach(el => {
el.textContent = fmt(saved);
el.classList.toggle("neg", saved < 0);
});
carry = bal;
});
paintSavings(savings, totalIncome);
// Affordability shares Totals' method: annualise the 💰 income and 🏠 housing straight from the
// recurring schedule (not a backward-looking average of month rows), so the two always agree.
let affIncYr = 0, affHouseYr = 0;
(state.recurring || []).forEach(it => {
if (it.enabled === false) return;
const yr = annualForItem(it);
if (!yr) return;
if (it.dir === "in" && it.icon === INCOME_ICON) affIncYr += yr;
else if (it.dir === "out" && it.icon === HOUSING_ICON) affHouseYr += yr;
});
paintAffordability(affIncYr / 12, affHouseYr / 12);
paintTotals(flow);
}
/* ---------- savings panel (summary stats + hand-drawn SVG chart, no deps) ---------- */
// Compact x-axis label for a month: "Jun '26" when a calendar month is set,
// else the (truncated) title, else a positional "M1, M2 …".
function monthLabel(m, idx) {
if (/^\d{4}-\d{2}$/.test(m.ym || "")) {
const [y, mo] = m.ym.split("-").map(Number);
return `${MON_SHORT[mo]} '${String(y).slice(2)}`;
}
const t = (m.title || "").trim();
if (t) return t.length > 9 ? t.slice(0, 8) + "…" : t;
return `M${idx + 1}`;
}
// Skeleton: stat slots + chart container. Live values are filled by paintSavings (from recompute).
function renderSavings() {
savingsBody.innerHTML = `
<div class="card sav-card">
<div class="sav-stats">
<div class="sav-stat"><span class="sav-stat-lab">Saved to date</span><span class="sav-stat-val" data-sav-total>&mdash;</span></div>
<div class="sav-stat"><span class="sav-stat-lab">Avg / month</span><span class="sav-stat-val" data-sav-avg>&mdash;</span></div>
<div class="sav-stat"><span class="sav-stat-lab">Savings rate</span><span class="sav-stat-val" data-sav-rate>&mdash;</span></div>
<div class="sav-stat"><span class="sav-stat-lab">Best month</span><span class="sav-stat-val" data-sav-best>&mdash;</span></div>
</div>
<div class="sav-legend">
<span class="sav-leg sav-leg-bar">Saved / month</span>
<span class="sav-leg sav-leg-line">Cumulative total</span>
</div>
<div class="sav-chart" data-savings-chart></div>
</div>`;
}
function setSav(sel, text, neg) {
const el = document.querySelector(sel);
if (el) { el.textContent = text; el.classList.toggle("neg", !!neg); }
}
function paintSavings(series, totalIncome) {
const total = series.length ? series[series.length - 1].cumulative : 0;
const withActivity = series.filter(s => s.saved !== 0);
const avg = withActivity.length ? total / withActivity.length : 0;
const best = series.reduce((b, s) => (s.saved > (b ? b.saved : 0) ? s : b), null);
const rate = totalIncome > 0 ? (total / totalIncome) * 100 : null;
setSav("[data-sav-total]", fmt(total), total < 0);
setSav("[data-sav-avg]", withActivity.length ? fmt(avg) : "—", avg < 0);
setSav("[data-sav-rate]", rate == null ? "—" : `${Math.round(rate)}%`, rate != null && rate < 0);
setSav("[data-sav-best]", best ? `${fmt(best.saved)} · ${best.label}` : "—", false);
const chart = document.querySelector("[data-savings-chart]");
if (chart) chart.innerHTML = savingsChartSVG(series);
}
// Monthly saved as green/red bars (zero baseline) with a cumulative-total line on
// top. Two independent vertical scales — bars read per-month, the line reads the
// running total — so neither swamps the other. Pure SVG; <title> gives hover tips.
function savingsChartSVG(series) {
if (!series.length || series.every(s => s.saved === 0 && s.cumulative === 0)) {
return `<div class="sav-empty">Tag any ledger row with <b>🐷 Savings</b> to watch your savings grow here.</div>`;
}
const n = series.length;
const slot = n <= 3 ? 118 : n <= 6 ? 92 : n <= 10 ? 66 : n <= 16 ? 50 : 38;
const padL = 12, padR = 12, padT = 18, padB = 30;
const H = 210, plotH = H - padT - padB;
const W = padL + n * slot + padR;
const cx = i => padL + slot * i + slot / 2;
// bar scale (per-month saved — may go negative on a net withdrawal)
const sv = series.map(s => s.saved);
let sMax = Math.max(0, ...sv), sMin = Math.min(0, ...sv);
if (sMax === sMin) sMax = sMin + 1;
const yBar = v => padT + (sMax - v) / (sMax - sMin) * plotH;
const zeroY = yBar(0);
// line scale (cumulative running total)
const cv = series.map(s => s.cumulative);
let cMax = Math.max(0, ...cv), cMin = Math.min(0, ...cv);
if (cMax === cMin) cMax = cMin + 1;
const yLine = v => padT + (cMax - v) / (cMax - cMin) * plotH;
const barW = Math.min(36, slot * 0.52);
const showLab = i => n <= 18 || i % 2 === 0; // thin labels when crowded
const baseline = `<line class="sav-base" x1="${padL}" y1="${zeroY.toFixed(1)}" x2="${(W - padR).toFixed(1)}" y2="${zeroY.toFixed(1)}"/>`;
const bars = series.map((s, i) => {
const x = cx(i), y1 = yBar(s.saved);
const top = Math.min(zeroY, y1), h = Math.max(1.5, Math.abs(y1 - zeroY));
return `<g class="sav-bar ${s.saved >= 0 ? "pos" : "neg"}">` +
`<rect x="${(x - barW / 2).toFixed(1)}" y="${top.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" rx="3"/>` +
`<title>${esc(s.label)} — saved ${fmt(s.saved)} · total ${fmt(s.cumulative)}</title></g>`;
}).join("");
const linePts = series.map((s, i) => `${cx(i).toFixed(1)},${yLine(s.cumulative).toFixed(1)}`).join(" ");
const dots = series.map((s, i) =>
`<g class="sav-dot"><circle cx="${cx(i).toFixed(1)}" cy="${yLine(s.cumulative).toFixed(1)}" r="3.2"/>` +
`<title>${esc(s.label)} — running total ${fmt(s.cumulative)}</title></g>`).join("");
const labels = series.map((s, i) => showLab(i)
? `<text class="sav-xlab" x="${cx(i).toFixed(1)}" y="${H - 10}" text-anchor="middle">${esc(s.label)}</text>` : "").join("");
return `<svg class="sav-svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" ` +
`aria-label="Savings per month and cumulative total">${baseline}${bars}` +
`<polyline class="sav-line" fill="none" points="${linePts}"/>${dots}${labels}</svg>`;
}
/* ---------- housing affordability (💰 income vs 🏠 housing) ----------
The classic "spend ~30% on housing" figure is a guideline, not a cliff — so
this reads as a green→red spectrum you sit somewhere along, rather than a
pass/fail line at 30%. Colour, words and the budget note are all guidance. */
const AFFORD_GUIDE = 0.30; // the widely-cited ~30% comfort guideline (a marker, not a hard limit)
// Map a housing-to-income % onto a smooth green→amber→red hue: green at/below
// ~25%, sweeping to red by ~55%. No hard threshold — the colour just slides.
function affordHue(pct) {
const t = Math.max(0, Math.min(1, (pct - 25) / (55 - 25))); // 0 at ≤25%, 1 at ≥55%
return 140 * (1 - t); // 140°=green → 0°=red
}
// Keep these light values in step with the .aff-track gradient stops in styles.css.
function affordColor(pct, light = 47) { return `hsl(${affordHue(pct).toFixed(1)} 76% ${light}%)`; }
// A soft, graduated read on the ratio — guidance language, never a verdict.
function affordRead(pct) {
if (pct < 25) return { word: "Comfortable", note: "a relaxed share of your income" };
if (pct < 35) return { word: "Comfortable", note: "right around the commonly-suggested ~30% comfort guide" };
if (pct < 45) return { word: "Manageable", note: "a little above the ~30% guide, but workable for many budgets" };
if (pct < 55) return { word: "Getting stretched", note: "a sizeable share — worth keeping an eye on" };
return { word: "Heavy", note: "a large share of your income, which can start to feel tight" };
}
function renderAffordability() {
affordBody.innerHTML = `
<div class="card aff-card">
<div class="aff-stats">
<div class="aff-stat"><span class="aff-stat-lab">Annual income 💰</span><span class="aff-stat-val" data-aff-annual>&mdash;</span></div>
<div class="aff-stat"><span class="aff-stat-lab">Income / month</span><span class="aff-stat-val" data-aff-minc>&mdash;</span></div>
<div class="aff-stat"><span class="aff-stat-lab">Housing / month 🏠</span><span class="aff-stat-val" data-aff-house>&mdash;</span></div>
<div class="aff-stat"><span class="aff-stat-lab">Of income on housing</span><span class="aff-stat-val" data-aff-ratio>&mdash;</span></div>
</div>
<div class="aff-meter" data-aff-meter hidden>
<div class="aff-gauge">
<div class="aff-track"></div>
<div class="aff-guide" style="left:30%"><span class="aff-guide-lab">~30% guide</span></div>
<div class="aff-needle" data-aff-needle><span class="aff-needle-val" data-aff-needleval></span></div>
</div>
<div class="aff-verdict" data-aff-verdict></div>
<div class="aff-budget" data-aff-budget></div>
<div class="aff-note">The 30% figure is a common rule of thumb, not a hard limit — treat the colour as a gentle guide.</div>
</div>
<div class="aff-empty" data-aff-empty hidden></div>
</div>`;
}
// monthlyIncome / monthlyHousing are the typical-month averages computed in recompute.
function paintAffordability(monthlyIncome, monthlyHousing) {
const setVal = (sel, text) => { const el = affordBody.querySelector(sel); if (el) el.textContent = text; };
setVal("[data-aff-annual]", monthlyIncome > 0 ? fmt(monthlyIncome * 12) : "—");
setVal("[data-aff-minc]", monthlyIncome > 0 ? fmt(monthlyIncome) : "—");
setVal("[data-aff-house]", monthlyHousing > 0 ? fmt(monthlyHousing) : "—");
const meter = affordBody.querySelector("[data-aff-meter]");
const empty = affordBody.querySelector("[data-aff-empty]");
const ratioEl = affordBody.querySelector("[data-aff-ratio]");
if (!(monthlyIncome > 0 && monthlyHousing > 0)) { // need both to form a ratio
if (ratioEl) { ratioEl.textContent = "—"; ratioEl.style.color = ""; }
if (meter) meter.hidden = true;
if (empty) {
empty.hidden = false;
empty.innerHTML = (!monthlyIncome && !monthlyHousing)
? `Tag income with <b>💰 Income</b> and housing costs with <b>🏠 Housing</b> to see your affordability here.`
: !monthlyHousing
? `Add some <b>🏠 Housing</b> costs and we'll measure them against your income.`
: `Tag your <b>💰 Income</b> so we can work out your housing ratio.`;
}
return;
}
const pct = (monthlyHousing / monthlyIncome) * 100;
const read = affordRead(pct);
const fillColor = affordColor(pct); // matches the gauge track beneath the needle
const textColor = affordColor(pct, 44); // a touch darker so it stays legible as text
if (empty) empty.hidden = true;
if (meter) meter.hidden = false;
if (ratioEl) { ratioEl.textContent = `${Math.round(pct)}%`; ratioEl.style.color = textColor; }
const needle = affordBody.querySelector("[data-aff-needle]");
if (needle) {
needle.style.left = Math.max(0, Math.min(100, pct)) + "%";
needle.style.setProperty("--aff-c", fillColor);
}
setVal("[data-aff-needleval]", `${Math.round(pct)}%`);
const verdict = affordBody.querySelector("[data-aff-verdict]");
if (verdict) {
verdict.style.color = textColor;
verdict.textContent = `${read.word} — about ${Math.round(pct)}% of your income goes on housing, ${read.note}.`;
}
const guideCost = monthlyIncome * AFFORD_GUIDE, headroom = guideCost - monthlyHousing;
const budget = affordBody.querySelector("[data-aff-budget]");
if (budget) {
budget.innerHTML = headroom >= 0
? `Around <b>${fmt(guideCost)}</b>/mo sits on the ~30% guide · <span class="aff-good">${fmt(headroom)} to spare</span>`
: `Around <b>${fmt(guideCost)}</b>/mo sits on the ~30% guide · <span class="aff-bad">${fmt(-headroom)} above it</span>`;
}
}
/* ---------- totals (annualised budget at a glance + click-to-chart per month) ---------- */
// The four headline figures. `pick` pulls this metric out of a month's flow record
// (built in recompute) so a click can chart it month by month. `tone` drives bar colour.
const TT_METRICS = [
{ key: "income", icon: "💰", label: "Income", sub: "in", tone: "in", pick: f => f.inc },
{ key: "costs", icon: "💸", label: "Spending", sub: "out", tone: "out", pick: f => f.out },
{ key: "net", icon: "⚖️", label: "Net flow", sub: "in out", tone: "net", pick: f => f.net },
{ key: "savings", icon: "🐷", label: "Savings", sub: "set aside", tone: "in", pick: f => f.saved },
];
const TT_BY_KEY = new Map(TT_METRICS.map(m => [m.key, m]));
let totalsFlow = []; // per-month series, set by paintTotals (for the chart modal)
let totalsAnnual = { income: 0, costs: 0, net: 0, savings: 0 }; // annualised figures, set by paintTotals
// How many times a year a recurring item fires — a forward-looking projection of the
// schedule (date bounds are ignored: this is "at this rate, per year").
function annualMult(item) {
if (item.freq === "yearly") return 1;
if (item.freq === "weekly") return 365.25 / 7 / Math.max(parseInt(item.every, 10) || 1, 1);
if (item.freq === "daily") return 365.25 / Math.max(parseInt(item.every, 10) || 1, 1);
return 12; // monthly
}
// Annualised value of a recurring item over the next 12 months, honouring its date window.
// A perpetual, already-running item (no end date, not starting in the future) uses the smooth
// annualMult rate (e.g. 52.18 weeks/yr). But an item bounded by an end date (until) or a future
// start (anchor) is counted by its ACTUAL occurrences in the window — so a benefit that runs only
// until a switch-over, or starts partway through the year, is no longer over-counted as a flat 12×.
function annualForItem(item) {
const amt = num(item.amount);
if (!amt) return 0;
const winStart = Date.parse(`${currentYm()}-01`);
const anchorMs = Date.parse(item.anchor);
const bounded = !isNaN(Date.parse(item.until)) || (!isNaN(anchorMs) && anchorMs > winStart);
if (!bounded) return amt * annualMult(item);
let ym = currentYm(), count = 0;
for (let i = 0; i < 12; i++) { count += recurOccurrences(item, ym).length; ym = addMonthYm(ym); }
return amt * count;
}
// Skeleton: four clickable stat cards. Live values are filled by paintTotals (from recompute).
function renderTotals() {
if (!totalsBody) return;
const cards = TT_METRICS.map(c => `
<button type="button" class="tt-card tt-${c.tone}" data-action="totals-chart" data-metric="${c.key}"
title="Chart ${esc(c.label)} month by month">
<span class="tt-top"><span class="tt-ic">${c.icon}</span><span class="tt-lab">${esc(c.label)}</span></span>
<span class="tt-year" data-tt="${c.key}-year">&mdash;</span>
<span class="tt-year-cap" data-tt="${c.key}-cap">per year</span>
<span class="tt-month"><b data-tt="${c.key}-month">&mdash;</b> <span data-tt="${c.key}-mcap">/ mo</span></span>
<span class="tt-cta">📈 Chart by month</span>
</button>`).join("");
totalsBody.innerHTML =
`<div class="tt-grid">${cards}</div>` +
`<p class="tt-empty" data-tt-empty hidden>Add <b>🔁 Recurring</b> income &amp; bills above and your annual ` +
`totals appear here — or tap any card to chart what's already in your ledger.</p>`;
}
function paintTotals(flow) {
if (!totalsBody) return;
totalsFlow = flow || [];
// Annualised budget from the recurring schedule (income vs costs; savings = 🐷-tagged outgoings).
let incYr = 0, costYr = 0, saveYr = 0;
(state.recurring || []).forEach(it => {
if (it.enabled === false) return;
const yr = annualForItem(it);
if (!yr) return;
if (it.dir === "in") incYr += yr;
else { costYr += yr; if (it.icon === SAVINGS_ICON) saveYr += yr; }
});
const vals = { income: incYr, costs: costYr, net: incYr - costYr, savings: saveYr };
totalsAnnual = vals; // annual rates still power the click-through chart's "planned/yr" line
const hasPlan = !!(incYr || costYr || saveYr);
// Savings is shown as the actual whole-ledger total set aside (matching the 🐷 Savings
// panel), not a 12-month rate — so the two figures reconcile. Avg is per active month.
const savedToDate = (flow || []).reduce((s, f) => s + (f.saved || 0), 0);
const savMonths = (flow || []).filter(f => f.saved !== 0).length;
const savAvg = savMonths ? savedToDate / savMonths : 0;
const hasSaved = savMonths > 0;
TT_METRICS.forEach(c => {
const yEl = totalsBody.querySelector(`[data-tt="${c.key}-year"]`);
const mEl = totalsBody.querySelector(`[data-tt="${c.key}-month"]`);
const capEl = totalsBody.querySelector(`[data-tt="${c.key}-cap"]`);
const mcapEl = totalsBody.querySelector(`[data-tt="${c.key}-mcap"]`);
if (c.key === "savings") {
const show = hasSaved || hasPlan;
if (yEl) { yEl.textContent = show ? fmt(savedToDate) : "—"; yEl.classList.toggle("neg", show && savedToDate < 0); }
if (mEl) { mEl.textContent = show ? fmt(savAvg) : "—"; mEl.classList.toggle("neg", show && savAvg < 0); }
if (capEl) capEl.textContent = "saved to date";
if (mcapEl) mcapEl.textContent = "/ mo avg";
return;
}
const yr = vals[c.key];
if (yEl) { yEl.textContent = hasPlan ? fmt(yr) : "—"; yEl.classList.toggle("neg", hasPlan && yr < 0); }
if (mEl) { mEl.textContent = hasPlan ? fmt(yr / 12) : "—"; mEl.classList.toggle("neg", hasPlan && yr < 0); }
});
const note = totalsBody.querySelector("[data-tt-empty]");
if (note) note.hidden = hasPlan || hasSaved;
}
// ----- click-to-chart modal: a month-by-month bar chart of the chosen metric -----
const ttModal = document.createElement("div");
ttModal.className = "overlay modal-overlay tt-modal hidden";
ttModal.innerHTML =
`<div class="modal tt-modal-box">
<div class="tt-modal-head">
<h3 class="modal-title" data-ttm-title></h3>
<button type="button" class="modal-x" data-ttm-close aria-label="Close">✕</button>
</div>
<p class="modal-sub" data-ttm-sub></p>
<div class="tt-modal-chart" data-ttm-chart></div>
</div>`;
document.body.appendChild(ttModal);
function closeTotalsChart() { ttModal.classList.add("hidden"); }
ttModal.addEventListener("click", e => {
if (e.target === ttModal || e.target.closest("[data-ttm-close]")) closeTotalsChart();
});
document.addEventListener("keydown", e => {
if (e.key === "Escape" && !ttModal.classList.contains("hidden")) closeTotalsChart();
});
function openTotalsChart(metric) {
const cfg = TT_BY_KEY.get(metric);
if (!cfg) return;
const series = totalsFlow.map(f => ({ label: f.label, value: cfg.pick(f) }));
const annual = totalsAnnual[metric] || 0;
const avg = series.length ? series.reduce((s, p) => s + p.value, 0) / series.length : 0;
ttModal.querySelector("[data-ttm-title]").textContent = `${cfg.icon} ${cfg.label} — month by month`;
let sub;
if (metric === "savings") {
// Reconcile with the card + Savings panel: whole-ledger total, averaged over active months.
const total = series.reduce((s, p) => s + p.value, 0);
const active = series.filter(p => p.value !== 0).length;
sub = `Saved <b>${fmt(total)}</b> to date` +
(active ? ` &nbsp;·&nbsp; <b>${fmt(total / active)}</b>/mo average` : "");
} else {
sub = `Planned <b>${fmt(annual)}</b>/yr · <b>${fmt(annual / 12)}</b>/mo from recurring` +
(series.length ? ` &nbsp;·&nbsp; actual average <b>${fmt(avg)}</b>/mo` : "");
}
ttModal.querySelector("[data-ttm-sub]").innerHTML = sub;
ttModal.querySelector("[data-ttm-chart]").innerHTML = metricChartSVG(series, cfg, avg);
ttModal.classList.remove("hidden");
}
// Pure inline SVG (no deps): one bar per month off a zero baseline, with a dashed
// average line. Colour comes from the tone class on the <svg> + each bar's sign.
function metricChartSVG(series, cfg, avg) {
if (!series.length) {
return `<div class="tt-chart-empty">Add some months in your <b>🌊 Ledger</b> to chart this.</div>`;
}
if (series.every(p => p.value === 0)) {
return `<div class="tt-chart-empty">No <b>${esc(cfg.label)}</b> recorded in your ledger months yet.</div>`;
}
const n = series.length;
const slot = n <= 3 ? 120 : n <= 6 ? 94 : n <= 10 ? 68 : n <= 16 ? 50 : 38;
const padL = 14, padR = 14, padT = 22, padB = 30;
const H = 240, plotH = H - padT - padB;
const W = padL + n * slot + padR;
const cx = i => padL + slot * i + slot / 2;
const vv = series.map(p => p.value);
let vMax = Math.max(0, ...vv), vMin = Math.min(0, ...vv);
if (vMax === vMin) vMax = vMin + 1;
const y = v => padT + (vMax - v) / (vMax - vMin) * plotH;
const zeroY = y(0);
const barW = Math.min(42, slot * 0.54);
const showLab = i => n <= 18 || i % 2 === 0;
const baseline = `<line class="tt-base" x1="${padL}" y1="${zeroY.toFixed(1)}" x2="${(W - padR).toFixed(1)}" y2="${zeroY.toFixed(1)}"/>`;
const bars = series.map((p, i) => {
const x = cx(i), yv = y(p.value);
const top = Math.min(zeroY, yv), h = Math.max(1.5, Math.abs(yv - zeroY));
return `<g class="tt-bar ${p.value >= 0 ? "pos" : "neg"}">` +
`<rect x="${(x - barW / 2).toFixed(1)}" y="${top.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" rx="3"/>` +
`<title>${esc(p.label)}${fmt(p.value)}</title></g>`;
}).join("");
const avgY = y(avg);
const avgLine = avg !== 0
? `<line class="tt-avg" x1="${padL}" y1="${avgY.toFixed(1)}" x2="${(W - padR).toFixed(1)}" y2="${avgY.toFixed(1)}"/>` +
`<text class="tt-avg-lab" x="${(W - padR).toFixed(1)}" y="${(avgY - 5).toFixed(1)}" text-anchor="end">avg ${fmt(avg)}</text>`
: "";
const labels = series.map((p, i) => showLab(i)
? `<text class="tt-xlab" x="${cx(i).toFixed(1)}" y="${H - 9}" text-anchor="middle">${esc(p.label)}</text>` : "").join("");
return `<svg class="tt-svg tt-${cfg.tone}" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" ` +
`aria-label="${esc(cfg.label)} per month">${baseline}${bars}${avgLine}${labels}</svg>`;
}
/* ---------- edit handlers (event delegation) ---------- */
function onInput(e) {
if (!state) return;
const el = e.target;
const { field, scope, id, month } = el.dataset;
if (!field) return;
if (scope === "account") {
const a = state.accounts.find(a => a.id === id);
if (a) a[field] = el.value;
} else if (scope === "month") {
const m = state.months.find(m => m.id === id);
if (m) {
if (field === "ym") {
// keep the display title in sync unless it was hand-edited
const prevName = ymName(m.ym);
m.ym = el.value;
if (!m.title || m.title === prevName) m.title = ymName(el.value);
render(); scheduleSave(); return;
}
m[field] = el.value;
}
} else if (scope === "row") {
const m = state.months.find(m => m.id === month);
const r = m && m.rows.find(r => r.id === id);
if (r) {
if (field === "day") {
const raw = el.value.trim();
if (raw === "") {
r.date = "";
} else if (/^\d{4}-\d{2}$/.test(m.ym || "")) {
const [y, mo] = m.ym.split("-").map(Number);
const d = Math.min(Math.max(parseInt(raw, 10) || 1, 1), daysInMonth(y, mo));
r.date = `${m.ym}-${pad2(d)}`;
}
const sup = document.querySelector(`[data-dayord="${r.id}"]`);
if (sup) sup.textContent = ordSuffix(raw);
} else {
r[field] = el.value;
if (field === "desc" && !r.icon) { applyCat(r, iconFor(el.value), false); if (r.icon) refreshDot("row", r.id, r, month); }
}
}
} else if (scope === "recurring") {
const it = state.recurring.find(x => x.id === id);
if (!it) return;
it[field] = el.value;
if (field === "desc" && !it.icon) { applyCat(it, iconFor(el.value), false); if (it.icon) refreshDot("recurring", it.id, it); }
const established = state.months.some(m => m.rows.some(r => r.recId === it.id));
let appliedNew = false;
if (!established && num(it.amount) > 0 && it.enabled !== false) {
applyItemToAllMonths(it); // first time it has a real amount → auto-add to existing months
appliedNew = true;
} else if (established) {
syncRecurringItem(it, ["freq", "day", "every", "anchor", "until", "ymonth", "yday"].includes(field));
}
// Reflect the change in the months, but off the typing critical path (see
// scheduleRecurRepaint). A brand-new item that isn't in any month yet has nothing to
// repaint, so skip it entirely — that keeps adding a new item instant. renderMonths()
// doesn't touch the recurring table, so the focused input stays put either way.
if (appliedNew || established) scheduleRecurRepaint();
if (field === "dir") sortRecurring(state); // flipped In/Out → re-group (income stays on top)
if (field === "freq" || field === "dir") renderRecurring(); // swap the "when" fields / recolour amount
scheduleSave();
return;
}
// Only repaint running balances when a money field actually changed. Typing in a
// description / name / title leaves every balance identical, so skip the whole-ledger
// recompute (a querySelector per row) and just save — keeps typing snappy on big ledgers.
const affectsBalance =
(scope === "row" && (field === "inc" || field === "out")) ||
(scope === "account" && field === "balance");
if (affectsBalance) recompute();
scheduleSave();
}
// When a row's date is committed, re-sort the month so it slots into chronological order.
function onChange(e) {
if (!state) return;
const d = e.target.dataset;
if (d && d.scope === "row" && (d.field === "date" || d.field === "day")) {
const m = state.months.find(m => m.id === d.month);
if (m) { sortMonthRows(m); renderMonths(); recompute(); scheduleSave(); }
}
}
function onClick(e) {
if (!state) return;
const btn = e.target.closest("[data-action]");
if (!btn) return;
const { action, id, month } = btn.dataset;
switch (action) {
case "totals-chart": openTotalsChart(btn.dataset.metric); return;
case "sec-tab": activeTab = btn.dataset.key; saveSectionPrefs(); applySectionView(); return;
case "sec-hide": hiddenSections.add(btn.dataset.key); saveSectionPrefs(); applySectionView(); return;
case "sec-show": hiddenSections.delete(btn.dataset.key); saveSectionPrefs(); applySectionView(); return;
case "switch-budget":
if (id !== book.activeId) { book.activeId = id; syncActive(); render(); scheduleSave(); }
return;
case "new-budget": {
const name = prompt("Name for the new budget:", `Budget ${book.budgets.length + 1}`);
if (name == null) return;
const b = newBudget(name.trim() || `Budget ${book.budgets.length + 1}`);
book.budgets.push(b);
book.activeId = b.id; syncActive();
render(); scheduleSave();
return;
}
case "rename-budget": {
const b = activeBudget();
const name = prompt("Rename budget:", b.name);
if (name == null) return;
b.name = name.trim() || b.name;
render(); scheduleSave();
return;
}
case "dup-budget": {
const copy = reassignIds(deepClone(activeBudget()));
copy.name = activeBudget().name + " (copy)";
book.budgets.push(copy);
book.activeId = copy.id; syncActive();
render(); scheduleSave();
return;
}
case "del-budget": {
if (book.budgets.length <= 1) return; // keep at least one
const b = activeBudget();
if (!confirm(`Delete budget "${b.name}" and all its months? This cannot be undone.`)) return;
book.budgets = book.budgets.filter(x => x.id !== b.id);
book.activeId = book.budgets[0].id; syncActive();
render(); scheduleSave();
return;
}
case "add-account": state.accounts.push({ id: uid(), name: "", balance: "", bank: "" }); break;
case "del-account": state.accounts = state.accounts.filter(a => a.id !== id); break;
case "add-recurring": state.recurring.push(newRecurring()); break;
case "del-recurring": {
const it = state.recurring.find(x => x.id === id);
if (!it) break;
const label = it.desc ? `"${it.desc}"` : "this recurring item";
if (!confirm(`Delete ${label} and remove its entries from all months?`)) return;
removeItemFromAllMonths(it); // pull every row it generated out of the months too
state.recurring = state.recurring.filter(x => x.id !== id);
break;
}
case "edit-sched": openSchedPop(btn); return;
case "toggle-recurring": {
const it = state.recurring.find(x => x.id === id);
if (it) {
it.enabled = it.enabled === false; // flip (undefined counts as enabled)
if (it.enabled) applyItemToAllMonths(it); // resume → add back everywhere
else removeItemFromAllMonths(it); // pause → pull from every month
}
break;
}
case "move-recurring": {
const arr = state.recurring;
const i = arr.findIndex(x => x.id === id);
const j = i + (btn.dataset.dir === "up" ? -1 : 1);
// Only swap within the same direction group — In stays grouped above Out.
if (i >= 0 && j >= 0 && j < arr.length && arr[j].dir === arr[i].dir) {
const [it] = arr.splice(i, 1);
arr.splice(j, 0, it);
}
break;
}
case "toggle-grouped": grouped = !grouped; renderMonths(); recompute(); return;
case "toggle-group": {
const key = btn.dataset.key;
if (collapsedGroups.has(key)) collapsedGroups.delete(key); else collapsedGroups.add(key);
renderMonths(); recompute(); return;
}
case "add-month": addOneMonth(); break;
case "add-months-bulk": {
const input = document.getElementById("bulk-count");
let n = parseInt(input && input.value, 10);
n = Math.min(Math.max(n || 1, 1), 60);
for (let i = 0; i < n; i++) addOneMonth();
break;
}
case "fill-recurring": {
const m = state.months.find(m => m.id === id);
if (!m) return;
if (!m.ym) { alert("Set this month's calendar month first (the 📅 box in the month header)."); return; }
const added = fillRecurring(m);
if (added === 0) alert("No recurring payments matched this month — add some in the Recurring section above.");
break;
}
case "fill-all-months": {
let total = 0, skipped = 0;
state.months.forEach(m => { if (!m.ym) { skipped++; return; } total += fillRecurring(m); });
alert(
`Added ${total} recurring ${total === 1 ? "entry" : "entries"} across your months.` +
(skipped ? `\n${skipped} month${skipped === 1 ? "" : "s"} skipped — no calendar month (📅) set.` : "")
);
break;
}
case "del-month":
if (!confirm("Delete this entire month and its rows?")) return;
state.months = state.months.filter(m => m.id !== id);
break;
case "add-row": { const m = state.months.find(m => m.id === month); if (m) m.rows.push(newRow()); break; }
case "insert-row": {
const m = state.months.find(m => m.id === month);
if (m) { const i = m.rows.findIndex(r => r.id === id); m.rows.splice(i + 1, 0, newRow()); }
break;
}
case "del-row": { const m = state.months.find(m => m.id === month); if (m) m.rows = m.rows.filter(r => r.id !== id); break; }
case "open-tags": openTagPicker(btn); return;
case "open-bank": openBankPicker(btn); return;
case "filter-cat": {
const mId = btn.dataset.month, icon = btn.dataset.icon;
let sel = catFilter.get(mId);
if (icon === CAT_ALL) {
// "All" toggles: everything already showing ⇒ hide all; otherwise ⇒ show all.
if (!sel) catFilter.set(mId, new Set([CAT_NONE])); // show-all → show-none
else catFilter.delete(mId); // filtered/none → show-all
} else {
if (!sel || sel.has(CAT_NONE)) { sel = new Set(); catFilter.set(mId, sel); } // leave none/empty
if (sel.has(icon)) sel.delete(icon); else sel.add(icon);
if (sel.size === 0) catFilter.delete(mId); // nothing selected ⇒ show all
}
const m = state.months.find(x => x.id === mId);
if (m) {
const chipEl = document.querySelector(`[data-chips="${mId}"]`);
if (chipEl) chipEl.innerHTML = monthChipsHTML(m); // refresh active states
applyMonthFilter(m);
}
return;
}
case "step": {
const input = btn.closest(".stepper") && btn.closest(".stepper").querySelector("input");
if (!input || input.readOnly) return;
const stepAttr = parseFloat(input.getAttribute("step")) || 1;
const unit = stepAttr < 1 ? 1 : stepAttr; // money steps by 1, not 0.01
let next = (parseFloat(input.value) || 0) + (btn.dataset.dir === "up" ? unit : -unit);
const min = input.getAttribute("min"), max = input.getAttribute("max");
if (min !== null && next < parseFloat(min)) next = parseFloat(min);
if (max !== null && next > parseFloat(max)) next = parseFloat(max);
input.value = String(Math.round(next * 100) / 100);
input.dispatchEvent(new Event("input", { bubbles: true })); // reuse onInput → state + recompute + save
return;
}
default: return;
}
render();
scheduleSave();
}
/* ---------- icon + colour picker (centered modal) ---------- */
const tagPop = document.createElement("div");
tagPop.className = "overlay modal-overlay tag-modal hidden";
tagPop.innerHTML =
`<div class="modal tag-modal-box" role="dialog" aria-modal="true" aria-label="Category and colour">
<div class="tag-modal-head">
<h3 class="modal-title">Category &amp; colour</h3>
<button type="button" class="modal-x" data-tagclose aria-label="Close">✕</button>
</div>
<input type="text" class="tag-search" data-tagsearch placeholder="Search categories…" autocomplete="off" spellcheck="false">
<div class="cat-grid">` +
`<button type="button" class="cat-opt none" data-emoji="" data-name="No category">✕ &nbsp;No category</button>` +
CATEGORIES.map(c => `<button type="button" class="cat-opt" data-emoji="${esc(c.icon)}" data-tag="${c.tag}" data-name="${esc(c.name)}"><span class="cat-emoji">${esc(c.icon)}</span><span class="cat-nm">${esc(c.name)}</span><span class="cat-sw" style="--dot:${tagCss(c.tag)}"></span></button>`).join("") +
`</div>
<div class="cat-empty" data-cat-empty hidden>No categories match your search.</div>
<div class="pop-foot"><span class="pop-foot-lab">Colour</span><div class="pop-swatches">` +
TAG_COLORS.map(t => `<button type="button" class="swatch" data-color="${t.key}" style="--dot:${t.css}" title="${t.key || "No colour"}"></button>`).join("") +
`</div>
</div>`;
document.body.appendChild(tagPop);
// Live-filter the category grid by name; show a hint when nothing matches.
function filterTagCats(q) {
q = (q || "").trim().toLowerCase();
let shown = 0;
tagPop.querySelectorAll(".cat-opt").forEach(b => {
const hit = !q || (b.dataset.name || "").toLowerCase().includes(q);
b.hidden = !hit;
if (hit) shown++;
});
const empty = tagPop.querySelector("[data-cat-empty]");
if (empty) empty.hidden = shown > 0;
}
let tagTarget = null;
function tagItemOf(t) {
if (!t || !state) return null;
if (t.scope === "recurring") return state.recurring.find(x => x.id === t.id) || null;
const m = state.months.find(m => m.id === t.month);
return (m && m.rows.find(r => r.id === t.id)) || null;
}
// Inner markup of a category dot/chip: icon + name when set, empty (bare dot) otherwise.
function catDotInner(icon) {
return icon ? `<span class="cat-emoji">${esc(icon)}</span><span class="cat-name">${esc(catLabel(icon))}</span>` : "";
}
// Update a category chip in place (so an auto-suggested category shows without a focus-losing re-render).
function refreshDot(scope, id, entry, month) {
const btn = document.querySelector(`.tagdot[data-scope="${scope}"][data-id="${id}"]` + (month ? `[data-month="${month}"]` : ""));
if (!btn) return;
btn.innerHTML = catDotInner(entry.icon);
btn.classList.toggle("has-icon", !!entry.icon);
btn.dataset.has = entry.tag ? "1" : "0";
btn.style.setProperty("--dot", tagCss(entry.tag));
}
function openTagPicker(dot) {
tagTarget = { scope: dot.dataset.scope, id: dot.dataset.id, month: dot.dataset.month };
const it = tagItemOf(tagTarget) || {};
tagPop.querySelectorAll(".swatch").forEach(s => s.classList.toggle("active", s.dataset.color === (it.tag || "")));
tagPop.querySelectorAll(".cat-opt").forEach(b => b.classList.toggle("active", b.dataset.emoji === (it.icon || "")));
const search = tagPop.querySelector("[data-tagsearch]");
if (search) search.value = "";
filterTagCats(""); // reset any prior search
tagPop.classList.remove("hidden");
if (search) search.focus();
const activeCat = tagPop.querySelector(".cat-opt.active");
const grid = tagPop.querySelector(".cat-grid");
if (activeCat && grid) grid.scrollTop = Math.max(0, activeCat.offsetTop - grid.clientHeight / 2); // centre it within the grid
}
function closeTagPicker() { tagPop.classList.add("hidden"); tagTarget = null; }
tagPop.addEventListener("click", e => {
if (e.target === tagPop || e.target.closest("[data-tagclose]")) { closeTagPicker(); return; } // backdrop or ✕
if (!tagTarget) return;
const cat = e.target.closest(".cat-opt");
const sw = e.target.closest(".swatch");
if (!cat && !sw) return;
const it = tagItemOf(tagTarget);
if (!it) return;
if (cat) applyCat(it, cat.dataset.emoji, true); // category → icon + its colour
else it.tag = sw.dataset.color; // swatch → override just the colour
if (tagTarget.scope === "recurring") syncRecurringItem(it, false); // push to its month rows
closeTagPicker();
render();
scheduleSave();
});
tagPop.addEventListener("input", e => {
if (e.target.matches("[data-tagsearch]")) filterTagCats(e.target.value);
});
tagPop.addEventListener("keydown", e => {
if (!e.target.matches("[data-tagsearch]") || e.key !== "Enter") return;
e.preventDefault(); // Enter applies the first matching category
const first = [...tagPop.querySelectorAll(".cat-opt")].find(b => !b.hidden);
if (first) first.click();
});
document.addEventListener("keydown", e => { if (e.key === "Escape") closeTagPicker(); });
/* ---------- bank picker (popover for account logos) ---------- */
let bankTarget = null;
const bankPop = document.createElement("div");
bankPop.className = "overlay modal-overlay tag-modal bank-modal hidden";
bankPop.innerHTML =
`<div class="modal tag-modal-box" role="dialog" aria-modal="true" aria-label="Choose a bank">
<div class="tag-modal-head">
<h3 class="modal-title">Bank</h3>
<button type="button" class="modal-x" data-bankclose aria-label="Close">✕</button>
</div>
<input type="text" class="tag-search" data-banksearch placeholder="Search banks…" autocomplete="off" spellcheck="false">
<div class="cat-grid">` +
`<button type="button" class="cat-opt none" data-bank="" data-name="No bank">✕ &nbsp;No bank</button>` +
BANKS.map(b => `<button type="button" class="cat-opt" data-bank="${b.key}" data-name="${esc(b.name)}"><span class="bank-opt-ico">${bankGlyph(b.key)}</span><span class="cat-nm">${esc(b.name)}</span></button>`).join("") +
`</div>
<div class="cat-empty" data-bank-empty hidden>No banks match your search.</div>
</div>`;
document.body.appendChild(bankPop);
// Live-filter the bank grid by name; show a hint when nothing matches.
function filterBanks(q) {
q = (q || "").trim().toLowerCase();
let shown = 0;
bankPop.querySelectorAll(".cat-opt").forEach(b => {
const hit = !q || (b.dataset.name || "").toLowerCase().includes(q);
b.hidden = !hit;
if (hit) shown++;
});
const empty = bankPop.querySelector("[data-bank-empty]");
if (empty) empty.hidden = shown > 0;
}
function openBankPicker(btn) {
bankTarget = btn.dataset.id;
const a = state.accounts.find(x => x.id === bankTarget);
bankPop.querySelectorAll(".cat-opt").forEach(o => o.classList.toggle("active", o.dataset.bank === ((a && a.bank) || "")));
const search = bankPop.querySelector("[data-banksearch]");
if (search) search.value = "";
filterBanks(""); // reset any prior search
bankPop.classList.remove("hidden");
if (search) search.focus();
const act = bankPop.querySelector(".cat-opt.active");
const grid = bankPop.querySelector(".cat-grid");
if (act && grid) grid.scrollTop = Math.max(0, act.offsetTop - grid.clientHeight / 2);
}
function closeBankPicker() { bankPop.classList.add("hidden"); bankTarget = null; }
bankPop.addEventListener("click", e => {
if (e.target === bankPop || e.target.closest("[data-bankclose]")) { closeBankPicker(); return; } // backdrop or ✕
const opt = e.target.closest(".cat-opt");
if (!opt || !bankTarget) return;
const a = state.accounts.find(x => x.id === bankTarget);
if (a) a.bank = opt.dataset.bank || "";
closeBankPicker();
render();
scheduleSave();
});
bankPop.addEventListener("input", e => {
if (e.target.matches("[data-banksearch]")) filterBanks(e.target.value);
});
bankPop.addEventListener("keydown", e => {
if (!e.target.matches("[data-banksearch]") || e.key !== "Enter") return;
e.preventDefault(); // Enter applies the first matching bank
const first = [...bankPop.querySelectorAll(".cat-opt")].find(b => !b.hidden);
if (first) first.click();
});
document.addEventListener("keydown", e => { if (e.key === "Escape") closeBankPicker(); });
/* ---------- schedule popover (edits a recurring item's frequency + dates) ---------- */
let schedTargetId = null;
const schedPop = document.createElement("div");
schedPop.className = "sched-pop hidden";
document.body.appendChild(schedPop);
function schedItem() { return state && schedTargetId ? state.recurring.find(x => x.id === schedTargetId) : null; }
function renderSchedPop() {
const it = schedItem();
if (!it) return;
const opt = (v, opts) => opts.map(([val, label]) => `<option value="${val}" ${v === val ? "selected" : ""}>${label}</option>`).join("");
const row = (label, sf, val, attrs) => `<label class="sched-row"><span>${label}</span><input data-sf="${sf}" value="${esc(val)}" ${attrs}></label>`;
let body = `<div class="sched-title">Schedule</div>` +
`<label class="sched-row"><span>Repeats</span><select data-sf="freq">${opt(it.freq, [["daily", "Every N days"], ["weekly", "Every N weeks"], ["monthly", "Monthly"], ["yearly", "Yearly"]])}</select></label>`;
if (it.freq === "daily") {
body += row("Every (days)", "every", it.every, 'type="number" min="1" inputmode="numeric"');
body += row("From", "anchor", it.anchor, 'type="date"');
body += row("To <em>(optional)</em>", "until", it.until, 'type="date"');
} else if (it.freq === "weekly") {
body += row("Every (weeks)", "every", it.every, 'type="number" min="1" inputmode="numeric"');
body += row("From", "anchor", it.anchor, 'type="date"');
body += row("To <em>(optional)</em>", "until", it.until, 'type="date"');
} else if (it.freq === "yearly") {
body += row("Month (112)", "ymonth", it.ymonth, 'type="number" min="1" max="12" inputmode="numeric"');
body += row("Day (131)", "yday", it.yday, 'type="number" min="1" max="31" inputmode="numeric"');
body += row("From <em>(optional)</em>", "anchor", it.anchor, 'type="date"');
body += row("To <em>(optional)</em>", "until", it.until, 'type="date"');
} else {
body += row("Day of month", "day", it.day, 'type="number" min="1" max="31" inputmode="numeric"');
body += row("From <em>(optional)</em>", "anchor", it.anchor, 'type="date"');
body += row("To <em>(optional)</em>", "until", it.until, 'type="date"');
}
body += `<div class="sched-foot"><button type="button" data-sf="done">Done</button></div>`;
schedPop.innerHTML = body;
}
function openSchedPop(btn) {
schedTargetId = btn.dataset.id;
renderSchedPop();
schedPop.classList.remove("hidden");
const r = btn.getBoundingClientRect();
const pw = schedPop.offsetWidth, ph = schedPop.offsetHeight;
let left = Math.min(r.left, window.innerWidth - pw - 8);
let top = r.bottom + 6;
if (top + ph > window.innerHeight) top = Math.max(8, r.top - ph - 6); // flip above if no room below
schedPop.style.left = Math.max(8, left) + "px";
schedPop.style.top = Math.max(8, top) + "px";
}
function closeSchedPop() { schedPop.classList.add("hidden"); schedTargetId = null; }
// Live edits inside the popover propagate to the months and refresh the chip.
schedPop.addEventListener("input", e => {
const sf = e.target.dataset.sf;
if (!sf || sf === "done") return;
const it = schedItem();
if (!it) return;
it[sf] = e.target.value;
if (sf === "freq") renderSchedPop(); // swap to the new frequency's fields
syncRecurringItem(it, true); // schedule changed → regenerate this item's rows
renderMonths(); recompute();
const chip = recurringBody.querySelector(`.when-chip[data-id="${it.id}"]`);
if (chip) chip.innerHTML = `🗓 ${esc(whenSummary(it))}`;
scheduleSave();
});
schedPop.addEventListener("click", e => { if (e.target.closest('[data-sf="done"]')) closeSchedPop(); });
document.addEventListener("click", e => {
if (schedPop.classList.contains("hidden")) return;
if (schedPop.contains(e.target)) return;
if (e.target.closest && e.target.closest('[data-action="edit-sched"]')) return;
closeSchedPop();
});
document.addEventListener("keydown", e => { if (e.key === "Escape") closeSchedPop(); });
window.addEventListener("scroll", closeSchedPop, true);
/* ---------- recurring drag-and-drop reorder ---------- */
let recDragId = null;
function reorderRecurring(fromId, toId, after) {
const arr = state.recurring;
const from = arr.findIndex(x => x.id === fromId);
if (from < 0) return;
const [item] = arr.splice(from, 1);
let to = arr.findIndex(x => x.id === toId);
if (to < 0) { arr.push(item); return; }
if (after) to += 1;
arr.splice(to, 0, item);
}
function clearDropMarks() {
recurringBody.querySelectorAll("tr.drop-before, tr.drop-after").forEach(t => t.classList.remove("drop-before", "drop-after"));
}
function dropAfter(tr, clientY) {
const r = tr.getBoundingClientRect();
return clientY > r.top + r.height / 2;
}
recurringBody.addEventListener("mousedown", e => {
const h = e.target.closest(".drag-handle");
if (h) { const tr = h.closest("tr[data-recid]"); if (tr) tr.draggable = true; }
});
document.addEventListener("mouseup", () => {
if (!recDragId) recurringBody.querySelectorAll('tr[draggable="true"]').forEach(t => (t.draggable = false));
});
recurringBody.addEventListener("dragstart", e => {
const tr = e.target.closest("tr[data-recid]");
if (!tr) return;
recDragId = tr.dataset.recid;
tr.classList.add("dragging");
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; try { e.dataTransfer.setData("text/plain", recDragId); } catch (_) {} }
});
recurringBody.addEventListener("dragover", e => {
if (!recDragId) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
const tr = e.target.closest("tr[data-recid]");
clearDropMarks();
if (tr && tr.dataset.recid !== recDragId) tr.classList.add(dropAfter(tr, e.clientY) ? "drop-after" : "drop-before");
});
recurringBody.addEventListener("drop", e => {
if (!recDragId) return;
e.preventDefault();
const tr = e.target.closest("tr[data-recid]");
if (tr && tr.dataset.recid !== recDragId) reorderRecurring(recDragId, tr.dataset.recid, dropAfter(tr, e.clientY));
recDragId = null;
render();
scheduleSave();
});
recurringBody.addEventListener("dragend", () => {
recDragId = null;
clearDropMarks();
recurringBody.querySelectorAll('tr[draggable="true"]').forEach(t => (t.draggable = false));
});
/* ---------- lock screen ---------- */
function showLock(m) {
mode = m;
lock.classList.remove("hidden");
app.classList.add("hidden");
lockErr.textContent = "";
if (m === "create") {
lockMsg.textContent = "Create a passphrase to encrypt your ledger";
pass2.hidden = false;
lockBtn.textContent = "Create & open";
} else {
lockMsg.textContent = "Enter your passphrase to unlock";
pass2.hidden = true;
lockBtn.textContent = "Unlock";
}
pass1.focus();
renderLockFs(); // show Reconnect / Open-file actions (no-op without File System Access)
}
function unlockDone() {
pass1.value = "";
pass2.value = "";
lock.classList.add("hidden");
app.classList.remove("hidden");
render();
updateFileStatus();
}
lockForm.addEventListener("submit", async e => {
e.preventDefault();
if (lockBtn.disabled) return;
lockErr.textContent = "";
const label = lockBtn.textContent;
lockBtn.disabled = true;
lockBtn.textContent = mode === "create" ? "Creating…" : "Unlocking…";
try { await submitLock(); }
finally { lockBtn.disabled = false; if (lockBtn.textContent.endsWith("…")) lockBtn.textContent = label; }
});
async function submitLock() {
const p1 = pass1.value;
if (mode === "create") {
if (p1.length < 1) { lockErr.textContent = "Enter a passphrase"; return; }
if (p1 !== pass2.value) { lockErr.textContent = "Passphrases do not match"; return; }
currentSalt = crypto.getRandomValues(new Uint8Array(16));
cryptoKey = await deriveKey(p1, currentSalt);
book = defaultBook(); syncActive();
await save();
unlockDone();
} else {
const stored = loadStored();
if (!stored) { showLock("create"); return; }
currentSalt = b64decode(stored.salt);
try {
const key = await deriveKey(p1, currentSalt);
book = migrateToBook(await decryptBlob(stored, key));
normalizeBook(); syncActive();
cryptoKey = key;
unlockDone();
} catch (err) {
lockErr.textContent = "Wrong passphrase";
}
}
}
/* ---------- toolbar ---------- */
document.getElementById("btn-lock").addEventListener("click", () => {
wipeKey(cryptoKey);
cryptoKey = null;
book = null;
state = null;
fileHandle = null; // require a fresh gesture-based reconnect next session
if (linkBtn) linkBtn.hidden = true;
showLock("unlock");
});
/* ---------- header "More" menu (Export / Backup / Restore / Change passphrase) ---------- */
const toolsMenu = document.getElementById("tools-menu");
const moreBtn = document.getElementById("btn-more");
const menuPop = toolsMenu && toolsMenu.querySelector(".menu-pop");
function closeMenu() {
if (!toolsMenu) return;
toolsMenu.classList.remove("open");
if (moreBtn) moreBtn.setAttribute("aria-expanded", "false");
if (menuPop) menuPop.hidden = true;
}
if (moreBtn) moreBtn.addEventListener("click", e => {
e.stopPropagation();
const open = toolsMenu.classList.toggle("open");
moreBtn.setAttribute("aria-expanded", open ? "true" : "false");
if (menuPop) menuPop.hidden = !open;
});
if (menuPop) menuPop.addEventListener("click", e => { if (e.target.closest("button")) closeMenu(); }); // run the item, then close
document.addEventListener("click", e => { if (toolsMenu && toolsMenu.classList.contains("open") && !toolsMenu.contains(e.target)) closeMenu(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") closeMenu(); });
/* ---------- sticky month headers tuck under the global header ---------- */
const headerEl = document.querySelector("header");
function syncHeaderHeight() {
const root = document.documentElement.style;
if (headerEl) root.setProperty("--header-h", headerEl.offsetHeight + "px");
const mh = monthsBody.querySelector(".month-head"); // month strips are all the same height
if (mh) root.setProperty("--monthhead-h", mh.offsetHeight + "px");
if (typeof observeStickyHeads === "function") observeStickyHeads(); // re-arm with the new offsets
}
if (window.ResizeObserver && headerEl) new ResizeObserver(syncHeaderHeight).observe(headerEl);
window.addEventListener("resize", syncHeaderHeight);
syncHeaderHeight();
/* ---------- theme (UI preference, stored unencrypted) ---------- */
const THEME_KEY = "money-ledger-theme";
const themeBtn = document.getElementById("btn-theme");
function currentTheme() {
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
}
function applyThemeIcon() {
const dark = currentTheme() === "dark";
themeBtn.textContent = dark ? "☀️" : "🌙";
themeBtn.title = dark ? "Switch to light theme" : "Switch to dark theme";
}
themeBtn.addEventListener("click", () => {
const next = currentTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try { localStorage.setItem(THEME_KEY, next); } catch (e) { /* ignore */ }
applyThemeIcon();
});
applyThemeIcon();
currencyEl.addEventListener("input", () => {
if (!state) return;
state.currency = currencyEl.value;
recompute();
scheduleSave();
});
document.getElementById("btn-passphrase").addEventListener("click", async () => {
if (!state) return;
const np = prompt("New passphrase:");
if (np == null) return;
if (np.length < 1) { alert("Passphrase cannot be empty"); return; }
if (prompt("Confirm new passphrase:") !== np) { alert("Passphrases do not match"); return; }
currentSalt = crypto.getRandomValues(new Uint8Array(16));
cryptoKey = await deriveKey(np, currentSalt);
await save();
alert("Passphrase changed.");
});
function todayStamp() {
// avoids locale surprises in filenames
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function downloadFile(name, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
/* ---------- budget chooser (shared by Export CSV + Backup) ---------- */
const budgetModal = document.createElement("div");
budgetModal.className = "overlay modal-overlay hidden";
budgetModal.innerHTML =
`<div class="modal">
<h3 class="modal-title"></h3>
<p class="modal-sub">Tick the budgets to include.</p>
<div class="modal-list"></div>
<div class="modal-actions">
<button type="button" class="modal-allnone" data-mc="toggle"></button>
<span class="modal-spacer"></span>
<button type="button" data-mc="cancel">Cancel</button>
<button type="button" class="primary" data-mc="ok"></button>
</div>
</div>`;
document.body.appendChild(budgetModal);
const modalTitle = budgetModal.querySelector(".modal-title");
const modalList = budgetModal.querySelector(".modal-list");
const modalOk = budgetModal.querySelector('[data-mc="ok"]');
const modalCancel = budgetModal.querySelector('[data-mc="cancel"]');
const modalToggle = budgetModal.querySelector('[data-mc="toggle"]');
// Show the chooser; resolves to an array of selected budget ids, or null if cancelled.
function openBudgetChooser(title, okLabel) {
return new Promise(resolve => {
modalTitle.textContent = title;
modalOk.textContent = okLabel;
modalList.innerHTML = book.budgets.map(b =>
`<label class="modal-row"><input type="checkbox" value="${b.id}" checked><span>${esc(b.name) || "Untitled"}</span></label>`
).join("");
const checks = () => [...modalList.querySelectorAll("input")];
const syncToggle = () => { modalToggle.textContent = checks().some(c => c.checked) ? "Select none" : "Select all"; };
syncToggle();
budgetModal.classList.remove("hidden");
const finish = val => {
budgetModal.classList.add("hidden");
modalOk.onclick = modalCancel.onclick = modalToggle.onclick = modalList.onchange = null;
resolve(val);
};
modalList.onchange = syncToggle;
modalToggle.onclick = () => { const all = checks().every(c => c.checked); checks().forEach(c => c.checked = !all); syncToggle(); };
modalOk.onclick = () => { const ids = checks().filter(c => c.checked).map(c => c.value); if (ids.length) finish(ids); };
modalCancel.onclick = () => finish(null);
});
}
budgetModal.addEventListener("click", e => { if (e.target === budgetModal) modalCancel.click(); });
document.getElementById("btn-backup").addEventListener("click", async () => {
if (!book || !cryptoKey) { alert("Unlock first."); return; }
const ids = await openBudgetChooser("Back up budgets", "Back up");
if (!ids) return;
const all = ids.length === book.budgets.length;
const subset = {
version: 2,
activeId: ids.includes(book.activeId) ? book.activeId : ids[0],
budgets: book.budgets.filter(b => ids.includes(b.id)).map(deepClone),
};
const blob = await encryptObj(subset, cryptoKey, currentSalt);
const tag = all ? "" : `-${ids.length}of${book.budgets.length}`;
downloadFile(`libreledger-backup${tag}-${todayStamp()}.json`, JSON.stringify(blob), "application/json");
});
document.getElementById("btn-restore").addEventListener("click", () => {
document.getElementById("file-restore").click();
});
document.getElementById("file-restore").addEventListener("change", async e => {
const f = e.target.files[0];
if (!f) return;
e.target.value = "";
let obj;
try {
obj = JSON.parse(await f.text());
if (!obj.salt || !obj.iv || !obj.ct) throw new Error("bad");
} catch (err) { alert("That doesn't look like a valid backup file."); return; }
// Locked: nothing decrypted in memory to merge into.
if (!cryptoKey || !book) {
if (localStorage.getItem(STORAGE_KEY)) {
alert("Unlock first, then Restore — that lets the backup merge with your existing budgets instead of replacing them.");
return;
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(obj)); // fresh browser, nothing to lose
alert("Backup loaded. Unlock with that backup's passphrase.");
location.reload();
return;
}
// Unlocked: decrypt the incoming file (same passphrase, else prompt) and MERGE its budgets in.
let incoming = null;
try { incoming = await decryptBlob(obj, cryptoKey); }
catch (_) {
const pw = prompt("Passphrase for this backup file:");
if (pw == null) return;
try { incoming = await decryptBlob(obj, await deriveKey(pw, b64decode(obj.salt))); }
catch (e2) { alert("Wrong passphrase for that backup."); return; }
}
let added = 0, updated = 0;
migrateToBook(incoming).budgets.forEach(nb => {
const existing = book.budgets.find(x => x.id === nb.id);
if (existing) { Object.assign(existing, nb); updated++; }
else { book.budgets.push(nb); added++; }
});
normalizeBook();
syncActive();
render();
await save();
alert(`Restored: ${added} budget${added === 1 ? "" : "s"} added, ${updated} updated.`);
});
function csv(s) {
s = String(s == null ? "" : s);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
// Build the CSV lines for a single budget.
function budgetCsv(b) {
const lines = [];
lines.push("Balances");
lines.push("Account,Balance");
let accTotal = 0;
b.accounts.forEach(a => { accTotal += num(a.balance); lines.push(`${csv(a.name)},${num(a.balance)}`); });
lines.push(`Total,${accTotal}`);
lines.push("");
if (b.recurring.length) {
lines.push("Recurring");
lines.push("Description,In/Out,Amount,Frequency,When");
b.recurring.forEach(it => {
const bounds = (it.anchor || it.until) ? ` (${it.anchor || "…"} to ${it.until || "…"})` : "";
const when = it.freq === "daily" ? `every ${it.every} days from ${it.anchor || "…"}${it.until ? ` to ${it.until}` : ""}`
: it.freq === "weekly" ? `every ${it.every} wks from ${it.anchor}${it.until ? ` to ${it.until}` : ""}`
: it.freq === "yearly" ? `${it.ymonth}/${it.yday}${bounds}` : `day ${it.day}${bounds}`;
lines.push(`${csv(it.desc)},${it.dir === "in" ? "In" : "Out"},${num(it.amount)},${it.freq},${csv(when)}`);
});
lines.push("");
}
let carry = accTotal;
b.months.forEach(m => {
lines.push(csv(m.title || "Month"));
lines.push("Date,Type,Description,In,Out,Balance");
let bal = carry;
lines.push(`,,Opening balance,,,${bal}`);
m.rows.forEach(r => {
const inc = num(r.inc), out = num(r.out);
bal += inc - out;
const type = inc > 0 && out === 0 ? "Money" : out > 0 && inc === 0 ? "Cost" : (inc || out ? "Mixed" : "");
lines.push(`${csv(r.date)},${type},${csv(r.desc)},${inc},${out},${bal}`);
});
carry = bal;
lines.push("");
});
return lines;
}
document.getElementById("btn-csv").addEventListener("click", async () => {
if (!book) return;
const ids = await openBudgetChooser("Export to CSV", "Export");
if (!ids) return;
const sel = book.budgets.filter(b => ids.includes(b.id));
const multi = sel.length > 1;
const lines = [];
sel.forEach(b => {
if (multi) { lines.push(`# Budget: ${csv(b.name)}`); lines.push(""); }
lines.push(...budgetCsv(b));
});
const tag = sel.length === book.budgets.length ? "" : `-${sel.length}budget${sel.length === 1 ? "" : "s"}`;
downloadFile(`libreledger${tag}-${todayStamp()}.csv`, lines.join("\n"), "text/csv");
});
/* ---------- PDF export (print the chosen budgets as a clean document) ----------
Reuse the live render pipeline: point `state` at each chosen budget, render it,
and snapshot every section's HTML into #print-root. It's all synchronous, so the
page never visibly flickers — the browser only repaints once we've restored the
user's budget and called print(). The PDF therefore shows exactly the figures the
app computed (balances, totals, affordability), every section regardless of the
on-screen tab, with interactive chrome stripped by the @media print rules in CSS.
The actual "save as PDF" is the browser's own print dialog (Destination → PDF). */
let printRoot = null;
// Outer HTML of the chosen sections (in their normal order) for the active budget.
function snapshotSectionsHTML(keys) {
return SECTIONS.filter(s => keys.includes(s.key)).map(s => {
const panel = document.querySelector(`.panel[data-section="${s.key}"]`);
return panel ? panel.outerHTML : "";
}).join("");
}
function buildPrintReport(ids, sectionKeys) {
const budgets = book.budgets.filter(b => ids.includes(b.id));
const prevActive = book.activeId;
const blocks = budgets.map(b => {
book.activeId = b.id; syncActive(); render(); // render() ends with recompute(), all synchronous
return `<section class="print-budget">
<h1 class="print-bname">${esc(b.name) || "Untitled budget"}</h1>
${snapshotSectionsHTML(sectionKeys)}
</section>`;
}).join("");
book.activeId = prevActive; syncActive(); render(); // restore the user's view exactly
const scope = budgets.length === book.budgets.length ? "All budgets"
: budgets.length === 1 ? (esc(budgets[0].name) || "Budget")
: `${budgets.length} budgets`;
const secNote = sectionKeys.length < SECTIONS.length ? ` · ${sectionKeys.length} of ${SECTIONS.length} sections` : "";
return `<div class="print-head">
<span class="print-brand">£ LibreLedger</span>
<span class="print-meta">${scope}${secNote} · ${todayStamp()}</span>
</div>${blocks}`;
}
function exportPdf(ids, sectionKeys) {
if (!printRoot) { printRoot = document.createElement("div"); printRoot.id = "print-root"; document.body.appendChild(printRoot); }
printRoot.innerHTML = buildPrintReport(ids, sectionKeys);
const cleanup = () => { printRoot.innerHTML = ""; window.removeEventListener("afterprint", cleanup); };
window.addEventListener("afterprint", cleanup);
window.print(); // browser dialog → choose "Save to PDF"
}
/* The PDF chooser: pick which budgets AND which sections to print, in one dialog.
Resolves to { budgetIds, sectionKeys }, or null if cancelled. Each list must
keep at least one tick — Export greys out otherwise. */
const pdfModal = document.createElement("div");
pdfModal.className = "overlay modal-overlay hidden";
pdfModal.innerHTML =
`<div class="modal">
<h3 class="modal-title">Export to PDF</h3>
<p class="modal-sub">Choose the budgets and sections to include.</p>
<div class="modal-group">
<div class="modal-group-head"><span>Budgets</span><button type="button" class="modal-allnone" data-pdf-all="budgets"></button></div>
<div class="modal-list" data-pdf-list="budgets"></div>
</div>
<div class="modal-group">
<div class="modal-group-head"><span>Sections</span><button type="button" class="modal-allnone" data-pdf-all="sections"></button></div>
<div class="modal-list" data-pdf-list="sections"></div>
</div>
<div class="modal-actions">
<span class="modal-spacer"></span>
<button type="button" data-pdf="cancel">Cancel</button>
<button type="button" class="primary" data-pdf="ok">Export</button>
</div>
</div>`;
document.body.appendChild(pdfModal);
function openPdfChooser() {
return new Promise(resolve => {
const lists = {
budgets: pdfModal.querySelector('[data-pdf-list="budgets"]'),
sections: pdfModal.querySelector('[data-pdf-list="sections"]'),
};
lists.budgets.innerHTML = book.budgets.map(b =>
`<label class="modal-row"><input type="checkbox" value="${b.id}" checked><span>${esc(b.name) || "Untitled"}</span></label>`).join("");
lists.sections.innerHTML = SECTIONS.map(s =>
`<label class="modal-row"><input type="checkbox" value="${s.key}" checked><span>${s.icon} ${esc(s.label)}</span></label>`).join("");
const okBtn = pdfModal.querySelector('[data-pdf="ok"]');
const cancelBtn = pdfModal.querySelector('[data-pdf="cancel"]');
const allBtn = grp => pdfModal.querySelector(`[data-pdf-all="${grp}"]`);
const checks = grp => [...lists[grp].querySelectorAll("input")];
const picked = grp => checks(grp).filter(c => c.checked).map(c => c.value);
const refresh = () => {
["budgets", "sections"].forEach(grp => { allBtn(grp).textContent = checks(grp).every(c => c.checked) ? "None" : "All"; });
okBtn.disabled = !(picked("budgets").length && picked("sections").length);
};
refresh();
pdfModal.classList.remove("hidden");
const finish = val => {
pdfModal.classList.add("hidden");
okBtn.onclick = cancelBtn.onclick = pdfModal.onclick = lists.budgets.onchange = lists.sections.onchange = null;
allBtn("budgets").onclick = allBtn("sections").onclick = null;
resolve(val);
};
lists.budgets.onchange = lists.sections.onchange = refresh;
["budgets", "sections"].forEach(grp => {
allBtn(grp).onclick = () => { const all = checks(grp).every(c => c.checked); checks(grp).forEach(c => c.checked = !all); refresh(); };
});
okBtn.onclick = () => { if (picked("budgets").length && picked("sections").length) finish({ budgetIds: picked("budgets"), sectionKeys: picked("sections") }); };
cancelBtn.onclick = () => finish(null);
pdfModal.onclick = e => { if (e.target === pdfModal) finish(null); };
});
}
document.getElementById("btn-pdf").addEventListener("click", async () => {
if (!book) return;
const choice = await openPdfChooser();
if (!choice) return;
exportPdf(choice.budgetIds, choice.sectionKeys);
});
/* ---------- wire up + boot ---------- */
app.addEventListener("input", onInput);
app.addEventListener("change", onChange);
app.addEventListener("click", onClick);
async function init() {
if (!HAS_SUBTLE) {
// Not a secure context (plain http to a LAN/VPN address): use the built-in fallback.
let ok = !!(window.crypto && crypto.getRandomValues);
if (ok) { try { await loadFallbackCrypto(); } catch (e) { ok = false; } }
if (!ok) {
lockMsg.textContent =
"This connection isn't secure and the built-in encryption couldn't load. Open LibreLedger over HTTPS or http://localhost.";
pass1.hidden = true;
pass2.hidden = true;
lockBtn.hidden = true;
return;
}
const warn = document.getElementById("lock-warn");
if (warn) {
warn.textContent =
"Not a secure connection (plain http), so your browser's own encryption is switched off. " +
"LibreLedger is using its built-in encryption instead: same format, a few seconds slower to unlock. " +
"Use HTTPS where you can.";
warn.hidden = false;
}
}
if (linkBtn) linkBtn.hidden = true; // only relevant once unlocked
await bootLoadFromServer(); // server.py? pull the on-disk ledger into the cache
// A remembered save file means we can offer "Reconnect" even with no localStorage copy yet.
const h = await fsGet();
rememberedName = h ? h.name : null;
showLock(loadStored() || h ? "unlock" : "create");
}
init();