From 5339e9818a0d2b4cb1e2e5c7b50a40f4ada67492 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:10:10 -0400 Subject: [PATCH] Add account sharing, Get Paid schedule, recurring manager, overspend warnings, mobile nav Account sharing: - fileStorage: add shares.json with createShare/listShares/getShareData/revokeShare - /api/share/account: real implementation using session token - /api/share/list: GET endpoint returning owned + received shares - /api/share/data: GET endpoint to fetch a shared account's data - SharedAccountManager: use session token, load existing shares, revoke support - AccountManagement: render SharedAccountManager per-account - page.tsx: load received shares on login, show "Shared with me" cards in Accounts view Get Paid schedule: - PaycheckSchedule type: weekly/biweekly/semimonthly/monthly with day config - computeNextPayday(): calculates next payday from today - Schedule UI in GetPaid modal with live "Next payday: ..." preview - Schedule persisted to localStorage template Recurring transaction manager: - Fix biweekly support (was missing) - Show "due now" vs "upcoming" with amber badge for overdue - Render in Transactions view; onApplyRecurring creates new transactions + updates lastAppliedDate Envelope overspending: - Red warning banner at top of Envelopes view listing overspent envelopes - Red ring on individual overspent envelope cards Mobile layout: - Fixed bottom tab bar (Home/Accounts/Txns/Envelopes/More) on sm: and below - Active tab highlighted in blue - Main content padded to avoid being hidden by bottom nav Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/share/account/route.ts | 55 +++--- src/app/api/share/data/route.ts | 22 +++ src/app/api/share/list/route.ts | 17 ++ src/app/page.tsx | 108 +++++++++++- src/components/AccountManagement.tsx | 6 +- src/components/GetPaid.tsx | 108 ++++++++++-- .../RecurringTransactionManager.tsx | 162 ++++++++++-------- src/components/SharedAccountManager.tsx | 126 ++++++++------ src/lib/fileStorage.ts | 75 +++++++- 9 files changed, 518 insertions(+), 161 deletions(-) create mode 100644 src/app/api/share/data/route.ts create mode 100644 src/app/api/share/list/route.ts diff --git a/src/app/api/share/account/route.ts b/src/app/api/share/account/route.ts index c92a631..ed6de96 100644 --- a/src/app/api/share/account/route.ts +++ b/src/app/api/share/account/route.ts @@ -1,32 +1,43 @@ import { NextRequest, NextResponse } from 'next/server'; +import { FileStorageService } from '@/lib/fileStorage'; export async function POST(request: NextRequest) { try { - const userId = request.headers.get('x-user-id'); - const { accountId, email, role } = await request.json(); + const token = request.headers.get('x-session-token'); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - if (!userId || !accountId || !email) { - return NextResponse.json( - { error: 'Missing required fields' }, - { status: 400 } - ); + const session = await FileStorageService.validateSession(token); + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { accountId, email, role } = await request.json(); + if (!accountId || !email || !role) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + if (email === session.email) { + return NextResponse.json({ error: 'Cannot share an account with yourself' }, { status: 400 }); } - // TODO: Implement actual sharing logic - // 1. Validate email exists - // 2. Create sharing record in database - // 3. Send notification to shared user - // 4. Add shared account to their account list - - return NextResponse.json({ - success: true, - message: `Account shared with ${email} (${role} access)`, - note: 'Sharing feature coming soon - for now this is a placeholder', - }); + const shareId = await FileStorageService.createShare(session.userId, session.email, email, accountId, role); + return NextResponse.json({ success: true, shareId, message: `Account shared with ${email}` }); } catch (error: any) { - return NextResponse.json( - { error: error.message || 'Failed to share account' }, - { status: 500 } - ); + return NextResponse.json({ error: error.message || 'Failed to share account' }, { status: 400 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + const token = request.headers.get('x-session-token'); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const session = await FileStorageService.validateSession(token); + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { shareId } = await request.json(); + if (!shareId) return NextResponse.json({ error: 'Missing shareId' }, { status: 400 }); + + await FileStorageService.revokeShare(shareId, session.userId); + return NextResponse.json({ success: true }); + } catch (error: any) { + return NextResponse.json({ error: error.message || 'Failed to revoke share' }, { status: 400 }); } } diff --git a/src/app/api/share/data/route.ts b/src/app/api/share/data/route.ts new file mode 100644 index 0000000..13fbf45 --- /dev/null +++ b/src/app/api/share/data/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { FileStorageService } from '@/lib/fileStorage'; + +export async function GET(request: NextRequest) { + try { + const token = request.headers.get('x-session-token'); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const session = await FileStorageService.validateSession(token); + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const shareId = request.nextUrl.searchParams.get('shareId'); + if (!shareId) return NextResponse.json({ error: 'Missing shareId' }, { status: 400 }); + + const data = await FileStorageService.getShareData(shareId, session.email); + if (!data) return NextResponse.json({ error: 'Share not found or access denied' }, { status: 404 }); + + return NextResponse.json(data); + } catch (error: any) { + return NextResponse.json({ error: error.message || 'Failed to load shared data' }, { status: 500 }); + } +} diff --git a/src/app/api/share/list/route.ts b/src/app/api/share/list/route.ts new file mode 100644 index 0000000..f6eb20c --- /dev/null +++ b/src/app/api/share/list/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { FileStorageService } from '@/lib/fileStorage'; + +export async function GET(request: NextRequest) { + try { + const token = request.headers.get('x-session-token'); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const session = await FileStorageService.validateSession(token); + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { received, owned } = await FileStorageService.listShares(session.email, session.userId); + return NextResponse.json({ received, owned }); + } catch (error: any) { + return NextResponse.json({ error: error.message || 'Failed to list shares' }, { status: 500 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7412d65..26bdc92 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,6 +15,8 @@ import Settings from '../components/Settings'; import GetPaid from '../components/GetPaid'; import GoalsManager from '../components/GoalsManager'; import TransactionRulesManager from '../components/TransactionRulesManager'; +import RecurringTransactionManager from '../components/RecurringTransactionManager'; +import SharedAccountManager from '../components/SharedAccountManager'; interface User { userId: string; email: string } @@ -112,6 +114,7 @@ export default function BudgetDashboard() { const [goals, setGoals] = useState([]); const [transactionRules, setTransactionRules] = useState([]); const [setupCompleted, setSetupCompleted] = useState(false); + const [sharedAccounts, setSharedAccounts] = useState>([]); // Transaction filters const [txSearch, setTxSearch] = useState(''); @@ -189,6 +192,24 @@ export default function BudgetDashboard() { // Auto-apply due recurring transactions autoApplyRecurring(txnsWithDates); + + // Load accounts shared with me + const sessionToken = AuthService.getSessionToken(); + if (sessionToken) { + fetch('/api/share/list', { headers: { 'x-session-token': sessionToken } }) + .then(r => r.json()) + .then(async (data) => { + const received: any[] = data.received ?? []; + const loaded = await Promise.all(received.map(async (share: any) => { + const res = await fetch(`/api/share/data?shareId=${share.shareId}`, { headers: { 'x-session-token': sessionToken } }); + if (!res.ok) return null; + const d = await res.json(); + return { shareId: share.shareId, ownerEmail: share.ownerEmail, account: d.account, role: share.role }; + })); + setSharedAccounts(loaded.filter(Boolean) as any); + }) + .catch(() => {}); + } } else { setShowSetupWizard(true); } @@ -200,6 +221,7 @@ export default function BudgetDashboard() { setAccounts([]); setEnvelopes([]); setTransactions([]); setGoals([]); setTransactionRules([]); setSetupCompleted(false); setShowSetupWizard(false); + setSharedAccounts([]); } setLoading(false); }); @@ -269,6 +291,25 @@ export default function BudgetDashboard() { }); }; + const handleApplyRecurring = (dueTxns: Transaction[]) => { + const now = new Date(); + const newTxns: Transaction[] = dueTxns.map(t => ({ + ...t, + id: crypto.randomUUID(), + date: now, + lastAppliedDate: now.toISOString(), + })); + setTransactions(prev => { + const updated = prev.map(t => + dueTxns.find(d => d.id === t.id) ? { ...t, lastAppliedDate: now.toISOString() } : t + ); + const next = [...updated, ...newTxns]; + setEnvelopes(calculateEnvelopeSpending(envelopes, next)); + setAccounts(calculateAccountBalances(accounts, next)); + return next; + }); + }; + // ── Event handlers ─────────────────────────────────────────────────────────── const handleTransactionsAdded = (newTxns: Transaction[]) => { @@ -778,6 +819,11 @@ export default function BudgetDashboard() { transactions={transactions} /> + + {/* Filter bar */}
); - const renderEnvelopes = () => ( + const renderEnvelopes = () => { + const overspentEnvs = envelopes.filter(e => { + const incomeAlloc = transactions.filter(t => t.envelopeId === e.id && t.amount > 0).reduce((s, t) => s + t.amount, 0); + return e.allocated + incomeAlloc > 0 && e.spent > e.allocated + incomeAlloc; + }); + return (

Envelopes

+ {overspentEnvs.length > 0 && ( +
+ ⚠️ +
+

Budget exceeded

+

+ {overspentEnvs.map(e => e.name).join(', ')} {overspentEnvs.length === 1 ? 'is' : 'are'} over budget. Consider adjusting allocations or reviewing recent spending. +

+
+
+ )}
{envelopes.map(env => { const incomeAlloc = transactions.filter(t => t.envelopeId === env.id && t.amount > 0).reduce((s, t) => s + t.amount, 0); const totalAlloc = env.allocated + incomeAlloc; const remaining = totalAlloc - env.spent; const pct = totalAlloc > 0 ? Math.min(100, Math.round((env.spent / totalAlloc) * 100)) : 0; + const isOver = totalAlloc > 0 && env.spent > totalAlloc; return ( -
+
@@ -921,7 +984,7 @@ export default function BudgetDashboard() {
- ); + );}; return (
@@ -991,7 +1054,7 @@ export default function BudgetDashboard() { {/* Main Content */} -
+
{loading ? (
Loading…
) : !user ? ( @@ -1056,6 +1119,24 @@ export default function BudgetDashboard() { onAccountUpdate={handleAccountUpdate} onAccountDelete={id => setAccounts(prev => prev.filter(a => a.id !== id))} /> + {sharedAccounts.length > 0 && ( +
+

Shared with me

+
+ {sharedAccounts.map(sa => ( +
+
+ {sa.account?.name} + {sa.role} +
+

Shared by {sa.ownerEmail}

+

${(sa.account?.balance ?? 0).toFixed(2)}

+

{sa.account?.type?.replace('_', ' ')}

+
+ ))} +
+
+ )}
)} @@ -1128,6 +1209,25 @@ export default function BudgetDashboard() { accounts={accounts.map(a => ({ id: a.id, name: a.name }))} /> )} + {/* Mobile bottom nav */} + {user && setupCompleted && ( + + )} + {editingEnvelope && ( {(accounts || []).map((account) => ( -
+
+
@@ -210,6 +212,8 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
+
+
))}
diff --git a/src/components/GetPaid.tsx b/src/components/GetPaid.tsx index 48c7cbd..c6268b7 100644 --- a/src/components/GetPaid.tsx +++ b/src/components/GetPaid.tsx @@ -35,12 +35,20 @@ interface Transaction { type DistributionMethod = 'proportional' | 'equal' | 'custom'; type AllocationMap = Record; +interface PaycheckSchedule { + frequency: 'weekly' | 'biweekly' | 'semimonthly' | 'monthly'; + dayOfWeek?: number; // 0-6, for weekly/biweekly + dayOfMonth?: number; // 1-31, for monthly/semimonthly + dayOfMonth2?: number; // for semimonthly second date +} + interface PaycheckTemplate { amount: number; selectedAccountId: string; description: string; distributionMethod: DistributionMethod; customAllocations: AllocationMap; + schedule?: PaycheckSchedule; } interface GetPaidProps { @@ -70,6 +78,8 @@ export default function GetPaid({ const [defaultAmountInput, setDefaultAmountInput] = useState(''); const [saveTemplate, setSaveTemplate] = useState(false); const [savedTemplate, setSavedTemplate] = useState(null); + const [showSchedule, setShowSchedule] = useState(false); + const [schedule, setSchedule] = useState({ frequency: 'biweekly', dayOfWeek: 5 }); useEffect(() => { if (typeof window === 'undefined') return; @@ -122,12 +132,37 @@ export default function GetPaid({ const applySavedTemplate = () => { if (!savedTemplate) return; - setAmount(savedTemplate.amount?.toString() || ''); setSelectedAccountId(savedTemplate.selectedAccountId || ''); setDescription(savedTemplate.description || 'Paycheck'); setDistributionMethod(savedTemplate.distributionMethod || 'proportional'); setCustomAllocations(savedTemplate.customAllocations || {}); + if (savedTemplate.schedule) setSchedule(savedTemplate.schedule); + }; + + const computeNextPayday = (sched: PaycheckSchedule): Date => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const next = new Date(today); + + if (sched.frequency === 'weekly' || sched.frequency === 'biweekly') { + const dow = sched.dayOfWeek ?? 5; + const diff = (dow - today.getDay() + 7) % 7 || (sched.frequency === 'biweekly' ? 14 : 7); + next.setDate(today.getDate() + diff); + } else if (sched.frequency === 'semimonthly') { + const d1 = sched.dayOfMonth ?? 1; + const d2 = sched.dayOfMonth2 ?? 15; + const thisMonthD1 = new Date(today.getFullYear(), today.getMonth(), d1); + const thisMonthD2 = new Date(today.getFullYear(), today.getMonth(), d2); + const nextMonthD1 = new Date(today.getFullYear(), today.getMonth() + 1, d1); + const candidates = [thisMonthD1, thisMonthD2, nextMonthD1].filter(d => d > today); + return candidates.sort((a, b) => a.getTime() - b.getTime())[0] ?? nextMonthD1; + } else { + const dom = sched.dayOfMonth ?? 1; + next.setDate(dom); + if (next <= today) next.setMonth(next.getMonth() + 1); + } + return next; }; const handleSubmit = (event: React.FormEvent) => { @@ -183,6 +218,7 @@ export default function GetPaid({ description, distributionMethod, customAllocations: previewAllocations, + schedule: showSchedule ? schedule : undefined, }; localStorage.setItem('paycheckTemplate', JSON.stringify(template)); setSavedTemplate(template); @@ -384,17 +420,67 @@ export default function GetPaid({ />
-
- setSaveTemplate(event.target.checked)} - className="mr-2" - /> -