/* ===================== Duewise (iOS-friendly UI) ===================== */ const { useState, useEffect, useRef } = React; /* ---- Month helpers (local time) ---- */ function ymFormat(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); return `${y}-${m}`; } function ymParse(ym) { const [y, m] = ym.split("-").map(Number); return new Date(y, m - 1, 1); } // returns 28..31 for a given "YYYY-MM" function daysInMonth(ym) { const [y, m] = ym.split("-").map(Number); return new Date(y, m, 0).getDate(); } // Fri, Sat, ... function weekdayShort(Y, M, d) { return new Date(Y, M - 1, d).toLocaleString(undefined, { weekday: "short" }); } // Parse "YYYY-MM-DD" *without* Date() (avoids timezone shifts) function dayFromDateStr(s) { if (!s) return null; const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(s).slice(0, 10)); return m ? Number(m[3]) : null; } // Most-used categories (+ "Other") const CATEGORIES = [ "Rent/Mortgage", "Maintenance", "Electricity", "Water", "Gas", "Trash/Sewer", "Internet", "Mobile Phone", "TV/Cable", "Streaming", "Credit Card", "Loan", "Insurance", "House Help", "Tution Fees", "Saving", "Investment", "Self Transfer", "Transfer", "App Subscription", "Software Subscription", "Groceries", "Dining & Food", "Shopping", "Fuel", "Transport", "Parking/Tolls", "Healthcare", "Pharmacy", "Education", // new, while keeping "Tution Fees" "Recharge/Top-up", // prepaid mobile/DTH/gift cards/FASTag top-ups "Fees/Charges", "Cash Withdrawal", "Income/Salary", "Other", ]; /* Display helpers */ const fmtAmt = (v) => v === null || v === undefined || v === "" ? ( ) : ( `${Number(v).toFixed(2)}` ); const getDisplayAmount = (b) => b.amount ?? b.default_amount ?? null; const isVariableBill = (b) => b.default_amount === null || b.default_amount === undefined; /* ---- API helpers ---- */ const API = "/api"; let CSRF = null; async function getCsrf() { const r = await fetch(`${API}/csrf`, { credentials: "include" }); const j = await r.json(); CSRF = j.csrf; return CSRF; } const opt = (m, b) => ({ method: m, credentials: "include", headers: { "Content-Type": "application/json", ...(CSRF ? { "X-CSRF": CSRF } : {}), }, body: b ? JSON.stringify(b) : undefined, }); const auth = { async login(email, password) { await getCsrf(); const r = await fetch(`${API}/login`, opt("POST", { email, password })); return r.json(); }, async register(name, email, password) { await getCsrf(); const r = await fetch( `${API}/register`, opt("POST", { name, email, password }) ); return r.json(); }, async logout() { await getCsrf(); // <— ensure token is loaded const r = await fetch(`${API}/logout`, opt('POST')); const j = await safeJson(r); if (!r.ok || j?.ok === false) throw new Error(j?.error || 'Logout failed'); return j || { ok: true }; }, async logout2() { const r = await fetch(`${API}/logout`, opt("POST")); return r.json(); }, async me() { const r = await fetch(`${API}/me`, { credentials: "include" }); return r.json(); }, }; async function safeJson(res) { const text = await res.text(); // works for empty/204 too try { return text ? JSON.parse(text) : null; } catch { return null; } } const api = { dashboard: async (month) => { const r = await fetch(`${API}/dashboard?month=${month}`, { credentials: "include", }); return r.json(); }, complete: async (bill_id, month, amount) => { await getCsrf(); const body = { bill_id, month }; if (amount !== undefined && amount !== null && String(amount) !== "") { body.amount = Number(amount); } const res = await fetch(`${API}/complete`, opt("POST", body)); const json = await safeJson(res); if (!res.ok || json?.ok === false) { throw new Error(json?.error || `Complete failed (${res.status})`); } return json || { ok: true }; }, uncomplete: async (bill_id, month) => { await getCsrf(); const res = await fetch( `${API}/uncomplete`, opt("POST", { bill_id, month }) ); const json = await safeJson(res); if (!res.ok || json?.ok === false) { throw new Error(json?.error || `Uncomplete failed (${res.status})`); } return json || { ok: true }; }, billsList: async () => { const r = await fetch(`${API}/bills`, { credentials: "include" }); return r.json(); }, billCreate: async (d) => { await getCsrf(); const r = await fetch(`${API}/bills`, opt("POST", d)); return r.json(); }, billUpdate: async (id, d) => { await getCsrf(); const r = await fetch(`${API}/bills/${id}`, opt("PUT", d)); return r.json(); }, billDelete: async (id) => { await getCsrf(); const r = await fetch(`${API}/bills/${id}`, opt("DELETE")); return r.json(); }, stats: async (bill_id, from, to) => { const params = new URLSearchParams({ bill_id, from, to }); const r = await fetch(`${API}/stats?${params}`, { credentials: "include" }); return r.json(); }, overview: async (from, to, exclude = {}) => { const p = new URLSearchParams({ from, to }); if (exclude.billIds?.length) p.set("exclude_bills", exclude.billIds.join(",")); if (exclude.categories?.length) p.set("exclude_categories", exclude.categories.join(",")); const r = await fetch(`${API}/overview?${p.toString()}`, { credentials: "include", }); return r.json(); }, }; function FloatingAddButton({ onClick, title = "Add" }) { return (
); } async function refreshApp() { try { if ("serviceWorker" in navigator) { const reg = await navigator.serviceWorker.getRegistration(); if (reg) { reg.update(); const timeout = setTimeout(() => location.reload(), 600); navigator.serviceWorker.addEventListener( "controllerchange", () => { clearTimeout(timeout); location.reload(); }, { once: true } ); return; } } } catch (e) {} location.reload(); } /* ---- Utilities ---- */ function cleanupBackdrops() { document.querySelectorAll(".modal-backdrop").forEach((el) => el.remove()); document.body.classList.remove("modal-open"); document.body.style.removeProperty("padding-right"); } function ensureBootstrapModal(el) { // eslint-disable-next-line no-undef return bootstrap.Modal.getOrCreateInstance(el, { backdrop: true, keyboard: true, }); } function loadChartJS() { return new Promise((resolve, reject) => { if (window.Chart) return resolve(); const s = document.createElement("script"); s.src = "https://cdn.jsdelivr.net/npm/chart.js"; s.onload = () => resolve(); s.onerror = reject; document.head.appendChild(s); }); } /* ---------------- UI ---------------- */ function TopBar({ view, setView, onLogout, onRefresh, onToday, showToday, onOpenGlobal, month, setMonth, }) { return (
{/* Row 1: brand left, controls right */}
v1.0
{showToday && ( )} {/* NEW: Global Insights button */}
{/* Row 2: segmented control centered */}
{view === "dashboard" ? ( ) : undefined}
); } function MonthNav({ month, setMonth }) { const dt = ymParse(month); const fmt = dt.toLocaleString(undefined, { month: "short", year: "numeric" }); const shift = (n) => { const d = ymParse(month); d.setMonth(d.getMonth() + n); setMonth(ymFormat(d)); }; const goToday = () => setMonth(ymFormat(new Date())); return (

{fmt}
{" "}

); } /* ---------------- Bill Insights Modal (per bill, readable) ---------------- */ function InsightsModal({ bill, open, onClose }) { const modalRef = useRef(null); const canvasRef = useRef(null); const chartRef = useRef(null); const [range, setRange] = useState("ytd"); // '6m' | '12m' | 'ytd' const [data, setData] = useState(null); const [loading, setLoading] = useState(false); // Pretty ₹ formatter + month labels like "Sep ’25" const INR = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 2, }); const rupee = (n) => INR.format(Number(n || 0)); const fmtYMpretty = (ym) => { if (!ym) return "-"; const [y, m] = ym.split("-").map(Number); const d = new Date(y, m - 1, 1); return d.toLocaleString(undefined, { month: "short", year: "2-digit" }); }; function computeRange() { const now = new Date(); const to = ymFormat(now); if (range === "6m") { const d = new Date(now); d.setMonth(d.getMonth() - 5); return { from: ymFormat(d), to }; } if (range === "ytd") { const d = new Date(now.getFullYear(), 0, 1); return { from: ymFormat(d), to }; } const d = new Date(now); d.setMonth(d.getMonth() - 11); return { from: ymFormat(d), to }; } /* ---- modal open/close & cleanup ---- */ useEffect(() => { const el = modalRef.current; if (!el) return; const m = ensureBootstrapModal(el); const onHidden = () => { if (chartRef.current) { chartRef.current.destroy(); chartRef.current = null; } cleanupBackdrops(); onClose && onClose(); }; el.addEventListener("hidden.bs.modal", onHidden); if (open) { cleanupBackdrops(); m.show(); } else { m.hide(); } return () => { el.removeEventListener("hidden.bs.modal", onHidden); if (chartRef.current) { chartRef.current.destroy(); chartRef.current = null; } }; }, [open, onClose]); /* ---- fetch data when opened / range changes ---- */ useEffect(() => { if (!open || !bill) return; const { from, to } = computeRange(); setLoading(true); api.stats(bill.id, from, to).then((res) => { setData(res); setLoading(false); }); }, [open, bill, range]); /* ---- build chart once we have data ---- */ useEffect(() => { (async () => { if (!open || !data || !canvasRef.current) return; await loadChartJS(); if (chartRef.current) { chartRef.current.destroy(); chartRef.current = null; } const labels = (data.months || []).map((m) => fmtYMpretty(m.ym)); const amounts = (data.months || []).map((m) => Number(m.amount || 0)); chartRef.current = new Chart(canvasRef.current.getContext("2d"), { type: "bar", data: { labels, datasets: [ { label: "Amount paid", data: amounts, backgroundColor: "rgba(59, 130, 246, .28)", borderColor: "rgba(59, 130, 246, .9)", borderWidth: 1.2, borderRadius: 6, maxBarThickness: 32, }, ], }, options: { responsive: true, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => rupee(ctx.parsed.y) } }, }, scales: { y: { beginAtZero: true, ticks: { callback: (v) => rupee(v) }, }, x: { ticks: { maxRotation: 0, autoSkip: true } }, }, }, }); })(); }, [open, data]); // Derive min/max months from the series (clearer than just values) const series = data?.months || []; const paidOnly = series.filter((m) => Number(m.amount) > 0); const minRow = paidOnly.length ? paidOnly.reduce((a, b) => (b.amount < a.amount ? b : a)) : null; const maxRow = paidOnly.length ? paidOnly.reduce((a, b) => (b.amount > a.amount ? b : a)) : null; const total = Number(data?.summary?.total || 0); const monthsPaid = Number(data?.summary?.months_paid || 0); const monthsTotal = Number(data?.summary?.months_total || series.length || 0); const avgPaid = Number(data?.summary?.avg_paid || 0); const completion = monthsTotal ? (monthsPaid * 100) / monthsTotal : 0; return (
Insights {bill ? `— ${bill.title}` : ""}
{/* Controls */}
{loading &&

Loading…

} {!loading && data && ( <> {/* Stat tiles */}
Total paid
{rupee(total)}
Months paid
{monthsPaid} / {monthsTotal}
Average (paid months)
{rupee(avgPaid)}
Best month
{minRow ? `${fmtYMpretty(minRow.ym)} · ${rupee(minRow.amount)}` : "-"}
Completion in range
{completion.toFixed(0)}%
{/* Chart */}
Monthly Amounts
{/* Monthly breakdown */}
Monthly Breakdown
{series.length ? (
    {series.map((m, i) => (
  • {fmtYMpretty(m.ym)} {rupee(m.amount)}
  • ))}
) : (

No data.

)} )}
); } /* ---------------- Global Insights Modal (Readable) ---------------- */ function GlobalInsightsModal({ open, onClose }) { const modalRef = useRef(null); const barCanvas = useRef(null); const pieCanvas = useRef(null); const barChartRef = useRef(null); const pieChartRef = useRef(null); const [range, setRange] = useState("ytd"); // 6m | 12m | ytd const [data, setData] = useState(null); const [loading, setLoading] = useState(false); // --- NEW: exclude filters (persisted) --- const [exclude, setExclude] = useState(() => { try { return JSON.parse( localStorage.getItem("duewise_exclude") || '{"billIds":[],"categories":[]}' ); } catch { return { billIds: [], categories: [] }; } }); const [billOptions, setBillOptions] = useState([]); const [filtersOpen, setFiltersOpen] = useState(false); // Budget (existing) const [budget, setBudget] = useState(() => { const v = localStorage.getItem("duewise_budget"); return v ? parseFloat(v) : ""; }); /* ---- format helpers ---- */ const INR = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 2, }); const rupee = (n) => INR.format(Number(n || 0)); const fmtYMpretty = (ym) => { if (!ym) return "-"; const [y, m] = ym.split("-").map(Number); const d = new Date(y, m - 1, 1); return d.toLocaleString(undefined, { month: "short", year: "2-digit" }); // e.g., Sep ’25 }; function computeRange() { const now = new Date(); const to = ymFormat(now); if (range === "6m") { const d = new Date(now); d.setMonth(d.getMonth() - 5); return { from: ymFormat(d), to }; } if (range === "ytd") { const d = new Date(now.getFullYear(), 0, 1); return { from: ymFormat(d), to }; } const d = new Date(now); d.setMonth(d.getMonth() - 11); return { from: ymFormat(d), to }; } /* ---- modal open/close & cleanup ---- */ useEffect(() => { const el = modalRef.current; if (!el) return; const m = ensureBootstrapModal(el); const onHidden = () => { if (barChartRef.current) { barChartRef.current.destroy(); barChartRef.current = null; } if (pieChartRef.current) { pieChartRef.current.destroy(); pieChartRef.current = null; } cleanupBackdrops(); onClose && onClose(); }; el.addEventListener("hidden.bs.modal", onHidden); if (open) { cleanupBackdrops(); m.show(); } else { m.hide(); } return () => { el.removeEventListener("hidden.bs.modal", onHidden); if (barChartRef.current) { barChartRef.current.destroy(); barChartRef.current = null; } if (pieChartRef.current) { pieChartRef.current.destroy(); pieChartRef.current = null; } }; }, [open, onClose]); /* ---- load bill options (for filters) when opened ---- */ useEffect(() => { if (open) api .billsList() .then(setBillOptions) .catch(() => {}); }, [open]); /* ---- fetch data when opened / range / excludes change ---- */ useEffect(() => { if (!open) return; const { from, to } = computeRange(); setLoading(true); api.overview(from, to, exclude).then((res) => { setData(res); setLoading(false); }); }, [open, range, exclude]); /* ---- build charts once we have data ---- */ useEffect(() => { (async () => { if (!open || !data) return; await loadChartJS(); // Destroy old charts if (barChartRef.current) { barChartRef.current.destroy(); barChartRef.current = null; } if (pieChartRef.current) { pieChartRef.current.destroy(); pieChartRef.current = null; } // Bar chart (Monthly Totals) const labels = (data.months || []).map((m) => fmtYMpretty(m.ym)); const totals = (data.months || []).map((m) => Number(m.total || 0)); if (barCanvas.current) { barChartRef.current = new Chart(barCanvas.current.getContext("2d"), { type: "bar", data: { labels, datasets: [ { label: "Monthly total", data: totals, backgroundColor: "rgba(16, 185, 129, .35)", borderColor: "rgba(16, 185, 129, .9)", borderWidth: 1.2, borderRadius: 6, maxBarThickness: 30, }, ], }, options: { responsive: true, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => rupee(ctx.parsed.y) } }, }, scales: { y: { beginAtZero: true, ticks: { callback: (v) => rupee(v) } }, x: { ticks: { maxRotation: 0, autoSkip: true } }, }, }, }); } // Doughnut chart (By Category) const catLabels = (data.categories || []).map((c) => c.category); const catTotals = (data.categories || []).map((c) => Number(c.total || 0) ); const palette = [ "#10b981", "#06b6d4", "#3b82f6", "#f59e0b", "#ef4444", "#8b5cf6", "#22c55e", "#a3e635", "#14b8a6", ]; if (pieCanvas.current) { pieChartRef.current = new Chart(pieCanvas.current.getContext("2d"), { type: "doughnut", data: { labels: catLabels, datasets: [ { data: catTotals, backgroundColor: catTotals.map( (_, i) => palette[i % palette.length] ), borderWidth: 0, }, ], }, options: { responsive: true, cutout: "58%", plugins: { legend: { position: "bottom" }, tooltip: { callbacks: { label: (ctx) => `${ctx.label}: ${rupee(ctx.parsed)}`, }, }, }, }, }); } })(); }, [open, data]); // Budget helpers const avgMonth = data?.summary?.avg_month ?? 0; const overUnder = budget !== "" ? avgMonth - Number(budget) : null; const totalAll = Number(data?.summary?.total || 0); const catWithPct = (data?.categories || []).map((c) => ({ ...c, total: Number(c.total || 0), pct: totalAll ? (Number(c.total || 0) * 100) / totalAll : 0, })); // --- NEW: helpers to save exclusions --- function saveExclude(next) { setExclude(next); try { localStorage.setItem("duewise_exclude", JSON.stringify(next)); } catch {} } // Active filter chips function FilterChips() { const cats = exclude.categories || []; const ids = exclude.billIds || []; if (!cats.length && !ids.length) return null; const idToTitle = {}; billOptions.forEach((b) => (idToTitle[b.id] = b.title)); return (
Excluding:
{cats.map((c) => ( {c} ))} {ids.map((id) => ( {idToTitle[id] || `#${id}`} ))}
); } // Category filter options from current data const categoryOptions = (data?.categories || []).map((c) => c.category); return (
Global Insights
{/* Controls */}
{/* NEW: Filters panel */} {filtersOpen && (
Exclude Categories
{categoryOptions.length ? ( categoryOptions.map((cat) => { const key = (cat || "").toLowerCase(); const sel = exclude.categories.includes(key); return ( ); }) ) : (
No categories yet.
)}
Exclude Bills
{billOptions.length ? ( billOptions.map((b) => { const sel = exclude.billIds.includes(b.id); return ( ); }) ) : (
No bills yet.
)}
)}
{ const v = e.target.value; setBudget(v); try { localStorage.setItem("duewise_budget", v); } catch {} }} />
{loading &&

Loading…

}
{!loading && data && ( <> {/* Stat Tiles */}
Total
{rupee(data.summary.total)}
Avg / month
{rupee(data.summary.avg_month)}
Best month
{fmtYMpretty(data.summary.min_month?.ym)} ·{" "} {rupee(data.summary.min_month?.total)}
Peak month
{fmtYMpretty(data.summary.max_month?.ym)} ·{" "} {rupee(data.summary.max_month?.total)}
{budget !== "" && (
Budget vs Avg
0 ? "text-danger" : overUnder < 0 ? "text-success" : "" }`} > {overUnder > 0 ? `Over by ${rupee(overUnder)}` : overUnder < 0 ? `Under by ${rupee(Math.abs(overUnder))}` : "On budget"}
0 ? "bg-danger" : "bg-success" }`} style={{ width: `${Math.min( 100, (avgMonth / (Number(budget) || 1)) * 100 )}%`, }} title={`Avg ${rupee(avgMonth)} vs Budget ${rupee( Number(budget || 0) )}`} />
)}
{/* Charts */}
Monthly Totals
By Category
{data.categories?.length ? ( ) : (

No data.

)}
{/* Category breakdown */}
Category Breakdown
{catWithPct.length ? (
{catWithPct.map((c, i) => (
{c.category} {rupee(c.total)}
{c.pct.toFixed(1)}%
))}
) : (

No data.

)}
{/* Top bills */}
Top Bills (total in range)
{data.topBills?.length ? (
    {data.topBills.map((t, i) => (
  • {t.title} {rupee(t.total)}
  • ))}
) : (

No data.

)}
)}
); } /* ---------------- Dashboard ---------------- */ /* ---------------- Dashboard (agenda-style month view) ---------------- */ /* ---------------- Dashboard (collapsible agenda-style) ---------------- */ function Dashboard({ month }) { const [data, setData] = React.useState({ paid: [], unpaid: [] }); const [dueDayById, setDueDayById] = React.useState({}); // id -> true due day (1..31) const [payAmt, setPayAmt] = React.useState({}); const [insightsBill, setInsightsBill] = React.useState(null); const [insightsOpen, setInsightsOpen] = React.useState(false); const [hidePaid, setHidePaid] = React.useState(false); // NEW: toggle to hide paid items // Quick Add (one-time for selected month) const [qa, setQa] = React.useState({ open: false, title: "", amount: "", category: "Other", tags: "", markPaid: false, date: "", }); const qaRef = React.useRef(null); // anchor for “today” (only used on month change) const todayRef = React.useRef(null); // ----- helpers ----- const dayFromDateStr = (s) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(s || "")); return m ? Number(m[3]) : null; }; const daysInMonth = (ym) => { const [y, m] = ym.split("-").map(Number); return new Date(y, m, 0).getDate(); }; const weekdayShort = (Y, M, d) => new Date(Y, M - 1, d).toLocaleDateString(undefined, { weekday: "short" }); // ----- load dashboard + master list (for true due days) ----- React.useEffect(() => { let alive = true; Promise.all([api.dashboard(month), api.billsList()]).then( ([dash, list]) => { if (!alive) return; setData(dash || { paid: [], unpaid: [] }); const map = {}; (list || []).forEach((b) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(b.due_date || "")); map[b.id] = m ? Number(m[3]) : 1; }); setDueDayById(map); setPayAmt({}); } ); return () => { alive = false; }; }, [month]); const openInsights = (bill) => { setInsightsBill(bill); setInsightsOpen(true); }; const closeInsights = () => { setInsightsOpen(false); setInsightsBill(null); }; // optimistic move helper function moveBetween(state, id, fromKey, toKey) { const from = state[fromKey] || []; const to = state[toKey] || []; const idx = from.findIndex((x) => x.id === id); if (idx < 0) return state; const item = from[idx]; return { ...state, [fromKey]: from.filter((_, i) => i !== idx), [toKey]: [{ ...item, __status: toKey.slice(0, -1) }, ...to], }; } const markPaid = async (bill, overrideAmt) => { const displayAmt = getDisplayAmount(bill); const useAmt = overrideAmt !== undefined ? overrideAmt : isVariableBill(bill) ? payAmt[bill.id] ?? "" : displayAmt; if (useAmt === "" || isNaN(Number(useAmt))) { alert("Please enter a valid amount first."); return; } // optimistic setData((s) => moveBetween(s, bill.id, "unpaid", "paid")); try { await api.complete(bill.id, month, Number(useAmt)); const fresh = await api.dashboard(month); setData(fresh || { paid: [], unpaid: [] }); setPayAmt((s) => ({ ...s, [bill.id]: "" })); // harmless now, but keeps things tidy } catch { setData((s) => moveBetween(s, bill.id, "paid", "unpaid")); alert("Failed to mark as paid."); } }; const unmarkPaid = async (id) => { // optimistic setData((s) => moveBetween(s, id, "paid", "unpaid")); try { await api.uncomplete(id, month); const fresh = await api.dashboard(month); setData(fresh || { paid: [], unpaid: [] }); } catch { setData((s) => moveBetween(s, id, "unpaid", "paid")); alert("Failed to move back to pending."); } }; // ------- Build "agenda" data: day -> bills[] (paid + unpaid) ------- function groupByDay() { const map = {}; const all = [ ...(data.unpaid || []).map((b) => ({ ...b, __status: "unpaid" })), ...(data.paid || []).map((b) => ({ ...b, __status: "paid" })), ]; all.forEach((b) => { const day = dueDayById[b.id] ?? dayFromDateStr(b.due_date) ?? 1; (map[day] ||= []).push(b); }); // sort: unpaid first, then by title Object.values(map).forEach((list) => { list.sort((a, b) => { if (a.__status !== b.__status) return a.__status === "unpaid" ? -1 : 1; return String(a.title || "").localeCompare(String(b.title || "")); }); }); return map; } const byDay = groupByDay(); // Scroll to today ONLY on month change (not on every data change) React.useEffect(() => { const [y, m] = month.split("-").map(Number); const now = new Date(); if (now.getFullYear() === y && now.getMonth() + 1 === m) { setTimeout(() => { const el = todayRef.current; if (!el) return; const targetY = window.scrollY + el.getBoundingClientRect().top - 240; // leave 40px from top window.scrollTo({ top: targetY, behavior: "smooth" }); }, 80); } }, [month]); // helpers for day loop const [Y, M] = month.split("-").map(Number); const totalDays = daysInMonth(month); const now = new Date(); const isCurrentMonth = now.getFullYear() === Y && now.getMonth() + 1 === M; const todayNum = isCurrentMonth ? now.getDate() : -1; // ----- Single bill row (compact) ----- const BillRow = React.memo(function BillRow({ b }) { console.log(b); const variable = isVariableBill(b); const displayAmt = getDisplayAmount(b); const completed = b.__status === "paid"; // local input state so typing doesn't touch parent & cause remounts const [localAmt, setLocalAmt] = React.useState(""); // if you want to prefill from parent occasionally, keep this: React.useEffect(() => { setLocalAmt(payAmt[b.id] ?? ""); }, [b.id]); // keep very narrow deps so it doesn't fight typing const handleMarkPaid = async () => { await markPaid(b, variable ? localAmt : undefined); if (variable) setLocalAmt(""); // clear after success }; return (
openInsights(b)} >
{/* Row 1: Title + actions */}
{b.title}
e.stopPropagation()} > {completed ? ( ) : ( <> {variable ? ( <> ) : ( <>
{fmtAmt(displayAmt)}
)} )}
{/* Amount + date */}
e.stopPropagation()}> {!completed && variable ? ( setLocalAmt(e.target.value)} onMouseDown={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} /> ) : ( fmtAmt(displayAmt) )} {/* Optional tags */} {b.tags ? (
{b.tags}
) : null}
{b.paid_at ? (
{" "} Paid on :{new Date(b.paid_at).toLocaleString() || " "}
) : undefined}
{/* Footer: category + recurring */}
{b.category || "-"} {b.recurring == "6months" ? "Half-Yearly" : b.recurring}
); }); // ----- Day block (always open, sticky header like calendar agenda) ----- function DayBlock({ d, items, wday, isToday }) { return (
{d} {wday}
{isToday && ( Today )} {items.length}
{items.length ? ( items.map((b) => ) ) : (
No bills.
)}
); } // ---- Quick Add ---- const openQuickAdd = () => { const [Y, M] = month.split("-").map(Number); const today = new Date(); const isThisMonth = today.getFullYear() === Y && today.getMonth() + 1 === M; const defaultDay = isThisMonth ? today.getDate() : 1; const dd = String(defaultDay).padStart(2, "0"); const mm = String(M).padStart(2, "0"); const defaultDate = `${Y}-${mm}-${dd}`; setQa({ open: true, title: "", amount: "", category: "Other", markPaid: false, date: defaultDate, }); ensureBootstrapModal(qaRef.current).show(); cleanupBackdrops(); }; const saveQuickAdd = async () => { if (!qa.title) { alert("Title is required"); return; } const due = qa.date || (() => { const d = ymParse(month); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart( 2, "0" )}-01`; })(); const res = await api.billCreate({ title: qa.title, amount: qa.amount === "" ? null : Number(qa.amount), due_date: due, recurring: "once", category: qa.category || "Other", tags: "", comments: "", }); if (qa.markPaid && res?.id) { await api.complete( res.id, month, qa.amount === "" ? undefined : Number(qa.amount) ); } ensureBootstrapModal(qaRef.current).hide(); cleanupBackdrops(); setQa({ open: false, title: "", amount: "", category: "Other", markPaid: false, date: "", }); const fresh = await api.dashboard(month); setData(fresh || { paid: [], unpaid: [] }); }; // ---- render ---- const [Y2, M2] = month.split("-").map(Number); return (
{/* Header + toggle */}
setHidePaid(e.target.checked)} />
{/* Agenda-style month view (always open) */} {Array.from({ length: daysInMonth(month) }, (_, i) => i + 1).map((d) => { const allItems = byDay[d] || []; const shown = hidePaid ? allItems.filter((b) => b.__status !== "paid") : allItems; if (hidePaid && shown.length === 0) return null; // hide empty day when hiding paid const wday = weekdayShort(Y2, M2, d); const now = new Date(); const isToday = now.getFullYear() === Y2 && now.getMonth() + 1 === M2 && now.getDate() === d; return ( ); })} {/* Bill-level Insights */} {/* FAB */}
{/* Quick Add Modal */}
Add One-time Expense
For{" "} {ymParse(month).toLocaleString(undefined, { month: "short", year: "numeric", })}{" "} only
setQa({ ...qa, title: e.target.value })} placeholder="e.g., Festival shopping" />
setQa({ ...qa, amount: e.target.value })} placeholder="Optional (leave blank if unknown now)" />
If blank: you can enter it when marking as Paid.
setQa({ ...qa, date: e.target.value })} />
This one-time expense will appear on the selected day.
setQa({ ...qa, markPaid: e.target.checked })} />
); } /* ---------------- Master List ---------------- */ function MasterList() { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [form, setForm] = useState({ id: null, title: "", amount: "", due_date: "", recurring: "monthly", category: "", selectedCategory: "Other", customCategory: "", tags: "", comments: "", }); // NEW: filter state const [filter, setFilter] = useState("all"); // 'all' | 'once' | 'monthly' | '6months' | 'yearly' const load = () => api.billsList().then((d) => { setRows(d || []); setLoading(false); }); useEffect(() => { load(); }, []); const openAdd = () => { setForm({ id: null, title: "", amount: "", due_date: "", recurring: "monthly", category: "", selectedCategory: "Other", customCategory: "", tags: "", comments: "", }); cleanupBackdrops(); // eslint-disable-next-line no-undef new bootstrap.Modal(document.getElementById("billModal")).show(); }; const openEdit = (b) => { const inList = CATEGORIES.includes(b.category); setForm({ id: b.id, amount: b.default_amount ?? "", title: b.title, due_date: b.due_date, recurring: b.recurring, selectedCategory: inList ? b.category : "Other", customCategory: inList ? "" : b.category || "", category: b.category || "", tags: b.tags || "", comments: b.comments || "", }); cleanupBackdrops(); // eslint-disable-next-line no-undef new bootstrap.Modal(document.getElementById("billModal")).show(); }; const save = async () => { console.log(form); const categoryToSave = form.selectedCategory === "Other" ? form.customCategory || "" : form.selectedCategory; const amt = form.amount === "" || form.amount === null || form.amount === undefined ? null : parseFloat(form.amount); const data = { ...form, category: categoryToSave, amount: amt }; if (!data.title || !data.due_date) return alert("Title and Due Date are required"); if (data.id) await api.billUpdate(data.id, data); else await api.billCreate(data); // eslint-disable-next-line no-undef bootstrap.Modal.getInstance(document.getElementById("billModal")).hide(); cleanupBackdrops(); load(); }; const remove = async (id) => { if (confirm("Delete this bill?")) { await api.billDelete(id); load(); } }; // --- NEW: normalize recurring and compute counts + filtered rows --- const recKey = (r) => { const rr = String(r || "") .toLowerCase() .replace(/\s+/g, ""); if ( rr === "once" || rr === "onetime" || rr === "one-time" || rr === "one_time" || rr == "" ) return "once"; if (rr === "monthly") return "monthly"; if ( rr === "6months" || rr === "sixmonths" || rr === "semiannual" || rr === "semiannually" || rr === "semi-annual" || rr === "semi-annually" ) return "6months"; if (rr === "yearly" || rr === "annual" || rr === "annually") return "yearly"; return "other"; }; const counts = React.useMemo(() => { const c = { all: rows.length, once: 0, monthly: 0, "6months": 0, yearly: 0, }; rows.forEach((b) => { const k = recKey(b.recurring); if (k in c) c[k]++; }); return c; }, [rows]); const filteredRows = React.useMemo(() => { if (filter === "all") return rows; return rows.filter((b) => recKey(b.recurring) === filter); }, [rows, filter]); const labelOf = (k) => ({ all: "All", once: "Once", monthly: "Monthly", "6months": "Half-Yearly", yearly: "Yearly", }[k]); return ( <>
Master List
{/* NEW: Filter bar */}
{["all", "once", "monthly", "6months", "yearly"].map((k) => ( ))}
{loading ? (

Loading…

) : filteredRows.length ? ( filteredRows.map((b) => { const rShort = (rec) => { const k = String(rec || "").toLowerCase(); if (k === "monthly") return "m"; if (k === "6months") return "6m"; if (k === "yearly") return "y"; if (k === "once") return "1x"; return k || "-"; }; return (
{/* Row 1: Title + actions */}
{b.title}
{" "} {fmtAmt(b.default_amount)}
Due date: {b.due_date || "-"}
{/* Row 2: Amount */} {b.tags ? ( {b.tags || ""} ) : undefined} {/* Divider */}
{/* Row 3: (recurring) (category) (date) */}
{b.category || "-"} {b.recurring == "6months" ? "Half-Yearly" : b.recurring}
); }) ) : (

No bills for {labelOf(filter)}.

)} {/* Add/Edit modal */}
{/* Header */}
{form.id ? "Edit Bill" : "Add Bill"}
Keep amount blank for variable bills (e.g., electricity).
{/* Body */}
{/* Title */}
setForm({ ...form, title: e.target.value })} />
{/* Amount + Date */}
setForm({ ...form, amount: e.target.value }) } />
Leave empty to enter the exact amount each month when paying.
setForm({ ...form, due_date: e.target.value }) } />
{/* Recurrence */}
We’ll auto-show it on the dashboard when it’s due.
{/* Category */}
{form.selectedCategory === "Other" && (
setForm({ ...form, customCategory: e.target.value }) } />
)}
{/* Tags + Comments */}
setForm({ ...form, tags: e.target.value })} />
Optional — helps search & filters.
{/* Footer */}
); } /* ---------------- Auth & App ---------------- */ function Auth({ onAuthed }) { const [mode, setMode] = useState("login"); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [err, setErr] = useState(""); async function submit() { setErr(""); if (mode === "login") { const r = await auth.login(email, password); if (r.ok) onAuthed(); else setErr(r.error || "Login failed"); } else { const r = await auth.register(name, email, password); if (r.ok) { const r2 = await auth.login(email, password); if (r2.ok) onAuthed(); else setErr(r2.error || "Login failed"); } else setErr(r.error || "Register failed"); } } return (
{/* Logo + Title */}

Duewise v1.0

{/* Card */}
{mode === "register" && (
setName(e.target.value)} />
)}
setEmail(e.target.value)} inputMode="email" autoComplete="email" />
setPassword(e.target.value)} autoComplete={mode === 'login' ? 'current-password' : 'new-password'} />
{err &&
{err}
}
{/* Tiny footer note */}

Secure • Private • Local-first

); } function App() { const [authed, setAuthed] = useState(false); const [view, setView] = useState("dashboard"); const [month, setMonth] = useState(ymFormat(new Date())); const [globalOpen, setGlobalOpen] = useState(false); const todayYM = ymFormat(new Date()); const isToday = month === todayYM; useEffect(() => { auth.me().then((u) => setAuthed(!!u.id)); }, []); if (!authed) return setAuthed(true)} />; return ( <> auth.logout().then(() => location.reload())} onRefresh={refreshApp} onToday={() => setMonth(ymFormat(new Date()))} showToday={!isToday} onOpenGlobal={() => setGlobalOpen(true)} month={month} setMonth={setMonth} />
{view === "dashboard" && ( <> )} {view === "master" && }
{/* Global Insights Modal */} setGlobalOpen(false)} /> ); } ReactDOM.createRoot(document.getElementById("root")).render(); // After render line: const splash = document.getElementById("dw-splash"); if (splash) splash.style.display = "none";