Initial commit: SubSplit - subscription split tracker

This commit is contained in:
2026-07-31 13:40:26 -07:00
commit 30eb44429c
29 changed files with 9081 additions and 0 deletions
+380
View File
@@ -0,0 +1,380 @@
import React, { useState, useEffect } from 'react';
import {
Group,
Member,
Subscription,
OneOffExpense,
SettlementRecord,
MemberBalance,
DebtTransaction,
} from './types';
import {
INITIAL_GROUPS,
INITIAL_MEMBERS,
INITIAL_SUBSCRIPTIONS,
INITIAL_ONE_OFF_EXPENSES,
INITIAL_SETTLEMENTS,
} from './data/initialData';
import { calculateAllMemberBalances, calculateSimplifiedDebts, getMonthlyCost } from './utils/splitCalculator';
import { Header } from './components/Header';
import { Dashboard } from './components/Dashboard';
import { SubscriptionList } from './components/SubscriptionList';
import { SubscriptionModal } from './components/SubscriptionModal';
import { OneOffExpenseList } from './components/OneOffExpenseList';
import { OneOffExpenseModal } from './components/OneOffExpenseModal';
import { GroupMemberManager } from './components/GroupMemberManager';
import { SettlementModal } from './components/SettlementModal';
import { SettlementLog } from './components/SettlementLog';
import { BreakdownModal } from './components/BreakdownModal';
import { MemberBreakdownModal } from './components/MemberBreakdownModal';
import { ShareReportModal } from './components/ShareReportModal';
export default function App() {
// Persistence Keys
const STORAGE_KEY_GROUPS = 'subsplit_groups_v1';
const STORAGE_KEY_MEMBERS = 'subsplit_members_v1';
const STORAGE_KEY_SUBS = 'subsplit_subs_v1';
const STORAGE_KEY_ONE_OFF = 'subsplit_one_off_v1';
const STORAGE_KEY_SETTLEMENTS = 'subsplit_settlements_v1';
// State
const [groups, setGroups] = useState<Group[]>(() => {
const saved = localStorage.getItem(STORAGE_KEY_GROUPS);
return saved ? JSON.parse(saved) : INITIAL_GROUPS;
});
const [members, setMembers] = useState<Member[]>(() => {
const saved = localStorage.getItem(STORAGE_KEY_MEMBERS);
return saved ? JSON.parse(saved) : INITIAL_MEMBERS;
});
const [subscriptions, setSubscriptions] = useState<Subscription[]>(() => {
const saved = localStorage.getItem(STORAGE_KEY_SUBS);
return saved ? JSON.parse(saved) : INITIAL_SUBSCRIPTIONS;
});
const [oneOffExpenses, setOneOffExpenses] = useState<OneOffExpense[]>(() => {
const saved = localStorage.getItem(STORAGE_KEY_ONE_OFF);
return saved ? JSON.parse(saved) : INITIAL_ONE_OFF_EXPENSES;
});
const [settlements, setSettlements] = useState<SettlementRecord[]>(() => {
const saved = localStorage.getItem(STORAGE_KEY_SETTLEMENTS);
return saved ? JSON.parse(saved) : INITIAL_SETTLEMENTS;
});
// Save to LocalStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEY_GROUPS, JSON.stringify(groups));
}, [groups]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY_MEMBERS, JSON.stringify(members));
}, [members]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY_SUBS, JSON.stringify(subscriptions));
}, [subscriptions]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY_ONE_OFF, JSON.stringify(oneOffExpenses));
}, [oneOffExpenses]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY_SETTLEMENTS, JSON.stringify(settlements));
}, [settlements]);
// Tab & Modal Navigation
const [activeTab, setActiveTab] = useState<'dashboard' | 'subscriptions' | 'one-off' | 'members' | 'settlements'>('dashboard');
// Modal States
const [isSubModalOpen, setIsSubModalOpen] = useState(false);
const [editingSub, setEditingSub] = useState<Subscription | null>(null);
const [isOneOffModalOpen, setIsOneOffModalOpen] = useState(false);
const [editingOneOff, setEditingOneOff] = useState<OneOffExpense | null>(null);
const [isSettleModalOpen, setIsSettleModalOpen] = useState(false);
const [settleFromId, setSettleFromId] = useState<string | undefined>();
const [settleToId, setSettleToId] = useState<string | undefined>();
const [settleDefaultAmount, setSettleDefaultAmount] = useState<number | undefined>();
const [breakdownSub, setBreakdownSub] = useState<Subscription | null>(null);
const [selectedMemberBalance, setSelectedMemberBalance] = useState<MemberBalance | null>(null);
const [isShareReportOpen, setIsShareReportOpen] = useState(false);
// Computed Balances & Debts
const balances: MemberBalance[] = calculateAllMemberBalances(subscriptions, oneOffExpenses, members, groups, settlements);
const debts: DebtTransaction[] = calculateSimplifiedDebts(balances);
const activeSubs = subscriptions.filter((s) => s.status === 'active');
const totalMonthlyCost = activeSubs.reduce(
(acc, s) => acc + getMonthlyCost(s.cost, s.billingCycle),
0
);
// Handlers for Subscriptions
const handleSaveSub = (sub: Subscription) => {
const exists = subscriptions.some((s) => s.id === sub.id);
if (exists) {
setSubscriptions(subscriptions.map((s) => (s.id === sub.id ? sub : s)));
} else {
setSubscriptions([...subscriptions, sub]);
}
};
const handleDeleteSub = (subId: string) => {
if (window.confirm('Are you sure you want to delete this subscription?')) {
setSubscriptions(subscriptions.filter((s) => s.id !== subId));
}
};
const handleToggleSubStatus = (subId: string) => {
setSubscriptions(
subscriptions.map((s) =>
s.id === subId ? { ...s, status: s.status === 'active' ? 'paused' : 'active' } : s
)
);
};
// Handlers for One-Off Expenses
const handleSaveOneOff = (expense: OneOffExpense) => {
const exists = oneOffExpenses.some((e) => e.id === expense.id);
if (exists) {
setOneOffExpenses(oneOffExpenses.map((e) => (e.id === expense.id ? expense : e)));
} else {
setOneOffExpenses([expense, ...oneOffExpenses]);
}
};
const handleDeleteOneOff = (expenseId: string) => {
if (window.confirm('Delete this one-off shared charge?')) {
setOneOffExpenses(oneOffExpenses.filter((e) => e.id !== expenseId));
}
};
// Handlers for Members & Groups
const handleAddMember = (m: Omit<Member, 'id'>) => {
const newMember: Member = { ...m, id: `mem-${Date.now()}` };
setMembers([...members, newMember]);
};
const handleEditMember = (m: Member) => {
setMembers(members.map((item) => (item.id === m.id ? m : item)));
};
const handleDeleteMember = (memberId: string) => {
if (window.confirm('Remove this member?')) {
setMembers(members.filter((m) => m.id !== memberId));
}
};
const handleAddGroup = (g: Omit<Group, 'id'>) => {
const newGroup: Group = { ...g, id: `grp-${Date.now()}` };
setGroups([...groups, newGroup]);
};
const handleEditGroup = (g: Group) => {
setGroups(groups.map((item) => (item.id === g.id ? g : item)));
};
const handleDeleteGroup = (groupId: string) => {
if (window.confirm('Delete this family group? Members in this group will become individual.')) {
setGroups(groups.filter((g) => g.id !== groupId));
setMembers(members.map((m) => (m.groupId === groupId ? { ...m, groupId: undefined } : m)));
}
};
// Settlement Handlers
const handleOpenSettleModal = (fromId?: string, toId?: string, defaultAmt?: number) => {
setSettleFromId(fromId);
setSettleToId(toId);
setSettleDefaultAmount(defaultAmt);
setIsSettleModalOpen(true);
};
const handleSaveSettlement = (record: Omit<SettlementRecord, 'id'>) => {
const newRecord: SettlementRecord = { ...record, id: `settle-${Date.now()}` };
setSettlements([newRecord, ...settlements]);
};
const handleDeleteSettlement = (id: string) => {
setSettlements(settlements.filter((s) => s.id !== id));
};
const handleResetData = () => {
if (window.confirm('Reset all subscriptions, one-off charges, members, and settlement records to initial seed data?')) {
setGroups(INITIAL_GROUPS);
setMembers(INITIAL_MEMBERS);
setSubscriptions(INITIAL_SUBSCRIPTIONS);
setOneOffExpenses(INITIAL_ONE_OFF_EXPENSES);
setSettlements(INITIAL_SETTLEMENTS);
}
};
return (
<div className="min-h-screen bg-slate-100/70 text-slate-900 font-sans antialiased pb-16">
{/* Top Header & Navigation */}
<Header
activeTab={activeTab}
setActiveTab={setActiveTab}
totalActiveSubs={activeSubs.length}
totalOneOffCount={oneOffExpenses.length}
totalMonthlyCost={totalMonthlyCost}
onOpenAddSub={() => {
setEditingSub(null);
setIsSubModalOpen(true);
}}
onOpenAddExpense={() => {
setEditingOneOff(null);
setIsOneOffModalOpen(true);
}}
onOpenRecordSettlement={() => handleOpenSettleModal()}
onOpenShareReport={() => setIsShareReportOpen(true)}
onResetData={handleResetData}
/>
{/* Main Container */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{activeTab === 'dashboard' && (
<Dashboard
balances={balances}
debts={debts}
subscriptions={subscriptions}
oneOffExpenses={oneOffExpenses}
settlements={settlements}
members={members}
groups={groups}
onOpenSettleModal={handleOpenSettleModal}
onOpenBreakdownModal={(sub) => setBreakdownSub(sub)}
onOpenMemberBreakdownModal={(bal) => setSelectedMemberBalance(bal)}
onOpenAddExpense={() => {
setEditingOneOff(null);
setIsOneOffModalOpen(true);
}}
onOpenAddSub={() => {
setEditingSub(null);
setIsSubModalOpen(true);
}}
/>
)}
{activeTab === 'subscriptions' && (
<SubscriptionList
subscriptions={subscriptions}
members={members}
groups={groups}
onAddSub={() => {
setEditingSub(null);
setIsSubModalOpen(true);
}}
onEditSub={(sub) => {
setEditingSub(sub);
setIsSubModalOpen(true);
}}
onDeleteSub={handleDeleteSub}
onToggleStatus={handleToggleSubStatus}
onOpenBreakdownModal={(sub) => setBreakdownSub(sub)}
/>
)}
{activeTab === 'one-off' && (
<OneOffExpenseList
expenses={oneOffExpenses}
members={members}
groups={groups}
onAddExpense={() => {
setEditingOneOff(null);
setIsOneOffModalOpen(true);
}}
onEditExpense={(exp) => {
setEditingOneOff(exp);
setIsOneOffModalOpen(true);
}}
onDeleteExpense={handleDeleteOneOff}
/>
)}
{activeTab === 'members' && (
<GroupMemberManager
members={members}
groups={groups}
onAddMember={handleAddMember}
onEditMember={handleEditMember}
onDeleteMember={handleDeleteMember}
onAddGroup={handleAddGroup}
onEditGroup={handleEditGroup}
onDeleteGroup={handleDeleteGroup}
/>
)}
{activeTab === 'settlements' && (
<SettlementLog
settlements={settlements}
members={members}
onOpenSettleModal={() => handleOpenSettleModal()}
onDeleteSettlement={handleDeleteSettlement}
/>
)}
</main>
{/* Subscription Modal (Create / Edit) */}
<SubscriptionModal
isOpen={isSubModalOpen}
onClose={() => setIsSubModalOpen(false)}
onSave={handleSaveSub}
initialData={editingSub}
members={members}
groups={groups}
/>
{/* One-Off Expense Modal (Create / Edit) */}
<OneOffExpenseModal
isOpen={isOneOffModalOpen}
onClose={() => setIsOneOffModalOpen(false)}
onSave={handleSaveOneOff}
initialData={editingOneOff}
members={members}
groups={groups}
/>
{/* Settlement Payment Modal */}
<SettlementModal
isOpen={isSettleModalOpen}
onClose={() => setIsSettleModalOpen(false)}
onSaveSettlement={handleSaveSettlement}
members={members}
defaultFromId={settleFromId}
defaultToId={settleToId}
defaultAmount={settleDefaultAmount}
/>
{/* Subscription Math Breakdown Modal */}
<BreakdownModal
subscription={breakdownSub}
onClose={() => setBreakdownSub(null)}
members={members}
groups={groups}
/>
{/* Member Balance Statement Modal */}
<MemberBreakdownModal
balance={selectedMemberBalance}
onClose={() => setSelectedMemberBalance(null)}
members={members}
groups={groups}
/>
{/* Share/Export Chat Report Modal */}
<ShareReportModal
isOpen={isShareReportOpen}
onClose={() => setIsShareReportOpen(false)}
balances={balances}
debts={debts}
subscriptions={subscriptions}
oneOffExpenses={oneOffExpenses}
/>
</div>
);
}