Files
tally/app.js

688 lines
26 KiB
JavaScript

/* Tally: daily / weekly / monthly views + repeating expenses.
Stored privately in localStorage; no network calls. */
"use strict";
/* Pastel chart/dot colors for every category. */
var CATS = {
Food: "#9ed6a4",
Transport: "#a9c8f0",
Household: "#eab88f",
Utilities: "#8fd8c4",
Shopping: "#f5b98a",
Health: "#f2a3a3",
Beauty: "#dba9e8",
Entertainment: "#f2cd88",
Other: "#c9c9c9"
};
var FREQ_LABEL = { once: "One-time", daily: "Daily", weekly: "Weekly", monthly: "Monthly" };
var STORE_KEY = "expense-tracker.expenses.v1";
var state = { tab: "daily", q: "", cat: "all", freq: "all", editingId: null, screen: "add", selMonth: null, selYear: null, selWeek: null, selDate: null };
var MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/* All amounts display in INR. */
var money = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" });
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function uid() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function pad(n) { return (n < 10 ? "0" : "") + n; }
function toStr(d) {
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
}
function todayStr() { return toStr(new Date()); }
function parseDay(s) {
var p = String(s).split("-");
return new Date(+p[0], +p[1] - 1, +p[2]);
}
function addDaysStr(s, n) {
var d = parseDay(s);
d.setDate(d.getDate() + n);
return toStr(d);
}
function fmtDay(s) {
return parseDay(s).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function fmtWeekday(s) {
return parseDay(s).toLocaleDateString("en-US", { weekday: "long" });
}
/* Weeks start on Monday; the key is the Monday date string. */
function mondayOf(s) {
var d = parseDay(s);
var shift = (d.getDay() + 6) % 7;
d.setDate(d.getDate() - shift);
return toStr(d);
}
function fmtWeekRange(monday) {
var a = parseDay(monday);
var b = new Date(a);
b.setDate(b.getDate() + 6);
var sameMonth = a.getMonth() === b.getMonth();
var left = a.toLocaleDateString("en-US", { month: "short", day: "numeric" });
var right = b.toLocaleDateString("en-US", sameMonth ? { day: "numeric", year: "numeric" } : { month: "short", day: "numeric", year: "numeric" });
return left + " \u2013 " + right;
}
function fmtMonth(key) {
var p = key.split("-");
return new Date(+p[0], +p[1] - 1, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" });
}
function nextDue(t) {
var base = t.lastLogged || t.date;
if (t.frequency === "daily") return addDaysStr(base, 1);
if (t.frequency === "weekly") return addDaysStr(base, 7);
var d = parseDay(base);
var day = d.getDate();
d.setDate(1);
d.setMonth(d.getMonth() + 1);
var last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(day, last));
return toStr(d);
}
/* ---- storage ---- */
function load() {
try {
var raw = localStorage.getItem(STORE_KEY);
if (raw === null) {
var seed = seedSamples();
localStorage.setItem(STORE_KEY, JSON.stringify(seed));
return seed;
}
var arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
/* Migrate the Housing -> Household rename in stored entries. */
var changed = false;
arr.forEach(function (e) {
if (e && e.category === "Housing") { e.category = "Household"; changed = true; }
});
if (changed) { try { save(arr); } catch (err) { /* keep the in-memory list */ } }
return arr;
} catch (e) {
return [];
}
}
function save(list) {
localStorage.setItem(STORE_KEY, JSON.stringify(list));
}
/* Sample data relative to today so every view has content on first run. */
function seedSamples() {
var t = todayStr();
var first = t.slice(0, 8) + "01";
function mk(desc, amount, cat, date, freq) {
return { id: uid(), description: desc, amount: amount, category: cat, date: date, frequency: freq || "once", lastLogged: null, createdAt: Date.now() };
}
return [
mk("Groceries", 54.2, "Food", t),
mk("Bus pass", 28.0, "Transport", t, "monthly"),
mk("Coffee", 4.5, "Food", addDaysStr(t, -1)),
mk("Pharmacy", 12.75, "Health", addDaysStr(t, -2)),
mk("Electricity bill", 85.4, "Utilities", addDaysStr(t, -3), "monthly"),
mk("Movie night", 16.0, "Entertainment", addDaysStr(t, -5)),
mk("Gym", 35.0, "Health", addDaysStr(t, -6), "monthly"),
mk("New shoes", 79.99, "Shopping", addDaysStr(t, -8)),
mk("Team lunch", 13.4, "Food", addDaysStr(t, -9), "weekly"),
mk("Internet", 59.99, "Utilities", addDaysStr(t, -12), "monthly"),
mk("Rent", 1200.0, "Household", first, "monthly"),
mk("Concert ticket", 45.0, "Entertainment", addDaysStr(t, -20)),
mk("Groceries", 62.1, "Food", addDaysStr(t, -33))
];
}
/* ---- filtering / grouping ---- */
function filtered(list) {
var q = state.q.trim().toLowerCase();
return list.filter(function (e) {
if (state.tab === "daily") {
if (!state.selDate) return false;
if (e.date !== state.selDate) return false;
}
if (state.tab === "monthly") {
if (!state.selMonth) return false;
if (e.date.slice(0, 7) !== state.selMonth) return false;
}
if (state.tab === "weekly") {
if (!state.selWeek) return false;
if (mondayOf(e.date) !== state.selWeek) return false;
}
if (state.cat !== "all" && e.category !== state.cat) return false;
if (state.freq !== "all" && e.frequency !== state.freq) return false;
if (q && (e.description + " " + e.category).toLowerCase().indexOf(q) === -1) return false;
return true;
});
}
var TAB_TITLES = { daily: "Daily", weekly: "Weekly", monthly: "Monthly", all: "All expenses" };
function groupKey(e) {
if (state.tab === "all") return "all";
return e.date;
}
function groupTitle(key) {
if (state.tab === "all") return "All expenses";
if (state.tab === "daily" || state.tab === "weekly" || state.tab === "monthly") {
var label = fmtDay(key);
if (key === todayStr()) label += " (today)";
else if (key === addDaysStr(todayStr(), -1)) label += " (yesterday)";
return label + " \u00b7 " + fmtWeekday(key);
}
return fmtMonth(key);
}
/* ---- rendering ---- */
function badge(freq) {
if (freq === "once") return "";
return '<span class="badge ' + freq + '">' + FREQ_LABEL[freq] + "</span>";
}
function renderStats(list) {
var t = todayStr(), wk = mondayOf(t), mo = t.slice(0, 7);
var d = 0, w = 0, m = 0;
list.forEach(function (e) {
if (e.date === t) d += e.amount;
if (mondayOf(e.date) === wk) w += e.amount;
if (e.date.slice(0, 7) === mo) m += e.amount;
});
$("statToday").textContent = money.format(d);
$("statWeek").textContent = money.format(w);
$("statMonth").textContent = money.format(m);
}
function renderSummary(list) {
var total = 0, byCat = {};
list.forEach(function (e) {
total += e.amount;
byCat[e.category] = (byCat[e.category] || 0) + e.amount;
});
var scope = state.tab === "all" ? "all time" : "day";
$("sumTitle").textContent = state.tab === "all" ? "All expenses" :
(state.tab === "monthly" && state.selMonth ? fmtMonth(state.selMonth) :
(state.tab === "weekly" && state.selWeek ? "Week of " + fmtWeekRange(state.selWeek) :
(state.tab === "daily" && state.selDate ? fmtDay(state.selDate) :
TAB_TITLES[state.tab] + " summary")));
$("sumLine").textContent = list.length + " shown \u00b7 " + money.format(total) + " total, grouped by " + scope;
var box = $("bars");
var names = Object.keys(byCat).sort(function (a, b) { return byCat[b] - byCat[a]; });
if (!names.length) {
box.innerHTML = '<p class="empty-note">Nothing matches the current filters.</p>';
return;
}
var max = byCat[names[0]];
box.innerHTML = names.map(function (n) {
var pct = Math.max(2, Math.round((byCat[n] / max) * 100));
return '<div class="bar-row"><span class="bar-name">' + esc(n) + '</span>' +
'<span class="bar-track"><span class="bar-fill" style="display:block;width:' + pct + "%;background:" + (CATS[n] || "#c9c9c9") + '"></span></span>' +
'<span class="bar-amt">' + money.format(byCat[n]) + "</span></div>";
}).join("");
}
function renderList(list) {
var groups = {};
list.forEach(function (e) {
var k = groupKey(e);
(groups[k] = groups[k] || []).push(e);
});
var keys = Object.keys(groups).sort().reverse();
var box = $("list");
if (!keys.length) {
box.innerHTML = '<div class="card"><p class="empty-note">No expenses yet. Add your first one on the left.</p></div>';
return;
}
box.innerHTML = keys.map(function (k) {
var items = groups[k].sort(function (a, b) {
return a.date < b.date ? 1 : (a.date > b.date ? -1 : b.createdAt - a.createdAt);
});
var total = items.reduce(function (s, e) { return s + e.amount; }, 0);
var rows = items.map(function (e) {
return '<li class="item"><span class="dot" style="background:' + (CATS[e.category] || "#c9c9c9") + '"></span>' +
'<div class="item-main"><div class="item-desc">' + esc(e.description) + badge(e.frequency) + "</div>" +
'<div class="item-meta"><span class="sub">' + esc(e.category) + "</span> \u00b7 " + fmtDay(e.date) + "</div></div>" +
'<span class="item-amt">' + money.format(e.amount) + "</span>" +
'<span class="item-ops">' +
'<button type="button" class="icon-btn" data-edit="' + e.id + '" title="Edit" aria-label="Edit ' + esc(e.description) + '">' +
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3l4 4L8 20l-5 1 1-5z"/></svg></button>' +
'<button type="button" class="icon-btn" data-del="' + e.id + '" title="Delete" aria-label="Delete ' + esc(e.description) + '">' +
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4h8v2m-9 0l1 14h8l1-14"/></svg></button>' +
"</span></li>";
}).join("");
return '<div class="group"><div class="group-head"><h3>' + esc(groupTitle(k)) + "</h3>" +
'<span class="group-total">' + money.format(total) + "</span></div>" +
'<ul class="items">' + rows + "</ul></div>";
}).join("");
}
function renderTemplates(list) {
var temps = list.filter(function (e) { return e.frequency !== "once"; })
.sort(function (a, b) { return nextDue(a) < nextDue(b) ? -1 : 1; });
var box = $("templates");
if (!temps.length) {
box.innerHTML = '<li class="empty-note">None yet. Set Repeats to Daily, Weekly or Monthly when adding an expense.</li>';
return;
}
box.innerHTML = temps.map(function (t) {
return '<li class="template"><div class="template-top"><strong>' + esc(t.description) + "</strong>" +
"<span>" + money.format(t.amount) + "</span></div>" +
'<div class="template-next"><span class="sub">' + esc(t.category) + "</span> \u00b7 repeats " + FREQ_LABEL[t.frequency].toLowerCase() +
" \u00b7 next " + fmtDay(nextDue(t)) + "</div>" +
'<button type="button" class="btn subtle small" data-log="' + t.id + '">Log next occurrence</button></li>';
}).join("");
}
/* View header: big title + date subline, like the Tasks Today view. */
function renderViewHead(list, view) {
var t = todayStr();
var total = view.reduce(function (s, e) { return s + e.amount; }, 0);
$("viewTitle").textContent = TAB_TITLES[state.tab];
var sub;
if (state.tab === "daily") {
sub = state.selDate ? fmtDay(state.selDate) + " \u00b7 " + fmtWeekday(state.selDate) : "Choose a date below";
} else if (state.tab === "weekly") {
sub = state.selWeek ? "Week of " + fmtWeekRange(state.selWeek) : "Choose a week below";
} else if (state.tab === "monthly") {
sub = state.selMonth ? fmtMonth(state.selMonth) : "Choose a month below";
} else {
sub = list.length + (list.length === 1 ? " expense" : " expenses") + " in total";
}
var needPick = (state.tab === "daily" && !state.selDate) ||
(state.tab === "weekly" && !state.selWeek) ||
(state.tab === "monthly" && !state.selMonth);
if (state.tab !== "all" && !needPick) {
sub += " \u00b7 " + money.format(total) + " shown";
} else if (state.tab === "all") {
sub += " \u00b7 " + money.format(total);
}
$("viewSub").textContent = sub;
}
/* Sidebar nav counts + category list, like the Tasks rail. */
function renderNav(list) {
var t = todayStr(), wk = mondayOf(t), mo = t.slice(0, 7);
var c = { daily: 0, weekly: 0, monthly: 0, all: list.length };
var byCat = {};
list.forEach(function (e) {
if (e.date === t) c.daily += 1;
if (mondayOf(e.date) === wk) c.weekly += 1;
if (e.date.slice(0, 7) === mo) c.monthly += 1;
byCat[e.category] = (byCat[e.category] || 0) + 1;
});
$("countDaily").textContent = c.daily;
$("countWeekly").textContent = c.weekly;
$("countMonthly").textContent = c.monthly;
$("countAll").textContent = c.all;
var names = Object.keys(CATS);
var html = '<li><button type="button" class="cat-item' + (state.cat === "all" ? " active" : "") +
'" data-cat="all"><span class="dot" style="background:#c9c9c9"></span><span>All categories</span>' +
'<span class="count">' + list.length + "</span></button></li>";
html += names.map(function (n) {
return '<li><button type="button" class="cat-item' + (state.cat === n ? " active" : "") +
'" data-cat="' + esc(n) + '"><span class="dot" style="background:' + (CATS[n] || "#c9c9c9") + '"></span>' +
"<span>" + esc(n) + "</span>" +
'<span class="count">' + (byCat[n] || 0) + "</span></button></li>";
}).join("");
$("catList").innerHTML = html;
var m = 0;
list.forEach(function (e) { if (e.date.slice(0, 7) === mo) m += e.amount; });
$("sideMonth").textContent = money.format(m);
$("topMonth").textContent = money.format(m);
}
/* Landing shows only the add form; records live behind the menu drawer. */
var RECORD_SECTIONS = ["viewHead", "statsSection", "sumCard", "listSection", "templatesCard"];
/* Calendar-like month picker for the Monthly view. */
function renderMonthPicker(list) {
var box = $("monthPicker");
if (state.tab !== "monthly") { box.innerHTML = ""; return; }
if (!state.selYear) state.selYear = todayStr().slice(0, 4);
var counts = {};
list.forEach(function (e) {
var k = e.date.slice(0, 7);
counts[k] = (counts[k] || 0) + 1;
});
var y = state.selYear;
var cells = MONTHS_SHORT.map(function (name, i) {
var k = y + "-" + (i < 9 ? "0" : "") + (i + 1);
return '<button type="button" class="mp-cell' + (state.selMonth === k ? " active" : "") + '" data-m="' + k + '">' +
"<span>" + name + "</span>" +
'<span class="count">' + (counts[k] || 0) + "</span></button>";
}).join("");
box.innerHTML = '<section class="card" aria-label="Choose a month"><div class="mp-head">' +
'<button type="button" class="icon-btn mp-nav" data-yr="-1" aria-label="Previous year">\u2039</button>' +
"<strong>" + y + "</strong>" +
'<button type="button" class="icon-btn mp-nav" data-yr="1" aria-label="Next year">\u203a</button></div>' +
'<div class="mp-grid">' + cells + "</div></section>";
}
/* Week picker for the Weekly view: stepper plus weeks that hold expenses. */
function renderWeekPicker(list) {
var box = $("weekPicker");
if (state.tab !== "weekly") { box.innerHTML = ""; return; }
var counts = {};
list.forEach(function (e) {
var k = mondayOf(e.date);
counts[k] = (counts[k] || 0) + 1;
});
var keys = Object.keys(counts).sort().reverse();
var rows = keys.map(function (k) {
return '<button type="button" class="wp-row' + (state.selWeek === k ? " active" : "") + '" data-w="' + k + '">' +
"<span>Week of " + fmtWeekRange(k) + "</span>" +
'<span class="count">' + counts[k] + "</span></button>";
}).join("");
if (!rows) rows = '<p class="empty-note">No expenses yet.</p>';
box.innerHTML = '<section class="card" aria-label="Choose a week"><div class="mp-head">' +
'<button type="button" class="icon-btn mp-nav" data-wstep="-1" aria-label="Previous week">\u2039</button>' +
"<strong>" + (state.selWeek ? fmtWeekRange(state.selWeek) : "Choose a week") + "</strong>" +
'<button type="button" class="icon-btn mp-nav" data-wstep="1" aria-label="Next week">\u203a</button></div>' +
'<div class="wp-list">' + rows + "</div></section>";
}
/* Date picker for the Daily view: stepper, calendar input, recent dates. */
function renderDatePicker(list) {
var box = $("datePicker");
if (state.tab !== "daily") { box.innerHTML = ""; return; }
var counts = {};
list.forEach(function (e) {
counts[e.date] = (counts[e.date] || 0) + 1;
});
var keys = Object.keys(counts).sort().reverse().slice(0, 10);
var rows = keys.map(function (k) {
var label = fmtDay(k);
if (k === todayStr()) label += " (today)";
else if (k === addDaysStr(todayStr(), -1)) label += " (yesterday)";
return '<button type="button" class="wp-row' + (state.selDate === k ? " active" : "") + '" data-d="' + k + '">' +
"<span>" + label + "</span>" +
'<span class="count">' + counts[k] + "</span></button>";
}).join("");
if (!rows) rows = '<p class="empty-note">No expenses yet.</p>';
box.innerHTML = '<section class="card" aria-label="Choose a date"><div class="mp-head">' +
'<button type="button" class="icon-btn mp-nav" data-dstep="-1" aria-label="Previous day">\u2039</button>' +
'<input type="date" class="dp-input" data-dinput value="' + (state.selDate || todayStr()) + '" aria-label="Choose a date">' +
'<button type="button" class="icon-btn mp-nav" data-dstep="1" aria-label="Next day">\u203a</button></div>' +
'<div class="dp-today"><button type="button" class="btn subtle small" data-dtoday>Today</button></div>' +
'<div class="wp-list">' + rows + "</div></section>";
}
function renderScreen() {
var records = state.screen === "records";
$("addCard").hidden = records;
var picking = records && ((state.tab === "daily" && !state.selDate) || (state.tab === "monthly" && !state.selMonth) || (state.tab === "weekly" && !state.selWeek));
RECORD_SECTIONS.forEach(function (id) {
if ((id === "sumCard" || id === "listSection") && picking) $(id).hidden = true;
else $(id).hidden = !records;
});
$("monthPicker").hidden = !(records && state.tab === "monthly");
$("weekPicker").hidden = !(records && state.tab === "weekly");
$("datePicker").hidden = !(records && state.tab === "daily");
}
/* After picking a date, bring the results into view — they render below
the picker and are easy to miss. Instant jump, so reduced-motion safe. */
function revealResults() {
var el = $("sumCard");
if (el && el.scrollIntoView) el.scrollIntoView();
}
function setDrawer(open) {
document.body.classList.toggle("nav-open", open);
$("backdrop").hidden = !open;
$("btnMenu").setAttribute("aria-expanded", open ? "true" : "false");
$("btnMenu").setAttribute("aria-label", open ? "Close menu" : "Open menu");
}
function renderAll() {
var list = load();
var view = filtered(list);
renderStats(list);
renderViewHead(list, view);
renderNav(list);
renderDatePicker(list);
renderMonthPicker(list);
renderWeekPicker(list);
renderSummary(view);
renderList(view);
renderTemplates(list);
renderScreen();
}
/* ---- form ---- */
function resetForm() {
state.editingId = null;
$("expForm").reset();
$("fDate").value = todayStr();
$("formTitle").textContent = "Add expense";
$("btnSave").textContent = "Add expense";
$("btnCancel").hidden = true;
$("formErr").hidden = true;
}
function showErr(msg) {
var p = $("formErr");
p.textContent = msg;
p.hidden = false;
}
function init() {
var catSel = $("fCat");
catSel.innerHTML = Object.keys(CATS).map(function (c) {
return '<option value="' + c + '">' + c + "</option>";
}).join("");
$("fDate").value = todayStr();
document.querySelectorAll(".nav-item[data-tab]").forEach(function (btn) {
btn.addEventListener("click", function () {
state.tab = btn.getAttribute("data-tab");
state.screen = "records";
document.querySelectorAll(".nav-item[data-tab]").forEach(function (b) {
var on = b === btn;
b.classList.toggle("active", on);
b.setAttribute("aria-selected", on ? "true" : "false");
});
setDrawer(false);
renderAll();
});
});
$("navAdd").addEventListener("click", function () {
state.screen = "add";
setDrawer(false);
renderAll();
$("fDesc").focus();
});
$("btnMenu").addEventListener("click", function () {
setDrawer(!document.body.classList.contains("nav-open"));
});
$("backdrop").addEventListener("click", function () { setDrawer(false); });
document.addEventListener("keydown", function (e) { if (e.key === "Escape") setDrawer(false); });
$("q").addEventListener("input", function (e) { state.q = e.target.value; renderAll(); });
$("catList").addEventListener("click", function (e) {
var item = e.target.closest("[data-cat]");
if (!item) return;
state.cat = item.getAttribute("data-cat");
state.screen = "records";
setDrawer(false);
renderAll();
});
$("monthPicker").addEventListener("click", function (e) {
var cell = e.target.closest("[data-m]");
if (cell) {
state.selMonth = cell.getAttribute("data-m");
renderAll();
return;
}
var yr = e.target.closest("[data-yr]");
if (yr) {
var cur = parseInt(state.selYear || todayStr().slice(0, 4), 10) + parseInt(yr.getAttribute("data-yr"), 10);
state.selYear = String(cur);
renderAll();
}
});
$("weekPicker").addEventListener("click", function (e) {
var row = e.target.closest("[data-w]");
if (row) {
state.selWeek = row.getAttribute("data-w");
renderAll();
return;
}
var step = e.target.closest("[data-wstep]");
if (step) {
var base = state.selWeek || mondayOf(todayStr());
state.selWeek = addDaysStr(base, parseInt(step.getAttribute("data-wstep"), 10) * 7);
renderAll();
}
});
$("datePicker").addEventListener("click", function (e) {
var todayBtn = e.target.closest("[data-dtoday]");
if (todayBtn) {
state.selDate = todayStr();
renderAll();
revealResults();
return;
}
var row = e.target.closest("[data-d]");
if (row) {
state.selDate = row.getAttribute("data-d");
renderAll();
revealResults();
return;
}
var step = e.target.closest("[data-dstep]");
if (step) {
var base = state.selDate || todayStr();
state.selDate = addDaysStr(base, parseInt(step.getAttribute("data-dstep"), 10));
renderAll();
revealResults();
}
});
function pickDateInput(input) {
if (!input) return;
if (/^\d{4}-\d{2}-\d{2}$/.test(input.value)) {
state.selDate = input.value;
renderAll();
revealResults();
}
}
$("datePicker").addEventListener("change", function (e) { pickDateInput(e.target.closest("[data-dinput]")); });
$("datePicker").addEventListener("input", function (e) { pickDateInput(e.target.closest("[data-dinput]")); });
$("fFreqFilter").addEventListener("change", function (e) { state.freq = e.target.value; renderAll(); });
$("expForm").addEventListener("submit", function (e) {
e.preventDefault();
var desc = $("fDesc").value.trim();
var amount = Math.round(parseFloat($("fAmount").value) * 100) / 100;
var date = $("fDate").value;
if (!desc) return showErr("Please enter a description.");
if (!(amount > 0)) return showErr("Please enter an amount greater than zero.");
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return showErr("Please pick a valid date.");
var list = load();
if (state.editingId) {
var cur = null;
list.forEach(function (x) { if (x.id === state.editingId) cur = x; });
if (!cur) { resetForm(); renderAll(); return; }
cur.description = desc;
cur.amount = amount;
cur.category = catSel.value;
cur.date = date;
cur.frequency = $("fFreq").value;
} else {
list.push({
id: uid(), description: desc, amount: amount, category: catSel.value,
date: date, frequency: $("fFreq").value, lastLogged: null, createdAt: Date.now()
});
}
save(list);
resetForm();
renderAll();
});
$("btnCancel").addEventListener("click", resetForm);
document.addEventListener("click", function (e) {
var editBtn = e.target.closest("[data-edit]");
var delBtn = e.target.closest("[data-del]");
var logBtn = e.target.closest("[data-log]");
var list = load();
if (editBtn) {
var cur = null;
list.forEach(function (x) { if (x.id === editBtn.getAttribute("data-edit")) cur = x; });
if (!cur) return;
state.editingId = cur.id;
$("fDesc").value = cur.description;
$("fAmount").value = cur.amount.toFixed(2);
$("fCat").value = cur.category;
$("fDate").value = cur.date;
$("fFreq").value = cur.frequency;
$("formTitle").textContent = "Edit expense";
$("btnSave").textContent = "Save changes";
$("btnCancel").hidden = false;
$("formErr").hidden = true;
state.screen = "add";
renderScreen();
$("fDesc").focus();
} else if (delBtn) {
var id = delBtn.getAttribute("data-del");
var name = "";
list.forEach(function (x) { if (x.id === id) name = x.description; });
if (!window.confirm('Delete "' + name + '"?')) return;
save(list.filter(function (x) { return x.id !== id; }));
if (state.editingId === id) resetForm();
renderAll();
} else if (logBtn) {
var tmp = null;
list.forEach(function (x) { if (x.id === logBtn.getAttribute("data-log")) tmp = x; });
if (!tmp) return;
var due = nextDue(tmp);
list.push({
id: uid(), description: tmp.description, amount: tmp.amount, category: tmp.category,
date: due, frequency: "once", lastLogged: null, createdAt: Date.now()
});
tmp.lastLogged = due;
save(list);
renderAll();
}
});
$("btnSamples").addEventListener("click", function () {
save(load().concat(seedSamples()));
renderAll();
});
$("btnClear").addEventListener("click", function () {
if (!window.confirm("Delete ALL expenses? This cannot be undone.")) return;
if (state.editingId) resetForm();
save([]);
renderAll();
});
renderAll();
}
document.addEventListener("DOMContentLoaded", init);