/* ===================== 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 */}
{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 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.
)}
)}
{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 */}
By Category
{data.categories?.length ? (
) : (
No data.
)}
{/* Category breakdown */}
Category Breakdown
{catWithPct.length ? (
{catWithPct.map((c, i) => (
{c.category}
{rupee(c.total)}
))}
) : (
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 */}
{/* 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
);
}
/* ---------------- 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 */}
{/* 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 */}
{/* 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 */}
{/* Card */}
{/* 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";