/* 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 '' + FREQ_LABEL[freq] + ""; } 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 = '

Nothing matches the current filters.

'; return; } var max = byCat[names[0]]; box.innerHTML = names.map(function (n) { var pct = Math.max(2, Math.round((byCat[n] / max) * 100)); return '
' + esc(n) + '' + '' + '' + money.format(byCat[n]) + "
"; }).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 = '

No expenses yet. Add your first one on the left.

'; 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 '
  • ' + '
    ' + esc(e.description) + badge(e.frequency) + "
    " + '
    ' + esc(e.category) + " \u00b7 " + fmtDay(e.date) + "
    " + '' + money.format(e.amount) + "" + '' + '' + '' + "
  • "; }).join(""); return '

    ' + esc(groupTitle(k)) + "

    " + '' + money.format(total) + "
    " + '
    "; }).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 = '
  • None yet. Set Repeats to Daily, Weekly or Monthly when adding an expense.
  • '; return; } box.innerHTML = temps.map(function (t) { return '
  • ' + esc(t.description) + "" + "" + money.format(t.amount) + "
    " + '
    ' + esc(t.category) + " \u00b7 repeats " + FREQ_LABEL[t.frequency].toLowerCase() + " \u00b7 next " + fmtDay(nextDue(t)) + "
    " + '
  • '; }).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 = '
  • "; html += names.map(function (n) { return '
  • "; }).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 '"; }).join(""); box.innerHTML = '
    ' + '' + "" + y + "" + '
    ' + '
    ' + cells + "
    "; } /* 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 '"; }).join(""); if (!rows) rows = '

    No expenses yet.

    '; box.innerHTML = '
    ' + '' + "" + (state.selWeek ? fmtWeekRange(state.selWeek) : "Choose a week") + "" + '
    ' + '
    ' + rows + "
    "; } /* 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 '"; }).join(""); if (!rows) rows = '

    No expenses yet.

    '; box.innerHTML = '
    ' + '' + '' + '
    ' + '
    ' + '
    ' + rows + "
    "; } 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 '"; }).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);