Pin & Penny expense tracker (web + expo-app)
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
expo-app/
|
||||||
|
server.py
|
||||||
|
__pycache__/
|
||||||
|
.git/
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
3
Dockerfile
Normal file
3
Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY index.html styles.css app.js /usr/share/nginx/html/
|
||||||
|
EXPOSE 80
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Pin & Penny
|
||||||
|
|
||||||
|
Daily, weekly and monthly expense tracking. Data is saved privately (local storage on web, on-device storage in the app). No account.
|
||||||
|
|
||||||
|
There are two versions:
|
||||||
|
|
||||||
|
- **This folder (plain web)** — no dependencies, no build step. Start it below.
|
||||||
|
- **`expo-app/` (React Native + Expo)** — one codebase that runs as a website and as iOS/Android apps. Needs Node.js 20+; see `expo-app/README.md`.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd "/mnt/c/workspace/expense tracker"
|
||||||
|
python3 server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:8000** in your browser.
|
||||||
|
To use another port: `PORT=8080 python3 server.py`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Sidebar views** — Daily, Weekly, Monthly and All expenses with live counts, each group with its own total.
|
||||||
|
- **Categories** — Food, Transport, Housing, Utilities, Shopping, Health, Entertainment, Other.
|
||||||
|
- **Repeats** — mark an expense Daily, Weekly or Monthly. Repeating entries appear under “Repeating expenses” with the next due date and a one-click **Log next occurrence** button.
|
||||||
|
- **Summary** — totals plus a per-category spending breakdown for whatever is shown.
|
||||||
|
- **INR amounts** — everything is shown in rupees (₹) with Indian digit grouping.
|
||||||
|
- **Search and filters** — by text, category and repeat type.
|
||||||
|
- **Edit / delete** — every entry can be changed or removed.
|
||||||
|
- **Sample data** — realistic entries are loaded on first run so each view has content. Use **Clear all** to start empty and **Load sample data** to add them back.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `index.html` — page structure
|
||||||
|
- `styles.css` — styling
|
||||||
|
- `app.js` — all logic (views, repeats, storage)
|
||||||
|
- `server.py` — zero-dependency local server (the project runner)
|
||||||
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);
|
||||||
279
expo-app/App.js
Normal file
279
expo-app/App.js
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Pressable, SafeAreaView, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { useFonts, PetitFormalScript_400Regular } from "@expo-google-fonts/petit-formal-script";
|
||||||
|
import { ShadowsIntoLightTwo_400Regular } from "@expo-google-fonts/shadows-into-light-two";
|
||||||
|
import { StyleScript_400Regular } from "@expo-google-fonts/style-script";
|
||||||
|
import { Waterfall_400Regular } from "@expo-google-fonts/waterfall";
|
||||||
|
import ExpenseForm from "./src/components/ExpenseForm";
|
||||||
|
import ExpenseGroups from "./src/components/ExpenseGroups";
|
||||||
|
import RepeatTemplates from "./src/components/RepeatTemplates";
|
||||||
|
import SummaryBars from "./src/components/SummaryBars";
|
||||||
|
import { CATEGORIES } from "./src/categories";
|
||||||
|
import {
|
||||||
|
addDaysStr,
|
||||||
|
groupKey,
|
||||||
|
groupTitle,
|
||||||
|
money,
|
||||||
|
mondayOf,
|
||||||
|
nextDue,
|
||||||
|
seedSamples,
|
||||||
|
todayStr,
|
||||||
|
uid,
|
||||||
|
} from "./src/logic";
|
||||||
|
import { loadExpenses, saveExpenses } from "./src/storage";
|
||||||
|
import s from "./src/styles";
|
||||||
|
|
||||||
|
const TABS = ["daily", "weekly", "monthly"];
|
||||||
|
const SCOPE_NOUN = { daily: "day", weekly: "week", monthly: "month" };
|
||||||
|
|
||||||
|
function blankForm() {
|
||||||
|
return { description: "", amount: "", category: "Food", date: todayStr(), frequency: "once" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [expenses, setExpenses] = useState([]);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [tab, setTab] = useState("daily");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [catFilter, setCatFilter] = useState("all");
|
||||||
|
const [freqFilter, setFreqFilter] = useState("all");
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState(null);
|
||||||
|
const currency = "INR";
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
/* Display font for headings; headings fall back to the system font
|
||||||
|
until it loads (or when offline), so rendering is never blocked. */
|
||||||
|
useFonts({ PetitFormalScript_400Regular, ShadowsIntoLightTwo_400Regular, StyleScript_400Regular, Waterfall_400Regular });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadExpenses().then((list) => {
|
||||||
|
setExpenses(list);
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function update(list) {
|
||||||
|
setExpenses(list);
|
||||||
|
saveExpenses(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const editing = editingId ? expenses.find((e) => e.id === editingId) || null : null;
|
||||||
|
|
||||||
|
function submitForm(data) {
|
||||||
|
if (editing) {
|
||||||
|
update(expenses.map((e) => (e.id === editing.id ? { ...e, ...data } : e)));
|
||||||
|
setEditingId(null);
|
||||||
|
} else {
|
||||||
|
update([...expenses, { ...data, id: uid(), lastLogged: null, createdAt: Date.now() }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(id) {
|
||||||
|
if (pendingDeleteId === id) {
|
||||||
|
update(expenses.filter((e) => e.id !== id));
|
||||||
|
if (editingId === id) setEditingId(null);
|
||||||
|
setPendingDeleteId(null);
|
||||||
|
} else {
|
||||||
|
setPendingDeleteId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logNext(id) {
|
||||||
|
const t = expenses.find((e) => e.id === id);
|
||||||
|
if (!t) return;
|
||||||
|
const due = nextDue(t);
|
||||||
|
update([
|
||||||
|
...expenses.map((e) => (e.id === id ? { ...e, lastLogged: due } : e)),
|
||||||
|
{
|
||||||
|
id: uid(),
|
||||||
|
description: t.description,
|
||||||
|
amount: t.amount,
|
||||||
|
category: t.category,
|
||||||
|
date: due,
|
||||||
|
frequency: "once",
|
||||||
|
lastLogged: null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return expenses.filter((e) => {
|
||||||
|
if (catFilter !== "all" && e.category !== catFilter) return false;
|
||||||
|
if (freqFilter !== "all" && e.frequency !== freqFilter) return false;
|
||||||
|
if (q && (e.description + " " + e.category).toLowerCase().indexOf(q) === -1) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [expenses, query, catFilter, freqFilter]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
visible.forEach((e) => {
|
||||||
|
const k = groupKey(e, tab);
|
||||||
|
(map[k] = map[k] || []).push(e);
|
||||||
|
});
|
||||||
|
return Object.keys(map)
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
.map((k) => {
|
||||||
|
const items = map[k].slice().sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : b.createdAt - a.createdAt));
|
||||||
|
return { key: k, title: groupTitle(k, tab), total: items.reduce((sum, e) => sum + e.amount, 0), items };
|
||||||
|
});
|
||||||
|
}, [visible, tab]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const t = todayStr();
|
||||||
|
const wk = mondayOf(t);
|
||||||
|
const mo = t.slice(0, 7);
|
||||||
|
let d = 0;
|
||||||
|
let w = 0;
|
||||||
|
let m = 0;
|
||||||
|
let dc = 0;
|
||||||
|
let wc = 0;
|
||||||
|
let mc = 0;
|
||||||
|
const cats = {};
|
||||||
|
expenses.forEach((e) => {
|
||||||
|
if (e.date === t) { d += e.amount; dc += 1; }
|
||||||
|
if (mondayOf(e.date) === wk) { w += e.amount; wc += 1; }
|
||||||
|
if (String(e.date).slice(0, 7) === mo) { m += e.amount; mc += 1; }
|
||||||
|
cats[e.category] = (cats[e.category] || 0) + 1;
|
||||||
|
});
|
||||||
|
return { d, w, m, counts: { daily: dc, weekly: wc, monthly: mc }, catCounts: cats };
|
||||||
|
}, [expenses]);
|
||||||
|
|
||||||
|
function editExpense(id) {
|
||||||
|
setEditingId(id);
|
||||||
|
setPendingDeleteId(null);
|
||||||
|
if (scrollRef.current && scrollRef.current.scrollTo) scrollRef.current.scrollTo({ y: 0, animated: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={s.safe}>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<ScrollView ref={scrollRef} style={s.scroll} contentContainerStyle={s.container}>
|
||||||
|
<View style={s.header}>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<Text style={s.headerTitle}>Pin & Penny</Text>
|
||||||
|
<Pressable onPress={() => setMenuOpen(!menuOpen)} accessibilityLabel={menuOpen ? "Close menu" : "Open records menu"}>
|
||||||
|
<Text style={[s.headerTotal, { fontSize: 20 }]}>{menuOpen ? "\u2715" : "\u2630"}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<Text style={s.headerTotal}>{money(stats.m, currency)} this month</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={s.card}>
|
||||||
|
<Text style={s.cardTitle}>{editing ? "Edit expense" : "Add expense"}</Text>
|
||||||
|
{!ready ? (
|
||||||
|
<Text style={s.emptyNote}>Loading…</Text>
|
||||||
|
) : (
|
||||||
|
<ExpenseForm
|
||||||
|
key={editing ? editing.id : "new"}
|
||||||
|
initial={
|
||||||
|
editing
|
||||||
|
? {
|
||||||
|
description: editing.description,
|
||||||
|
amount: String(editing.amount),
|
||||||
|
category: editing.category,
|
||||||
|
date: editing.date,
|
||||||
|
frequency: editing.frequency,
|
||||||
|
}
|
||||||
|
: blankForm()
|
||||||
|
}
|
||||||
|
submitLabel={editing ? "Save changes" : "Add expense"}
|
||||||
|
onSubmit={submitForm}
|
||||||
|
onCancel={editing ? () => setEditingId(null) : null}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{menuOpen ? (
|
||||||
|
<>
|
||||||
|
<View style={s.statsRow}>
|
||||||
|
<View style={s.stat}>
|
||||||
|
<Text style={s.statLabel}>Today</Text>
|
||||||
|
<Text style={s.statVal}>{money(stats.d, currency)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={s.stat}>
|
||||||
|
<Text style={s.statLabel}>This week</Text>
|
||||||
|
<Text style={s.statVal}>{money(stats.w, currency)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={s.stat}>
|
||||||
|
<Text style={s.statLabel}>This month</Text>
|
||||||
|
<Text style={s.statVal}>{money(stats.m, currency)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={s.card}>
|
||||||
|
<View style={s.tabs}>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<Pressable key={t} onPress={() => setTab(t)} style={[s.tab, tab === t && s.tabActive]}>
|
||||||
|
<Text style={[s.tabText, tab === t && s.tabTextActive]}>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)} {"\u00b7"} {stats.counts[t]}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={s.searchRow}>
|
||||||
|
<TextInput value={query} onChangeText={setQuery} placeholder="Search expenses…" style={s.input} />
|
||||||
|
</View>
|
||||||
|
<Text style={s.label}>Category</Text>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={s.chipRow}>
|
||||||
|
<View style={[s.chips, { marginTop: 0 }]}>
|
||||||
|
<Pressable onPress={() => setCatFilter("all")} style={[s.chip, catFilter === "all" && s.chipActive]}>
|
||||||
|
<Text style={[s.chipText, catFilter === "all" && s.chipTextActive]}>All</Text>
|
||||||
|
</Pressable>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<Pressable key={c.name} onPress={() => setCatFilter(c.name)} style={[s.chip, catFilter === c.name && s.chipActive]}>
|
||||||
|
<Text style={[s.chipText, catFilter === c.name && s.chipTextActive]}>{"\u25cf"} {c.name} <Text style={s.chipCount}>{"\u00b7"} {stats.catCounts[c.name] || 0}</Text></Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
<Text style={s.label}>Repeats</Text>
|
||||||
|
<View style={s.chips}>
|
||||||
|
{["all", "once", "daily", "weekly", "monthly"].map((f) => (
|
||||||
|
<Pressable key={f} onPress={() => setFreqFilter(f)} style={[s.chip, freqFilter === f && s.chipActive]}>
|
||||||
|
<Text style={[s.chipText, freqFilter === f && s.chipTextActive]}>
|
||||||
|
{f === "all" ? "All" : f === "once" ? "One-time" : f.charAt(0).toUpperCase() + f.slice(1)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={s.card}>
|
||||||
|
<Text style={s.cardTitle}>{tab.charAt(0).toUpperCase() + tab.slice(1)} summary</Text>
|
||||||
|
<SummaryBars entries={visible} scopeNoun={SCOPE_NOUN[tab]} currency={currency} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ExpenseGroups groups={groups} pendingDeleteId={pendingDeleteId} onEdit={editExpense} onDeleteRequest={requestDelete} currency={currency} />
|
||||||
|
|
||||||
|
<RepeatTemplates expenses={expenses} onLog={logNext} currency={currency} />
|
||||||
|
|
||||||
|
<View style={[s.card, { flexDirection: "row", gap: 8 }]}>
|
||||||
|
<Pressable onPress={() => update([...expenses, ...seedSamples()])} style={[s.btn, s.btnSubtle, { flex: 1 }]}>
|
||||||
|
<Text style={s.btnTextDark}>Load sample data</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setPendingDeleteId(null);
|
||||||
|
update([]);
|
||||||
|
}}
|
||||||
|
style={[s.btn, s.btnSubtle, { flex: 1 }]}
|
||||||
|
>
|
||||||
|
<Text style={[s.btnTextDark, { color: "#b91c1c" }]}>Clear all</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Text style={s.foot}>Saved privately on this device. Yesterday: {addDaysStr(todayStr(), -1)}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
expo-app/README.md
Normal file
53
expo-app/README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Pin & Penny — Expo app (web + mobile from one codebase)
|
||||||
|
|
||||||
|
React Native (Expo SDK 57) version of the expense tracker. The same code runs as a
|
||||||
|
website, an iOS app and an Android app. Data is stored on-device with AsyncStorage,
|
||||||
|
so it works on all three without a backend.
|
||||||
|
|
||||||
|
The original plain-web version still lives in the folder above this one and keeps
|
||||||
|
working as before; this folder is the future-proof rewrite.
|
||||||
|
|
||||||
|
## Screens and features
|
||||||
|
|
||||||
|
- **Daily / Weekly / Monthly tabs** — the same expenses regrouped by day, by week
|
||||||
|
(Monday–Sunday) or by month, each group with its own total.
|
||||||
|
- **Categories** — Food, Transport, Housing, Utilities, Shopping, Health,
|
||||||
|
Entertainment, Other.
|
||||||
|
- **Repeats** — mark an expense Daily, Weekly or Monthly. They appear under
|
||||||
|
“Repeating expenses” with the next due date and a **Log next occurrence** button.
|
||||||
|
- **Summary** — totals plus a per-category spending breakdown of whatever is shown.
|
||||||
|
- **INR amounts** — everything is shown in rupees (₹) with Indian digit grouping.
|
||||||
|
- **Search and filters**, **edit**, and two-tap **delete** (no popups, works
|
||||||
|
identically on web and phones).
|
||||||
|
- **Sample data** on first run; **Clear all** to start empty.
|
||||||
|
|
||||||
|
## Run it (needs Node.js 20+ — not available in this workspace)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd expo-app
|
||||||
|
npm install
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
- **Web** — press `w` in the terminal, or run `npm run web`. Opens the app in your browser.
|
||||||
|
- **Phone** — install the free **Expo Go** app, then scan the QR code the terminal shows.
|
||||||
|
- **Simulators** — press `a` (Android Studio emulator) or `i` (Xcode simulator, Mac only).
|
||||||
|
|
||||||
|
If versions ever drift, `npx expo install --fix` realigns them with the SDK.
|
||||||
|
|
||||||
|
## Going further later
|
||||||
|
|
||||||
|
- **Installable phone apps** (App Store / Play Store): `npm install -g eas-cli`, then
|
||||||
|
`eas build -p android` / `eas build -p ios`. No code changes needed.
|
||||||
|
- **Backend sync** (same data on web and phone): add any API later; only
|
||||||
|
`src/storage.js` needs to change — the UI and `src/logic.js` stay untouched.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `App.js` — screen state and layout
|
||||||
|
- `src/logic.js` — dates, totals, grouping, repeats (plain JS, no platform imports)
|
||||||
|
- `src/storage.js` — on-device persistence (AsyncStorage)
|
||||||
|
- `src/components/` — form, summary bars, grouped list, repeating templates
|
||||||
|
- `package.json` — every version pinned to a release verified on the npm registry
|
||||||
16
expo-app/app.json
Normal file
16
expo-app/app.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Pin & Penny",
|
||||||
|
"slug": "expense-tracker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"platforms": ["ios", "android", "web"],
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
expo-app/babel.config.js
Normal file
3
expo-app/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: ["babel-preset-expo"],
|
||||||
|
};
|
||||||
4
expo-app/index.js
Normal file
4
expo-app/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRootComponent } from "expo";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
registerRootComponent(App);
|
||||||
6305
expo-app/package-lock.json
generated
Normal file
6305
expo-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
expo-app/package.json
Normal file
30
expo-app/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "expense-tracker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo-google-fonts/petit-formal-script": "0.4.1",
|
||||||
|
"@expo-google-fonts/shadows-into-light-two": "0.4.1",
|
||||||
|
"@expo-google-fonts/style-script": "0.4.2",
|
||||||
|
"@expo-google-fonts/waterfall": "0.4.2",
|
||||||
|
"@expo/metro-runtime": "57.0.15",
|
||||||
|
"@react-native-async-storage/async-storage": "3.1.1",
|
||||||
|
"expo": "57.0.23",
|
||||||
|
"expo-font": "57.0.4",
|
||||||
|
"expo-status-bar": "57.0.1",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-native": "0.86.3",
|
||||||
|
"react-native-web": "0.21.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.20.0"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
17
expo-app/src/categories.js
Normal file
17
expo-app/src/categories.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/* Expense categories with their chart/dot colors. */
|
||||||
|
|
||||||
|
export const CATEGORIES = [
|
||||||
|
{ name: "Food", color: "#9ed6a4" },
|
||||||
|
{ name: "Transport", color: "#a9c8f0" },
|
||||||
|
{ name: "Housing", color: "#eab88f" },
|
||||||
|
{ name: "Utilities", color: "#8fd8c4" },
|
||||||
|
{ name: "Shopping", color: "#f5b98a" },
|
||||||
|
{ name: "Health", color: "#f2a3a3" },
|
||||||
|
{ name: "Entertainment", color: "#f2cd88" },
|
||||||
|
{ name: "Other", color: "#c9c9c9" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function colorFor(category) {
|
||||||
|
const found = CATEGORIES.find((c) => c.name === category);
|
||||||
|
return found ? found.color : "#c9c9c9";
|
||||||
|
}
|
||||||
90
expo-app/src/components/ExpenseForm.js
Normal file
90
expo-app/src/components/ExpenseForm.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Pressable, Text, TextInput, View } from "react-native";
|
||||||
|
import { CATEGORIES } from "../categories";
|
||||||
|
import { CURRENCY_SYMBOLS, FREQS, FREQ_LABEL, parseDay, todayStr } from "../logic";
|
||||||
|
import s from "../styles";
|
||||||
|
|
||||||
|
function Chip({ label, active, onPress, dot }) {
|
||||||
|
return (
|
||||||
|
<Pressable onPress={onPress} style={[s.chip, active && s.chipActive]}>
|
||||||
|
<Text style={[s.chipText, active && s.chipTextActive]}>
|
||||||
|
{dot ? "\u25cf " : ""}
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExpenseForm({ initial, submitLabel, onSubmit, onCancel, currency }) {
|
||||||
|
const [desc, setDesc] = useState(initial.description);
|
||||||
|
const [amount, setAmount] = useState(initial.amount);
|
||||||
|
const [cat, setCat] = useState(initial.category);
|
||||||
|
const [date, setDate] = useState(initial.date);
|
||||||
|
const [freq, setFreq] = useState(initial.frequency);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const d = desc.trim();
|
||||||
|
const a = Math.round(parseFloat(amount) * 100) / 100;
|
||||||
|
if (!d) return setErr("Please enter a description.");
|
||||||
|
if (!(a > 0)) return setErr("Please enter an amount greater than zero.");
|
||||||
|
if (!parseDay(date)) return setErr("Please use a real date like " + todayStr() + ".");
|
||||||
|
setErr("");
|
||||||
|
onSubmit({ description: d, amount: a, category: cat, date, frequency: freq });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={s.label}>Description</Text>
|
||||||
|
<TextInput value={desc} onChangeText={setDesc} placeholder="e.g. Groceries" maxLength={80} style={s.input} />
|
||||||
|
|
||||||
|
<View style={s.row2}>
|
||||||
|
<View style={s.half}>
|
||||||
|
<Text style={s.label}>Amount ({CURRENCY_SYMBOLS[currency] || "$"})</Text>
|
||||||
|
<TextInput
|
||||||
|
value={amount}
|
||||||
|
onChangeText={setAmount}
|
||||||
|
placeholder="0.00"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
style={s.input}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={s.half}>
|
||||||
|
<Text style={s.label}>Date</Text>
|
||||||
|
<TextInput value={date} onChangeText={setDate} placeholder="YYYY-MM-DD" maxLength={10} style={s.input} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable onPress={() => setDate(todayStr())} style={[s.btn, s.btnSubtle, { marginTop: 8, alignSelf: "flex-start" }]}>
|
||||||
|
<Text style={s.btnTextDark}>Use today</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text style={s.label}>Category</Text>
|
||||||
|
<View style={s.chips}>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<Chip key={c.name} label={c.name} dot active={cat === c.name} onPress={() => setCat(c.name)} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={s.label}>Repeats</Text>
|
||||||
|
<View style={s.chips}>
|
||||||
|
{FREQS.map((f) => (
|
||||||
|
<Chip key={f} label={FREQ_LABEL[f]} active={freq === f} onPress={() => setFreq(f)} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||||
|
|
||||||
|
<View style={s.btnRow}>
|
||||||
|
<Pressable onPress={submit} style={[s.btn, s.btnPrimary]}>
|
||||||
|
<Text style={s.btnText}>{submitLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
{onCancel ? (
|
||||||
|
<Pressable onPress={onCancel} style={[s.btn, s.btnSubtle]}>
|
||||||
|
<Text style={s.btnTextDark}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
expo-app/src/components/ExpenseGroups.js
Normal file
57
expo-app/src/components/ExpenseGroups.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { colorFor } from "../categories";
|
||||||
|
import { FREQ_LABEL, fmtDay, money } from "../logic";
|
||||||
|
import s from "../styles";
|
||||||
|
|
||||||
|
export default function ExpenseGroups({ groups, pendingDeleteId, onEdit, onDeleteRequest, currency }) {
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={s.card}>
|
||||||
|
<Text style={s.emptyNote}>No expenses yet. Add your first one above.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<View key={g.key} style={s.group}>
|
||||||
|
<View style={s.groupHead}>
|
||||||
|
<Text style={s.groupTitle}>{g.title}</Text>
|
||||||
|
<Text style={s.groupTotal}>{money(g.total, currency)}</Text>
|
||||||
|
</View>
|
||||||
|
{g.items.map((e, i) => {
|
||||||
|
const confirming = pendingDeleteId === e.id;
|
||||||
|
return (
|
||||||
|
<View key={e.id} style={[s.item, i === 0 && { borderTopWidth: 0 }]}>
|
||||||
|
<View style={[s.dot, { backgroundColor: colorFor(e.category) }]} />
|
||||||
|
<View style={s.itemMain}>
|
||||||
|
<Text style={s.itemDesc}>
|
||||||
|
{e.description}
|
||||||
|
{e.frequency !== "once" ? <Text style={s.freqTag}> {"\u00b7"} {FREQ_LABEL[e.frequency]}</Text> : null}
|
||||||
|
</Text>
|
||||||
|
<Text style={s.itemMeta}>
|
||||||
|
<Text style={s.subInline}>{e.category}</Text> {"\u00b7"} {fmtDay(e.date)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={s.itemAmt}>{money(e.amount, currency)}</Text>
|
||||||
|
<View style={s.itemOps}>
|
||||||
|
<Pressable onPress={() => onEdit(e.id)} style={s.linkBtn}>
|
||||||
|
<Text style={[s.small, s.editLink]}>Edit</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onDeleteRequest(e.id)}
|
||||||
|
style={[s.linkBtn, confirming && s.confirmBg]}
|
||||||
|
>
|
||||||
|
<Text style={confirming ? s.linkConfirm : s.linkDanger}>
|
||||||
|
{confirming ? "Sure?" : "Delete"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
expo-app/src/components/RepeatTemplates.js
Normal file
37
expo-app/src/components/RepeatTemplates.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { FREQ_LABEL, fmtDay, money, nextDue } from "../logic";
|
||||||
|
import s from "../styles";
|
||||||
|
|
||||||
|
export default function RepeatTemplates({ expenses, onLog, currency }) {
|
||||||
|
const temps = expenses
|
||||||
|
.filter((e) => e.frequency !== "once")
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (nextDue(a) < nextDue(b) ? -1 : 1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={s.card}>
|
||||||
|
<Text style={s.cardTitle}>Repeating expenses</Text>
|
||||||
|
<Text style={[s.muted, s.small, { marginBottom: 10 }]}>
|
||||||
|
Templates for bills and habits. Use Log to record each occurrence as it happens.
|
||||||
|
</Text>
|
||||||
|
{temps.length === 0 ? (
|
||||||
|
<Text style={s.emptyNote}>None yet. Set Repeats to Daily, Weekly or Monthly when adding an expense.</Text>
|
||||||
|
) : (
|
||||||
|
temps.map((t) => (
|
||||||
|
<View key={t.id} style={s.template}>
|
||||||
|
<View style={s.templateTop}>
|
||||||
|
<Text style={s.templateName}>{t.description}</Text>
|
||||||
|
<Text style={s.itemAmt}>{money(t.amount, currency)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={s.templateNext}>
|
||||||
|
<Text style={s.subInline}>{t.category}</Text> {"\u00b7"} repeats {FREQ_LABEL[t.frequency].toLowerCase()} {"\u00b7"} next {fmtDay(nextDue(t))}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => onLog(t.id)} style={[s.btn, s.btnSubtle, s.btnSmall, { alignSelf: "flex-start" }]}>
|
||||||
|
<Text style={s.btnSmallText}>Log next occurrence</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
expo-app/src/components/SummaryBars.js
Normal file
38
expo-app/src/components/SummaryBars.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { colorFor } from "../categories";
|
||||||
|
import { money } from "../logic";
|
||||||
|
import s from "../styles";
|
||||||
|
|
||||||
|
export default function SummaryBars({ entries, scopeNoun, currency }) {
|
||||||
|
let total = 0;
|
||||||
|
const byCat = {};
|
||||||
|
entries.forEach((e) => {
|
||||||
|
total += e.amount;
|
||||||
|
byCat[e.category] = (byCat[e.category] || 0) + e.amount;
|
||||||
|
});
|
||||||
|
const names = Object.keys(byCat).sort((a, b) => byCat[b] - byCat[a]);
|
||||||
|
const max = names.length ? byCat[names[0]] : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={s.sumLine}>
|
||||||
|
{entries.length} shown {"\u00b7"} {money(total, currency)} total, grouped by {scopeNoun}
|
||||||
|
</Text>
|
||||||
|
{names.length === 0 ? (
|
||||||
|
<Text style={s.emptyNote}>Nothing matches the current filters.</Text>
|
||||||
|
) : (
|
||||||
|
names.map((n) => (
|
||||||
|
<View key={n} style={s.barRow}>
|
||||||
|
<Text style={s.barName} numberOfLines={1}>
|
||||||
|
{n}
|
||||||
|
</Text>
|
||||||
|
<View style={s.barTrack}>
|
||||||
|
<View style={[s.barFill, { width: `${Math.max(2, Math.round((byCat[n] / max) * 100))}%`, backgroundColor: colorFor(n) }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={s.barAmt}>{money(byCat[n], currency)}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
expo-app/src/logic.js
Normal file
183
expo-app/src/logic.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/* Pure expense-tracker logic: dates, money, grouping, repeats, seeds.
|
||||||
|
No platform imports, so it runs unchanged on web, iOS and Android. */
|
||||||
|
|
||||||
|
const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
const MONTHS_LONG = [
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December",
|
||||||
|
];
|
||||||
|
const DAYS_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
|
export const FREQ_LABEL = { once: "One-time", daily: "Daily", weekly: "Weekly", monthly: "Monthly" };
|
||||||
|
export const FREQS = ["once", "daily", "weekly", "monthly"];
|
||||||
|
|
||||||
|
export function uid() {
|
||||||
|
return Date.now().toString(36) + Math.floor(Math.random() * 0xffffff).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n) {
|
||||||
|
return (n < 10 ? "0" : "") + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDateStr(d) {
|
||||||
|
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayStr() {
|
||||||
|
return toDateStr(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strict YYYY-MM-DD parse; returns a Date or null. */
|
||||||
|
export function parseDay(s) {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || ""));
|
||||||
|
if (!m) return null;
|
||||||
|
const d = new Date(+m[1], +m[2] - 1, +m[3]);
|
||||||
|
if (d.getFullYear() !== +m[1] || d.getMonth() !== +m[2] - 1 || d.getDate() !== +m[3]) return null;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDaysStr(s, n) {
|
||||||
|
const d = parseDay(s);
|
||||||
|
if (!d) return s;
|
||||||
|
d.setDate(d.getDate() + n);
|
||||||
|
return toDateStr(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDay(s) {
|
||||||
|
const d = parseDay(s);
|
||||||
|
if (!d) return String(s);
|
||||||
|
return MONTHS_SHORT[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtWeekday(s) {
|
||||||
|
const d = parseDay(s);
|
||||||
|
return d ? DAYS_LONG[d.getDay()] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Weeks start on Monday; the key is the Monday date string. */
|
||||||
|
export function mondayOf(s) {
|
||||||
|
const d = parseDay(s);
|
||||||
|
if (!d) return s;
|
||||||
|
const shift = (d.getDay() + 6) % 7;
|
||||||
|
d.setDate(d.getDate() - shift);
|
||||||
|
return toDateStr(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtWeekRange(monday) {
|
||||||
|
const a = parseDay(monday);
|
||||||
|
if (!a) return String(monday);
|
||||||
|
const b = new Date(a);
|
||||||
|
b.setDate(b.getDate() + 6);
|
||||||
|
const left = MONTHS_SHORT[a.getMonth()] + " " + a.getDate();
|
||||||
|
const right =
|
||||||
|
a.getMonth() === b.getMonth()
|
||||||
|
? b.getDate() + ", " + b.getFullYear()
|
||||||
|
: MONTHS_SHORT[b.getMonth()] + " " + b.getDate() + ", " + b.getFullYear();
|
||||||
|
return left + " \u2013 " + right;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtMonthTitle(key) {
|
||||||
|
const m = /^(\d{4})-(\d{2})$/.exec(String(key || ""));
|
||||||
|
if (!m) return String(key);
|
||||||
|
return MONTHS_LONG[+m[2] - 1] + " " + m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* INR-only display (stored amounts are never converted).
|
||||||
|
Manual formatting without Intl, so output matches on every OS. */
|
||||||
|
export const CURRENCIES = ["INR"];
|
||||||
|
export const CURRENCY_SYMBOLS = { INR: "₹" };
|
||||||
|
|
||||||
|
function groupInt(int, currency) {
|
||||||
|
if (currency === "INR") {
|
||||||
|
// Indian grouping: final 3 digits, then pairs (12,34,567).
|
||||||
|
const tail = int.slice(-3);
|
||||||
|
let head = int.slice(0, -3);
|
||||||
|
if (!head) return tail;
|
||||||
|
const parts = [];
|
||||||
|
while (head.length > 2) {
|
||||||
|
parts.unshift(head.slice(-2));
|
||||||
|
head = head.slice(0, -2);
|
||||||
|
}
|
||||||
|
if (head) parts.unshift(head);
|
||||||
|
return parts.join(",") + "," + tail;
|
||||||
|
}
|
||||||
|
return int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deterministic currency formatting; unknown codes fall back to USD. */
|
||||||
|
export function money(n, currency) {
|
||||||
|
const code = CURRENCY_SYMBOLS[currency] ? currency : "USD";
|
||||||
|
const sym = CURRENCY_SYMBOLS[code];
|
||||||
|
const neg = n < 0;
|
||||||
|
const factor = code === "JPY" ? 1 : 100;
|
||||||
|
const v = Math.abs(Math.round(n * factor));
|
||||||
|
const frac = code === "JPY" ? "" : "." + String(v % 100).padStart(2, "0");
|
||||||
|
const int = groupInt(String(Math.floor(v / factor)), code);
|
||||||
|
return (neg ? "-" : "") + sym + int + frac;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextDue(t) {
|
||||||
|
const base = t.lastLogged || t.date;
|
||||||
|
if (t.frequency === "daily") return addDaysStr(base, 1);
|
||||||
|
if (t.frequency === "weekly") return addDaysStr(base, 7);
|
||||||
|
const d = parseDay(base);
|
||||||
|
if (!d) return base;
|
||||||
|
const day = d.getDate();
|
||||||
|
d.setDate(1);
|
||||||
|
d.setMonth(d.getMonth() + 1);
|
||||||
|
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
||||||
|
d.setDate(Math.min(day, last));
|
||||||
|
return toDateStr(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupKey(exp, tab) {
|
||||||
|
if (tab === "daily") return exp.date;
|
||||||
|
if (tab === "weekly") return mondayOf(exp.date);
|
||||||
|
return String(exp.date).slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTitle(key, tab) {
|
||||||
|
if (tab === "daily") {
|
||||||
|
const t = todayStr();
|
||||||
|
let label = fmtDay(key);
|
||||||
|
if (key === t) label += " (today)";
|
||||||
|
else if (key === addDaysStr(t, -1)) label += " (yesterday)";
|
||||||
|
return label + " \u00b7 " + fmtWeekday(key);
|
||||||
|
}
|
||||||
|
if (tab === "weekly") return "Week of " + fmtWeekRange(key);
|
||||||
|
return fmtMonthTitle(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sample data relative to today so every view has content on first run. */
|
||||||
|
export function seedSamples() {
|
||||||
|
const t = todayStr();
|
||||||
|
const first = t.slice(0, 8) + "01";
|
||||||
|
const now = Date.now();
|
||||||
|
function mk(desc, amount, cat, date, freq) {
|
||||||
|
return {
|
||||||
|
id: uid() + Math.floor(Math.random() * 1e6).toString(36),
|
||||||
|
description: desc,
|
||||||
|
amount,
|
||||||
|
category: cat,
|
||||||
|
date,
|
||||||
|
frequency: freq || "once",
|
||||||
|
lastLogged: null,
|
||||||
|
createdAt: 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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
29
expo-app/src/storage.js
Normal file
29
expo-app/src/storage.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/* Persistence via AsyncStorage, which works on web, iOS and Android. */
|
||||||
|
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { seedSamples } from "./logic";
|
||||||
|
|
||||||
|
const KEY = "expense-tracker.expenses.v1";
|
||||||
|
|
||||||
|
export async function loadExpenses() {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY);
|
||||||
|
if (raw === null) {
|
||||||
|
const seed = seedSamples();
|
||||||
|
await AsyncStorage.setItem(KEY, JSON.stringify(seed));
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
const arr = JSON.parse(raw);
|
||||||
|
return Array.isArray(arr) ? arr : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveExpenses(list) {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(KEY, JSON.stringify(list));
|
||||||
|
} catch (e) {
|
||||||
|
// Storage full or unavailable; the in-memory list still works this session.
|
||||||
|
}
|
||||||
|
}
|
||||||
114
expo-app/src/styles.js
Normal file
114
expo-app/src/styles.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
/* The Tasks app "Cute" theme, matching web styles.css: soft pink canvas,
|
||||||
|
lavender drawer, gentle bright-pink accent, deep plum ink.
|
||||||
|
DISPLAY = headings/totals, BODY = all smaller text (kept at normal weight
|
||||||
|
since the body face ships a single regular cut). */
|
||||||
|
export const palette = {
|
||||||
|
bg: "#ffe3ee",
|
||||||
|
card: "#fffafc",
|
||||||
|
ink: "#6e3350",
|
||||||
|
muted: "#a76a89",
|
||||||
|
line: "#d5aec0",
|
||||||
|
header: "#6e3350",
|
||||||
|
headerText: "#fff8e7",
|
||||||
|
accent: "#ff6fae",
|
||||||
|
accentDark: "#ff4f9a",
|
||||||
|
accentText: "#d63384",
|
||||||
|
danger: "#b91c1c",
|
||||||
|
track: "#ffe0ee",
|
||||||
|
inputBg: "#fffafc",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DISPLAY = "PetitFormalScript_400Regular";
|
||||||
|
const BODY = "ShadowsIntoLightTwo_400Regular";
|
||||||
|
const SUB = "StyleScript_400Regular";
|
||||||
|
const BTN = "Waterfall_400Regular";
|
||||||
|
|
||||||
|
export default StyleSheet.create({
|
||||||
|
safe: { flex: 1, backgroundColor: palette.bg },
|
||||||
|
scroll: { flex: 1 },
|
||||||
|
container: { padding: 16, paddingBottom: 48, maxWidth: 720, width: "100%", alignSelf: "center" },
|
||||||
|
|
||||||
|
header: { backgroundColor: "#ffd3e3", borderRadius: 12, padding: 18, marginBottom: 12 },
|
||||||
|
headerTitle: { color: palette.header, fontSize: 27, fontWeight: "700", fontFamily: DISPLAY },
|
||||||
|
headerTotal: { color: palette.accentText, fontSize: 15, fontWeight: "700", marginTop: 8, fontFamily: DISPLAY },
|
||||||
|
|
||||||
|
card: { backgroundColor: palette.card, borderColor: palette.line, borderWidth: 1, borderRadius: 12, padding: 16, marginBottom: 12 },
|
||||||
|
cardTitle: { fontSize: 20, fontWeight: "700", color: palette.ink, marginBottom: 10, fontFamily: DISPLAY },
|
||||||
|
|
||||||
|
statsRow: { flexDirection: "row", gap: 10, marginBottom: 12 },
|
||||||
|
stat: { flex: 1, backgroundColor: palette.card, borderColor: palette.line, borderWidth: 1, borderRadius: 12, padding: 12 },
|
||||||
|
statLabel: { color: palette.muted, fontSize: 12, fontFamily: SUB },
|
||||||
|
statVal: { fontSize: 23, fontWeight: "700", color: palette.ink, marginTop: 2, fontVariant: ["tabular-nums"], fontFamily: DISPLAY },
|
||||||
|
|
||||||
|
tabs: { flexDirection: "row", backgroundColor: "#ffd9e8", borderRadius: 999, padding: 4, marginBottom: 12 },
|
||||||
|
tab: { flex: 1, paddingVertical: 8, borderRadius: 999, alignItems: "center" },
|
||||||
|
tabActive: { backgroundColor: palette.header },
|
||||||
|
tabText: { color: palette.muted, fontWeight: "400", fontFamily: SUB },
|
||||||
|
tabTextActive: { color: "#fff" },
|
||||||
|
|
||||||
|
label: { color: palette.muted, fontSize: 13, marginBottom: 4, marginTop: 10, fontFamily: SUB },
|
||||||
|
input: {
|
||||||
|
backgroundColor: palette.inputBg, borderColor: palette.line, borderWidth: 1,
|
||||||
|
borderRadius: 8, paddingHorizontal: 10, paddingVertical: 9, fontSize: 15, color: palette.ink,
|
||||||
|
fontFamily: BODY,
|
||||||
|
},
|
||||||
|
row2: { flexDirection: "row", gap: 10 },
|
||||||
|
half: { flex: 1 },
|
||||||
|
err: { color: palette.danger, fontSize: 13, marginTop: 8, fontFamily: BODY },
|
||||||
|
|
||||||
|
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 6 },
|
||||||
|
chip: { borderWidth: 1, borderColor: palette.line, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7, backgroundColor: palette.inputBg },
|
||||||
|
chipActive: { backgroundColor: palette.header, borderColor: palette.header },
|
||||||
|
chipText: { color: palette.muted, fontWeight: "400", fontSize: 13, fontFamily: SUB },
|
||||||
|
chipCount: { fontSize: 13, fontFamily: BODY },
|
||||||
|
chipTextActive: { color: "#fff" },
|
||||||
|
chipRow: { marginBottom: 4 },
|
||||||
|
|
||||||
|
btnRow: { flexDirection: "row", gap: 8, marginTop: 14 },
|
||||||
|
btn: { borderRadius: 9, paddingHorizontal: 14, paddingVertical: 10, alignItems: "center" },
|
||||||
|
btnPrimary: { backgroundColor: palette.accent, flex: 1 },
|
||||||
|
btnSubtle: { backgroundColor: "#ffe0ee", borderColor: palette.line, borderWidth: 1 },
|
||||||
|
btnText: { color: "#fff", fontWeight: "400", fontSize: 17, fontFamily: BTN },
|
||||||
|
btnTextDark: { color: palette.ink, fontWeight: "400", fontSize: 17, fontFamily: BTN },
|
||||||
|
btnSmall: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 7 },
|
||||||
|
btnSmallText: { fontSize: 15, fontWeight: "400", color: palette.ink, fontFamily: BTN },
|
||||||
|
linkBtn: { paddingHorizontal: 10, paddingVertical: 6 },
|
||||||
|
editLink: { color: palette.accentText, fontWeight: "400", fontSize: 16, fontFamily: BTN },
|
||||||
|
linkDanger: { color: palette.danger, fontWeight: "400", fontSize: 16, fontFamily: BTN },
|
||||||
|
linkConfirm: { color: "#fff", fontWeight: "400", fontSize: 16, fontFamily: BTN },
|
||||||
|
confirmBg: { backgroundColor: palette.danger, borderRadius: 7 },
|
||||||
|
|
||||||
|
sumLine: { color: palette.muted, fontSize: 13, marginBottom: 10, fontFamily: BODY },
|
||||||
|
barRow: { flexDirection: "row", alignItems: "center", gap: 10, marginBottom: 8 },
|
||||||
|
barName: { width: 110, fontSize: 13, color: palette.ink, fontFamily: SUB },
|
||||||
|
barTrack: { flex: 1, backgroundColor: palette.track, borderRadius: 6, height: 12, overflow: "hidden" },
|
||||||
|
barFill: { height: 12, borderRadius: 6 },
|
||||||
|
barAmt: { width: 76, textAlign: "right", fontSize: 13, fontWeight: "400", fontVariant: ["tabular-nums"], fontFamily: BODY },
|
||||||
|
|
||||||
|
group: { marginBottom: 12, borderRadius: 10, overflow: "hidden", borderColor: palette.line, borderWidth: 1 },
|
||||||
|
groupHead: { backgroundColor: palette.header, paddingHorizontal: 14, paddingVertical: 10, flexDirection: "row", justifyContent: "space-between", alignItems: "center", gap: 10 },
|
||||||
|
groupTitle: { color: palette.headerText, fontSize: 17, fontWeight: "700", flex: 1, fontFamily: DISPLAY },
|
||||||
|
groupTotal: { color: palette.headerText, fontWeight: "700", fontVariant: ["tabular-nums"], fontFamily: DISPLAY },
|
||||||
|
item: { flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 14, paddingVertical: 11, backgroundColor: palette.card, borderTopWidth: 1, borderTopColor: palette.line },
|
||||||
|
dot: { width: 11, height: 11, borderRadius: 6 },
|
||||||
|
itemMain: { flex: 1 },
|
||||||
|
itemDesc: { fontWeight: "400", color: palette.ink, fontSize: 14, fontFamily: BODY },
|
||||||
|
freqTag: { fontSize: 11, fontWeight: "400", color: palette.accentText, fontFamily: SUB },
|
||||||
|
itemMeta: { color: palette.muted, fontSize: 12.5, marginTop: 1, fontFamily: BODY },
|
||||||
|
itemAmt: { fontWeight: "400", fontVariant: ["tabular-nums"], color: palette.ink, fontFamily: BODY },
|
||||||
|
itemOps: { flexDirection: "row", gap: 2, alignItems: "center" },
|
||||||
|
|
||||||
|
template: { borderColor: palette.line, borderWidth: 1, borderRadius: 8, padding: 10, backgroundColor: palette.inputBg, marginBottom: 8 },
|
||||||
|
templateTop: { flexDirection: "row", justifyContent: "space-between", gap: 8 },
|
||||||
|
templateName: { fontWeight: "400", color: palette.ink, flex: 1, fontFamily: BODY },
|
||||||
|
templateNext: { color: palette.muted, fontSize: 12.5, marginVertical: 4, fontFamily: BODY },
|
||||||
|
|
||||||
|
muted: { color: palette.muted, fontFamily: BODY },
|
||||||
|
subInline: { fontFamily: SUB },
|
||||||
|
small: { fontSize: 13, fontFamily: BODY },
|
||||||
|
emptyNote: { color: palette.muted, fontSize: 14, fontFamily: BODY },
|
||||||
|
foot: { color: palette.muted, fontSize: 12.5, textAlign: "center", marginTop: 4, fontFamily: BODY },
|
||||||
|
searchRow: { marginBottom: 4 },
|
||||||
|
});
|
||||||
169
index.html
Normal file
169
index.html
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pin & Penny — Daily, Weekly, Monthly</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Petit+Formal+Script&family=Shadows+Into+Light+Two&family=Style+Script&family=Waterfall&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar-mini">
|
||||||
|
<button type="button" id="btnMenu" class="menu-btn" aria-label="Open menu" aria-expanded="false">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-dasharray="0.1 5" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="topbar-brand">Pin & Penny</span>
|
||||||
|
<span class="topbar-month" id="topMonth">$0.00</span>
|
||||||
|
</header>
|
||||||
|
<div class="backdrop" id="backdrop" hidden></div>
|
||||||
|
<div class="wrap layout">
|
||||||
|
<aside class="side" id="drawer" aria-label="Menu">
|
||||||
|
<button type="button" class="nav-item nav-add" id="navAdd">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
<span>Add expense</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="side-search">
|
||||||
|
<input type="search" id="q" placeholder="Search expenses…" aria-label="Search expenses">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="views" aria-label="Views">
|
||||||
|
<button type="button" class="nav-item active" data-tab="daily" aria-selected="true">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>
|
||||||
|
<span>Daily</span>
|
||||||
|
<span class="count" id="countDaily">0</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nav-item" data-tab="weekly" aria-selected="false">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="4" width="18" height="17" rx="2"/><path d="M9.5 4v13M15 4v13"/></svg>
|
||||||
|
<span>Weekly</span>
|
||||||
|
<span class="count" id="countWeekly">0</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nav-item" data-tab="monthly" aria-selected="false">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M10 10v11M17 10v11"/></svg>
|
||||||
|
<span>Monthly</span>
|
||||||
|
<span class="count" id="countMonthly">0</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nav-item" data-tab="all" aria-selected="false">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
<span>All expenses</span>
|
||||||
|
<span class="count" id="countAll">0</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h2 class="side-h">Categories</h2>
|
||||||
|
<ul class="cats" id="catList"></ul>
|
||||||
|
|
||||||
|
<div class="side-foot">
|
||||||
|
<label class="field compact">
|
||||||
|
<span>Repeats</span>
|
||||||
|
<select id="fFreqFilter" aria-label="Filter by repeat">
|
||||||
|
<option value="all">All repeats</option>
|
||||||
|
<option value="once">One-time</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="month-total">
|
||||||
|
<span>This month</span>
|
||||||
|
<strong id="sideMonth">$0.00</strong>
|
||||||
|
</div>
|
||||||
|
<div class="side-btns">
|
||||||
|
<button type="button" id="btnSamples" class="btn ghost-light">Samples</button>
|
||||||
|
<button type="button" id="btnClear" class="btn ghost-light">Clear all</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="view-head" id="viewHead">
|
||||||
|
<h2 id="viewTitle">Daily</h2>
|
||||||
|
<p class="muted" id="viewSub"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card" id="addCard" aria-labelledby="formTitle">
|
||||||
|
<h2 id="formTitle">Add expense</h2>
|
||||||
|
<form id="expForm" novalidate>
|
||||||
|
<label class="field">
|
||||||
|
<span>Description</span>
|
||||||
|
<input type="text" id="fDesc" placeholder="e.g. Groceries" maxlength="80" required>
|
||||||
|
</label>
|
||||||
|
<div class="row2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Amount (₹)</span>
|
||||||
|
<input type="number" id="fAmount" placeholder="0.00" min="0.01" step="0.01" required>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Date</span>
|
||||||
|
<input type="date" id="fDate" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row2">
|
||||||
|
<label class="field">
|
||||||
|
<span>Category</span>
|
||||||
|
<select id="fCat"></select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Repeats</span>
|
||||||
|
<select id="fFreq">
|
||||||
|
<option value="once">One-time</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="form-err" id="formErr" role="alert" hidden></p>
|
||||||
|
<div class="form-btns">
|
||||||
|
<button type="submit" class="btn primary" id="btnSave">Add expense</button>
|
||||||
|
<button type="button" class="btn subtle" id="btnCancel" hidden>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stats" id="statsSection" aria-label="Current totals">
|
||||||
|
<div class="stat card">
|
||||||
|
<span class="stat-label">Spent today</span>
|
||||||
|
<strong class="stat-val" id="statToday">$0.00</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat card">
|
||||||
|
<span class="stat-label">Spent this week</span>
|
||||||
|
<strong class="stat-val" id="statWeek">$0.00</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat card">
|
||||||
|
<span class="stat-label">Spent this month</span>
|
||||||
|
<strong class="stat-val" id="statMonth">$0.00</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="datePicker" hidden></div>
|
||||||
|
<div id="monthPicker" hidden></div>
|
||||||
|
<div id="weekPicker" hidden></div>
|
||||||
|
|
||||||
|
<section class="card" id="sumCard" aria-labelledby="sumTitle">
|
||||||
|
<div class="sum-head">
|
||||||
|
<h2 id="sumTitle">Summary</h2>
|
||||||
|
<p class="muted" id="sumLine"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bars" id="bars"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Expense list" id="listSection">
|
||||||
|
<div id="list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="templatesCard" aria-labelledby="recTitle">
|
||||||
|
<h2 id="recTitle">Repeating expenses</h2>
|
||||||
|
<p class="muted small">Templates for bills and habits. Use Log to record each occurrence as it happens.</p>
|
||||||
|
<ul class="templates" id="templates"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="muted small foot">Your expenses are saved privately in this browser (local storage). No account, no server upload.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
server.py
Normal file
20
server.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Project runner: serves the expense tracker with zero dependencies."""
|
||||||
|
import functools
|
||||||
|
import http.server
|
||||||
|
import os
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("PORT", "8000"))
|
||||||
|
HOST = os.environ.get("HOST", "0.0.0.0")
|
||||||
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
handler = functools.partial(
|
||||||
|
http.server.SimpleHTTPRequestHandler, directory=ROOT
|
||||||
|
)
|
||||||
|
server = http.server.ThreadingHTTPServer((HOST, PORT), handler)
|
||||||
|
print(
|
||||||
|
"Pin & Penny running at http://%s:%d (open http://localhost:%d in your browser)"
|
||||||
|
% (HOST, PORT, PORT),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
server.serve_forever()
|
||||||
413
styles.css
Normal file
413
styles.css
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
:root {
|
||||||
|
/* Inspired by photos/download (1) - Copy.jpg: pastel-yellow base
|
||||||
|
with a soft pink aura at the center. */
|
||||||
|
/* The Tasks app "Cute" theme: soft pink canvas, gentle bright-pink
|
||||||
|
accent, deep plum ink. The drawer carries a yellow/pink/blue ombre. */
|
||||||
|
--bg: #ffe3ee;
|
||||||
|
--card: #fffafc;
|
||||||
|
--ink: #6e3350;
|
||||||
|
--muted: #a76a89;
|
||||||
|
--line: #d5aec0;
|
||||||
|
--accent: #ff6fae;
|
||||||
|
--accent-dark: #ff4f9a;
|
||||||
|
--accent-text: #d63384;
|
||||||
|
--header: #6e3350;
|
||||||
|
--danger: #b91c1c;
|
||||||
|
--track: #ffe0ee;
|
||||||
|
/* Type roles (loaded from Google Fonts in index.html): headings in
|
||||||
|
Petit Formal Script, subject text in Shadows Into Light Two,
|
||||||
|
subheadings in Style Script. */
|
||||||
|
--font-display: "Petit Formal Script", "Snell Roundhand", cursive;
|
||||||
|
--font-sub: "Style Script", "Snell Roundhand", cursive;
|
||||||
|
--font-btn: "Waterfall", "Snell Roundhand", cursive;
|
||||||
|
}
|
||||||
|
h1, h2, h3, .stat-val, .group-total { font-family: var(--font-display); }
|
||||||
|
.stat-label, .field > span, .view-head p { font-family: var(--font-sub); }
|
||||||
|
/* Subheadings: names that title a smaller unit — views, categories, repeat
|
||||||
|
tags, picker options. Counts and data stay in the subject face. */
|
||||||
|
.nav-item, .cat-item, .mp-cell, .wp-row, .badge { font-family: var(--font-sub); }
|
||||||
|
.nav-item .count, .cat-item .count, .mp-cell .count, .wp-row .count {
|
||||||
|
font-family: "Shadows Into Light Two", "Segoe Print", cursive;
|
||||||
|
}
|
||||||
|
.sub { font-family: var(--font-sub); }
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "Shadows Into Light Two", "Segoe Print", cursive;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.45;
|
||||||
|
background-color: var(--bg);
|
||||||
|
/* Cute canvas: dual dot grid, darker ombre up top, then pastel
|
||||||
|
pink + blue + yellow washes. */
|
||||||
|
background-image:
|
||||||
|
radial-gradient(#ffffff8c 0.5px, transparent 0.7px),
|
||||||
|
radial-gradient(#7b2e501c 0.5px, transparent 0.7px),
|
||||||
|
linear-gradient(180deg, rgba(255, 183, 205, 0.55) 0%, rgba(255, 227, 238, 0) 34%),
|
||||||
|
radial-gradient(135% 115% at 8% 0%, #ffc6def2, #ffc6de00 52%),
|
||||||
|
radial-gradient(120% 115% at 96% 6%, #c9e3ffe6, #c9e3ff00 48%),
|
||||||
|
radial-gradient(130% 130% at 50% 108%, #ffedb2e6, #ffedb200 55%);
|
||||||
|
background-position: 0 0, 3px 3px, 0 0, 0 0, 0 0, 0 0;
|
||||||
|
background-repeat: repeat, repeat, no-repeat, no-repeat, no-repeat, no-repeat;
|
||||||
|
background-size: 6px 6px, 6px 6px, 100% 100%, 100% 100%, 100% 100%, 100% 100%;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 18px; }
|
||||||
|
|
||||||
|
/* Slim top bar: menu button + brand + month total. */
|
||||||
|
.topbar-mini {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
/* Ombre: deeper pink at the very top melting into the canvas — no edge. */
|
||||||
|
background: linear-gradient(180deg, rgba(255, 200, 218, 0.95) 0%, rgba(255, 227, 238, 0) 100%);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
color: var(--header);
|
||||||
|
padding: 10px 18px 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
.menu-btn {
|
||||||
|
font-family: var(--font-btn);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--header);
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.menu-btn:hover { border-color: var(--header); }
|
||||||
|
.topbar-brand { font-family: "Petit Formal Script", "Snell Roundhand", cursive; font-size: 24px; }
|
||||||
|
.topbar-month {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawer: sidebar hides off-canvas until the menu button opens it. */
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(74, 47, 58, 0.35);
|
||||||
|
z-index: 45;
|
||||||
|
}
|
||||||
|
.backdrop[hidden] { display: none; }
|
||||||
|
.side {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; bottom: 0; left: 0;
|
||||||
|
width: min(300px, 86vw);
|
||||||
|
overflow-y: auto;
|
||||||
|
/* Prominent pastel yellow → pink → blue ombre. */
|
||||||
|
background: linear-gradient(165deg, #fff3c2 0%, #ffd9e8 48%, #cfe4ff 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
padding: 16px 14px 24px;
|
||||||
|
z-index: 50;
|
||||||
|
transform: translateX(-105%);
|
||||||
|
transition: transform 0.22s ease-out;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
body.nav-open .side { transform: none; }
|
||||||
|
.nav-add { background: var(--accent); color: #fff; }
|
||||||
|
.nav-add:hover { background: var(--accent-dark); }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
/* (Currency is INR-only; no picker.) */
|
||||||
|
|
||||||
|
/* Layout: single centered column; the sidebar is an off-canvas drawer. */
|
||||||
|
.layout { display: grid; grid-template-columns: 1fr; gap: 18px; padding-top: 20px; padding-bottom: 40px; align-items: start; }
|
||||||
|
.side { display: grid; gap: 14px; align-content: start; }
|
||||||
|
.content { display: grid; gap: 18px; min-width: 0; max-width: 720px; width: 100%; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* Sidebar search */
|
||||||
|
.side-search input {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.side-search input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* Sidebar view nav: icon + label + count, active is deep plum. */
|
||||||
|
.views { display: grid; gap: 6px; }
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-family: var(--font-sub);
|
||||||
|
color: var(--ink);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: #ffe0ee; }
|
||||||
|
.nav-item.active { background: var(--header); color: #fff; }
|
||||||
|
.nav-item svg { flex: none; }
|
||||||
|
.nav-item .count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #ffe0ee;
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 9px;
|
||||||
|
}
|
||||||
|
.nav-item.active .count { background: rgba(255, 255, 255, 0.24); color: #fff; }
|
||||||
|
|
||||||
|
/* Sidebar categories */
|
||||||
|
.side-h {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--header);
|
||||||
|
background: #c9e3ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
.cats { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||||
|
.cat-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-family: var(--font-sub);
|
||||||
|
color: var(--ink);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.cat-item:hover { background: #ffe0ee; }
|
||||||
|
.cat-item.active { background: var(--header); color: #fff; }
|
||||||
|
.cat-item .count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #ffe0ee;
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 9px;
|
||||||
|
}
|
||||||
|
.cat-item.active .count { background: rgba(255, 255, 255, 0.24); color: #fff; }
|
||||||
|
|
||||||
|
/* Sidebar footer card */
|
||||||
|
.side-foot {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.field.compact { margin-bottom: 0; }
|
||||||
|
.month-total {
|
||||||
|
display: flex; justify-content: space-between; align-items: baseline; gap: 8px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.month-total strong { font-family: var(--font-display); font-weight: 400; font-size: 19px; color: var(--ink); }
|
||||||
|
.side-btns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
|
||||||
|
/* Main view header: big title + date subline, like the Tasks Today view. */
|
||||||
|
.view-head h2 { margin: 0; font-size: 35px; }
|
||||||
|
.view-head p { margin: 4px 0 0; }
|
||||||
|
|
||||||
|
/* Month picker: year stepper + calendar-like month grid with counts. */
|
||||||
|
.mp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||||
|
.mp-head strong { font-family: var(--font-display); font-weight: 400; font-size: 19px; }
|
||||||
|
.icon-btn.mp-nav { font-size: 24px; line-height: 1; padding: 2px 14px 6px; background: var(--accent); color: #fff; border-radius: 10px; }
|
||||||
|
.icon-btn.mp-nav:hover { background: var(--accent-dark); color: #fff; }
|
||||||
|
.mp-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||||
|
@media (max-width: 500px) { .mp-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
.mp-cell {
|
||||||
|
display: grid; gap: 2px; justify-items: center;
|
||||||
|
font: inherit;
|
||||||
|
font-family: var(--font-sub);
|
||||||
|
color: var(--ink);
|
||||||
|
background: #ffe0ee;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mp-cell:hover { border-color: var(--accent); }
|
||||||
|
.mp-cell.active { background: var(--header); border-color: var(--header); color: #fff; }
|
||||||
|
.mp-cell.active .count { background: rgba(255, 255, 255, 0.24); color: #fff; }
|
||||||
|
.mp-cell .count {
|
||||||
|
font-size: 12px;
|
||||||
|
background: #ffd9e8;
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date picker: calendar input + today shortcut + recent dates. */
|
||||||
|
.dp-input {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
max-width: 170px;
|
||||||
|
}
|
||||||
|
.dp-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.dp-today { margin-bottom: 10px; }
|
||||||
|
|
||||||
|
/* Week picker: stepper head plus week rows with counts. */
|
||||||
|
.wp-list { display: grid; gap: 8px; }
|
||||||
|
.wp-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-family: var(--font-sub);
|
||||||
|
color: var(--ink);
|
||||||
|
background: #ffe0ee;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.wp-row:hover { border-color: var(--accent); }
|
||||||
|
.wp-row.active { background: var(--header); border-color: var(--header); color: #fff; }
|
||||||
|
.wp-row.active .count { background: rgba(255, 255, 255, 0.24); color: #fff; }
|
||||||
|
.wp-row .count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #ffd9e8;
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
scroll-margin-top: 76px;
|
||||||
|
}
|
||||||
|
.card h2 { margin: 0 0 12px; font-size: 20px; }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||||
|
@media (max-width: 600px) { .stats { grid-template-columns: 1fr; } }
|
||||||
|
.stat-label { color: var(--muted); font-size: 13px; }
|
||||||
|
.stat-val { display: block; font-size: 30px; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.field { display: grid; gap: 5px; margin-bottom: 12px; font-size: 13px; color: var(--muted); }
|
||||||
|
.field input, .field select {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.form-err { color: var(--danger); font-size: 13px; margin: 0 0 10px; }
|
||||||
|
.form-btns { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
font: inherit;
|
||||||
|
font-family: var(--font-btn);
|
||||||
|
font-size: 17px;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.btn.primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn.primary:hover { background: var(--accent-dark); }
|
||||||
|
.btn.subtle { background: #ffe0ee; color: var(--ink); border-color: var(--line); }
|
||||||
|
.btn.ghost-light { background: var(--card); color: var(--header); border-color: var(--line); font-size: 16px; padding: 7px 12px; }
|
||||||
|
.btn.ghost-light:hover { border-color: var(--header); }
|
||||||
|
.btn.small { font-size: 17px; padding: 6px 12px; }
|
||||||
|
.btn.danger-ghost { background: transparent; color: var(--danger); border-color: #e8a8b4; }
|
||||||
|
/* Front page: larger subheadings and buttons on the add-expense card. */
|
||||||
|
#addCard .field > span { font-size: 16px; }
|
||||||
|
#addCard .btn { font-size: 20px; padding: 11px 18px; }
|
||||||
|
.icon-btn { font-family: var(--font-btn); font-size: 18px; background: none; border: none; cursor: pointer; color: var(--muted); padding: 4px; border-radius: 6px; }
|
||||||
|
.icon-btn:hover { color: var(--ink); background: #ffe0ee; }
|
||||||
|
.icon-btn svg { display: block; }
|
||||||
|
|
||||||
|
/* (Views and repeats filters live in the sidebar now.) */
|
||||||
|
|
||||||
|
/* Summary bars */
|
||||||
|
.sum-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.sum-head h2 { margin: 0; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: 13px; }
|
||||||
|
.bars { display: grid; gap: 10px; margin-top: 12px; }
|
||||||
|
.bar-row { display: grid; grid-template-columns: 130px 1fr 76px; gap: 10px; align-items: center; font-size: 13.5px; }
|
||||||
|
.bar-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--font-sub); }
|
||||||
|
.bar-track { background: var(--track); border-radius: 6px; height: 12px; overflow: hidden; }
|
||||||
|
.bar-fill { height: 100%; border-radius: 6px; }
|
||||||
|
.bar-amt { text-align: right; font-variant-numeric: tabular-nums; font-weight: 400; }
|
||||||
|
.empty-note { color: var(--muted); font-size: 14px; margin: 4px 0; }
|
||||||
|
|
||||||
|
/* Grouped list */
|
||||||
|
.group { margin-bottom: 16px; }
|
||||||
|
.group-head {
|
||||||
|
display: flex; justify-content: space-between; align-items: baseline; gap: 10px;
|
||||||
|
background: var(--header); color: #fff8e7;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.group-head h3 { margin: 0; font-size: 18px; }
|
||||||
|
.group-total { font-variant-numeric: tabular-nums; font-weight: 700; }
|
||||||
|
.items { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-top: none; border-radius: 0 0 10px 10px; background: var(--card); }
|
||||||
|
.item { display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-top: 1px solid var(--line); }
|
||||||
|
.item:first-child { border-top: none; }
|
||||||
|
.dot { width: 11px; height: 11px; border-radius: 50%; flex: none; }
|
||||||
|
.item-main { flex: 1; min-width: 0; }
|
||||||
|
.item-desc { font-weight: 400; }
|
||||||
|
.item-meta { color: var(--muted); font-size: 12.5px; }
|
||||||
|
.item-amt { font-weight: 400; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.item-ops { display: flex; gap: 2px; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; font-size: 11px; font-weight: 400;
|
||||||
|
border-radius: 999px; padding: 1px 8px; margin-left: 8px; vertical-align: 1px;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
}
|
||||||
|
.badge.daily { background: var(--accent); color: #fff; }
|
||||||
|
.badge.weekly { background: var(--accent); color: #fff; }
|
||||||
|
.badge.monthly { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
|
/* Templates */
|
||||||
|
.templates { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
||||||
|
.template { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: var(--card); }
|
||||||
|
.template-top { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
|
||||||
|
.template-top strong { font-weight: 400; }
|
||||||
|
.template-next { color: var(--muted); font-size: 12.5px; margin: 4px 0 8px; }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.foot { margin: 0; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { transition: none !important; animation: none !important; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user