"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, "&").replace(/"/g, """) .replace(//g, ">"); } 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/.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 `🏦`; if (b.logo) return ``; return `${esc(bankShort(b))}`; } /* ---------- 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(``); parts.push(``); 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 = `
` + `` + SECTIONS.map(s => ``).join("") + `
` + ``; // 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 = `Hidden` + hidden.map(s => ``).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 ``; }).join(""); const multi = book.budgets.length > 1; budgetBar.innerHTML = `
${tabs}
${multi ? `` : ""}
`; } // 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]) => ``).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 ` ${withStepper(``)} `; }).join(""); const empty = `No recurring items yet β€” add the bills & income that repeat, and they'll flow into your months. πŸ”`; recurringBody.innerHTML = `
${rows || empty}
Description In / Out Amount Schedule
${state.recurring.length ? `` : ""}
`; } // Wrap a number with custom rounded β–²/β–Ό stepper buttons. function withStepper(inputHTML) { return `${inputHTML}` + `` + `` + ``; } function renderAccounts() { const rows = state.accounts.map(a => `
${withStepper(``)} `).join(""); accountsBody.innerHTML = `
${rows}
AccountBalance
Total balance
`; } // 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 ``; } 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 `${day || "β€”"}${ord}`; } return `` + `${ord}`; } 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 ? `` : ``; return `${withStepper(input)}`; }; return ` ${locked ? `πŸ”’` : ``} ${dateCellHTML(m, r, locked)} ${amtCell("in", "inc", r.inc)} ${amtCell("out", "out", r.out)} `; }).join(""); return `
${monthHeadHTML(m, idx)} ${rows}
Date Description In Out Balance
Month totals · net · 🐷 saved Money in Money out Closing balance
`; } // Shared month header (title, calendar month, opening, delete) + the per-category summary chips strip. function monthHeadHTML(m, idx) { const openingLabel = idx === 0 ? "Opening · from balances" : "Opening · 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 `
${isPastYm(m.ym) ? `πŸ•“ Past` : ""}
`; } // 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 = ``; const catChips = groups.map(g => { const income = g.inSum >= g.outSum; const icon = g.icon || ""; const active = !!(sel && sel.has(icon)); return ``; }).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 `
${groupedDayLabel(r)} ${esc(r.desc) || "β€”"} ${num(r.inc) ? "+" : "βˆ’"}${fmt(amt)}
`; }).join(""); return `
${open ? `
${entries}
` : ""}
`; }).join(""); return `
${monthHeadHTML(m, idx)}
${body || `
No entries yet β€” switch to Chronological to add some.
`}
🐷 Saved In Out Closing
Read-only overview β€” switch to πŸ“‹ Chronological to add or edit entries.
`; } let grouped = false; // global view mode: chronological (edit) vs grouped (overview) const collapsedGroups = new Set(); // "|" keys that are collapsed in grouped view function renderMonths() { const render = grouped ? monthGroupedHTML : monthHTML; monthsBody.innerHTML = state.months.map(render).join("") + `
or generate months ahead
`; 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 = `
Saved to date
Avg / month
Savings rate
Best month
Saved / month Cumulative total
`; } 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; 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)}`; }).join(""); const linePts = series.map((s, i) => `${cx(i).toFixed(1)},${yLine(s.cumulative).toFixed(1)}`).join(" "); const dots = series.map((s, i) => `` + `${esc(s.label)} β€” running total ${fmt(s.cumulative)}`).join(""); const labels = series.map((s, i) => showLab(i) ? `${esc(s.label)}` : "").join(""); return `${baseline}${bars}` + `${dots}${labels}`; } /* ---------- 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 = `
Annual income πŸ’°
Income / month
Housing / month 🏠
Of income on housing
`; } // 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 πŸ’° Income and housing costs with 🏠 Housing to see your affordability here.` : !monthlyHousing ? `Add some 🏠 Housing costs and we'll measure them against your income.` : `Tag your πŸ’° Income 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 ${fmt(guideCost)}/mo sits on the ~30% guide Β· ${fmt(headroom)} to spare` : `Around ${fmt(guideCost)}/mo sits on the ~30% guide Β· ${fmt(-headroom)} above it`; } } /* ---------- 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 => ` `).join(""); totalsBody.innerHTML = `
${cards}
` + ``; } 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 = ``; 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 ${fmt(total)} to date` + (active ? `  Β·  ${fmt(total / active)}/mo average` : ""); } else { sub = `Planned ${fmt(annual)}/yr Β· ${fmt(annual / 12)}/mo from recurring` + (series.length ? `  Β·  actual average ${fmt(avg)}/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 + each bar's sign. function metricChartSVG(series, cfg, avg) { if (!series.length) { return `
Add some months in your 🌊 Ledger to chart this.
`; } if (series.every(p => p.value === 0)) { return `
No ${esc(cfg.label)} recorded in your ledger months yet.
`; } 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 = ``; 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 `` + `` + `${esc(p.label)} β€” ${fmt(p.value)}`; }).join(""); const avgY = y(avg); const avgLine = avg !== 0 ? `` + `avg ${fmt(avg)}` : ""; const labels = series.map((p, i) => showLab(i) ? `${esc(p.label)}` : "").join(""); return `${baseline}${bars}${avgLine}${labels}`; } /* ---------- 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 = `