Pin & Penny expense tracker (web + expo-app)
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user