Pin & Penny expense tracker (web + expo-app)
This commit is contained in:
679
app.js
Normal file
679
app.js
Normal file
@@ -0,0 +1,679 @@
|
||||
/* Pin & Penny: 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",
|
||||
Housing: "#eab88f",
|
||||
Utilities: "#8fd8c4",
|
||||
Shopping: "#f5b98a",
|
||||
Health: "#f2a3a3",
|
||||
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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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);
|
||||
return Array.isArray(arr) ? 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, "Housing", 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);
|
||||
Reference in New Issue
Block a user