Files
SkyMoney/web/src/pages/DashboardPage.tsx
Ricearoni1245 48268728f8
Some checks failed
Deploy / deploy (push) Failing after 14s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 26s
add ui tip in records for current month
2026-04-02 21:40:09 -05:00

1446 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useDashboard, type DashboardResponse } from "../hooks/useDashboard";
import { Money } from "../components/ui";
import PercentGuard from "../components/PercentGuard";
import MonthlyTrendChart from "../components/charts/MonthlyTrendChart";
import FixedFundingBars, {
type FixedItem,
} from "../components/charts/FixedFundingBars";
import VariableAllocationDonut, {
type VariableSlice,
} from "../components/charts/VariableAllocationDonut";
import { useAuthSession } from "../hooks/useAuthSession";
import { fixedPlansApi } from "../api/fixedPlans";
import { useToast } from "../components/Toast";
import PaydayOverlay from "../components/PaydayOverlay";
import PaymentReconciliationModal from "../components/PaymentReconciliationModal";
import FundingConfirmationModal from "../components/FundingConfirmationModal";
import EarlyPaymentPromptModal from "../components/EarlyPaymentPromptModal";
import { isoToDateString, dateStringToUTCMidnight, getTodayInTimezone, getBrowserTimezone, formatDateInTimezone } from "../utils/timezone";
export default function DashboardPage() {
const session = useAuthSession();
const shouldLoadDashboard = !!session.data?.userId;
const trendMonths = 6;
const [trendPage, setTrendPage] = useState(0);
const { data, isLoading, isError, isFetching, error, refetch } = useDashboard(
shouldLoadDashboard,
{
trendPage,
trendMonths,
}
);
const navigate = useNavigate();
const { push } = useToast();
const [activeTab, setActiveTab] = useState<
"overview" | "variable" | "fixed" | "trend"
>("overview");
const [isDesktop, setIsDesktop] = useState(() => {
if (typeof window === "undefined") return true;
return window.matchMedia("(min-width: 1024px)").matches;
});
// Get user timezone from dashboard data
const userTimezone = data?.user?.timezone || getBrowserTimezone();
const incomeType = data?.user?.incomeType ?? "regular";
const incomeFrequency = data?.user?.incomeFrequency;
const firstIncomeDate = data?.user?.firstIncomeDate ?? null;
const variableSlices: VariableSlice[] = useMemo(() => {
if (!data) return [];
return data.variableCategories.map((c) => ({
name: c.name,
value: c.percent,
isSavings: !!c.isSavings,
}));
}, [data]);
const fixedChartData: FixedItem[] = useMemo(() => {
if (!data) return [];
return data.fixedPlans.map((plan) => {
const total = plan.totalCents ?? 0;
const funded = Math.min(plan.fundedCents ?? 0, total);
const remaining = Math.max(total - funded, 0);
const aheadCents = getFundingAhead(plan) ?? 0;
// Calculate percentages for the chart (0-100 scale)
const fundedPercent = total > 0 ? (funded / total) * 100 : 0;
const remainingPercent = total > 0 ? (remaining / total) * 100 : 0;
return {
name: plan.name,
funded: fundedPercent,
remaining: remainingPercent,
fundedCents: funded,
remainingCents: remaining,
aheadCents,
isOverdue: (plan as { isOverdue?: boolean }).isOverdue ?? false,
dueOn: plan.dueOn,
};
});
}, [data]);
// Due-today overlay state
const [dueTodayPlan, setDueTodayPlan] = useState<
| { id: string; name: string; dueOn: string; remainingCents: number }
| null
>(null);
const [overrideDueISO, setOverrideDueISO] = useState<string>("");
useEffect(() => {
if (typeof window === "undefined") return;
const media = window.matchMedia("(min-width: 1024px)");
const update = () => setIsDesktop(media.matches);
update();
media.addEventListener("change", update);
return () => {
media.removeEventListener("change", update);
};
}, []);
useEffect(() => {
if (!isDesktop && activeTab === "overview") {
setActiveTab("variable");
}
}, [isDesktop, activeTab]);
const [showDueOverlay, setShowDueOverlay] = useState(false);
const [isSubmittingDue, setIsSubmittingDue] = useState(false);
const [dueQueue, setDueQueue] = useState<
Array<{ id: string; name: string; dueOn: string; remainingCents: number }>
>([]);
const [queueIndex, setQueueIndex] = useState(0);
const savingsCategories = useMemo(
() => (data?.variableCategories || []).filter((c) => c.isSavings),
[data]
);
const savingsReserveById = useMemo(() => {
if (!data) return new Map<string, number>();
const reserveTotal = data.fixedPlans.reduce(
(sum, plan) => sum + (plan.fundedCents || 0),
0
);
const savingsTotalPercent = savingsCategories.reduce(
(sum, c) => sum + (c.percent || 0),
0
);
const shares = savingsCategories.map((c) => {
const raw = savingsTotalPercent
? (reserveTotal * (c.percent || 0)) / savingsTotalPercent
: 0;
const floored = Math.floor(raw);
return { id: c.id, share: floored, frac: raw - floored };
});
let remainder =
reserveTotal - shares.reduce((sum, item) => sum + item.share, 0);
shares
.slice()
.sort((a, b) => b.frac - a.frac)
.forEach((item) => {
if (remainder > 0) {
item.share += 1;
remainder -= 1;
}
});
return new Map(shares.map((item) => [item.id, item.share]));
}, [data, savingsCategories]);
const [fundingSource, setFundingSource] = useState<"savings" | "deficit">(
"savings"
);
const [savingsCategoryId, setSavingsCategoryId] = useState<string | null>(
null
);
const [hasCheckedDueToday, setHasCheckedDueToday] = useState(false);
// Funding confirmation modal state
const [showFundingConfirmation, setShowFundingConfirmation] = useState(false);
const [fundingData, setFundingData] = useState<{
planId: string;
planName: string;
totalCents: number;
fundedCents: number;
availableBudget: number;
} | null>(null);
// Payment reconciliation modal state
const [showPaymentReconciliation, setShowPaymentReconciliation] = useState(false);
const [reconciliationData, setReconciliationData] = useState<{
planId: string;
planName: string;
totalCents: number;
fundedCents: number;
isOverdue: boolean;
overdueAmount?: number;
message: string;
nextDueDate: string;
} | null>(null);
const [earlyPaymentPrompt, setEarlyPaymentPrompt] = useState<{
planId: string;
planName: string;
dueOn: string;
} | null>(null);
const openDueItem = async (item: { id: string; name: string; dueOn: string }) => {
const planDetail = data?.fixedPlans.find((p) => p.id === item.id);
const totalCents = planDetail?.totalCents ?? 0;
const fundedCents = planDetail?.fundedCents ?? 0;
const isFullyFunded = fundedCents >= totalCents;
if (!isFullyFunded) {
const availableBudget = data?.totals.variableBalanceCents ?? 0;
setFundingData({
planId: item.id,
planName: item.name,
totalCents,
fundedCents,
availableBudget,
});
setShowFundingConfirmation(true);
return;
}
try {
const fundingResult = await fixedPlansApi.attemptFinalFunding(item.id);
const freq = (planDetail as any)?.frequency;
const nextDueISO = computeNextDueDateISO(item.dueOn, freq);
const nextDueDate = nextDueISO
? isoToDateString(nextDueISO, userTimezone)
: item.dueOn.slice(0, 10);
setReconciliationData({
planId: item.id,
planName: item.name,
totalCents: fundingResult.totalCents,
fundedCents: fundingResult.fundedCents,
isOverdue: !!fundingResult.isOverdue,
overdueAmount: fundingResult.overdueAmount,
message: fundingResult.message ?? "",
nextDueDate: nextDueDate ?? getTodayInTimezone(userTimezone),
});
setShowPaymentReconciliation(true);
} catch (fundingError) {
console.error("[DueOverlay] attempt-final-funding failed", fundingError);
}
};
const advanceDueQueue = async () => {
const nextIdx = queueIndex + 1;
if (nextIdx < dueQueue.length) {
const nextItem = dueQueue[nextIdx];
setQueueIndex(nextIdx);
setDueTodayPlan(nextItem);
setOverrideDueISO(nextItem.dueOn.slice(0, 10));
const initialSavings = savingsCategories[0]?.id || null;
setSavingsCategoryId(initialSavings);
setFundingSource("savings");
await openDueItem(nextItem);
} else {
setDueTodayPlan(null);
setDueQueue([]);
setQueueIndex(0);
}
};
function toUserMidnight(iso: string, timezone: string) {
const dateStr = isoToDateString(iso, timezone);
return new Date(dateStringToUTCMidnight(dateStr, timezone));
}
function countPayPeriodsBetween(
startIso: string,
endIso: string,
firstIncomeIso: string,
frequency: NonNullable<typeof incomeFrequency>,
timezone: string
) {
let count = 0;
let nextPayDate = toUserMidnight(firstIncomeIso, timezone);
const normalizedStart = toUserMidnight(startIso, timezone);
const normalizedEnd = toUserMidnight(endIso, timezone);
const targetDay = Number(isoToDateString(firstIncomeIso, timezone).split("-")[2] || "1");
const advanceByPeriod = () => {
if (frequency === "monthly") {
const year = nextPayDate.getUTCFullYear();
const month = nextPayDate.getUTCMonth() + 1;
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
nextPayDate = new Date(Date.UTC(year, month, Math.min(targetDay, daysInMonth)));
} else {
const days = frequency === "biweekly" ? 14 : 7;
nextPayDate = new Date(nextPayDate.getTime() + days * 86_400_000);
}
};
while (nextPayDate < normalizedStart) {
advanceByPeriod();
}
while (nextPayDate < normalizedEnd) {
count++;
advanceByPeriod();
}
return Math.max(1, count);
}
function getFundingAhead(plan: any) {
if (
incomeType !== "regular" ||
!incomeFrequency ||
!firstIncomeDate ||
!plan.cycleStart ||
!plan.dueOn
) {
return null;
}
const nowOverride = new URLSearchParams(window.location.search).get("debugNow");
const now = nowOverride ? new Date(`${nowOverride}T00:00:00.000Z`).toISOString() : new Date().toISOString();
let cycleStart = plan.cycleStart as string;
const dueOn = plan.dueOn as string;
let cycleStartDate: Date;
let dueDate: Date;
let nowDate: Date;
try {
cycleStartDate = toUserMidnight(cycleStart, userTimezone);
dueDate = toUserMidnight(dueOn, userTimezone);
nowDate = toUserMidnight(now, userTimezone);
} catch {
return null;
}
if (cycleStartDate >= dueDate || cycleStartDate > nowDate) {
cycleStart = now;
}
const totalPeriods = countPayPeriodsBetween(
cycleStart,
dueOn,
firstIncomeDate,
incomeFrequency,
userTimezone
);
const elapsedPeriods = countPayPeriodsBetween(
cycleStart,
now,
firstIncomeDate,
incomeFrequency,
userTimezone
);
const targetFunded = Math.min(
plan.totalCents ?? 0,
Math.ceil(((plan.totalCents ?? 0) * elapsedPeriods) / totalPeriods)
);
const funded = plan.fundedCents ?? 0;
const aheadBy = Math.max(0, funded - targetFunded);
return aheadBy > 0 ? aheadBy : null;
}
// Helper: compute next due date from current due using user's timezone
function computeNextDueDateISO(dateISO: string, frequency?: string): string | null {
if (!frequency || frequency === "one-time") return null;
// Convert ISO to date string in user's timezone
const dateStr = isoToDateString(dateISO, userTimezone);
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (frequency === "weekly") {
date.setUTCDate(date.getUTCDate() + 7);
} else if (frequency === "biweekly") {
date.setUTCDate(date.getUTCDate() + 14);
} else if (frequency === "monthly") {
date.setUTCMonth(date.getUTCMonth() + 1);
}
// Convert back to ISO with timezone handling
const nextDateStr = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
return dateStringToUTCMidnight(nextDateStr, userTimezone);
}
// Detect if any fixed plan is due/overdue and needs user payment confirmation.
// Include fully funded overdue plans so missed-day confirmations still surface.
useEffect(() => {
let cancelled = false;
async function checkDueToday() {
if (!data || !shouldLoadDashboard || hasCheckedDueToday) return;
try {
const res = await fixedPlansApi.due({ daysAhead: 0 });
const items = (res.items || [])
.filter((x) => x.isDue && (!x.isOverdue || x.remainingCents <= 0))
.filter((x) => {
const dismissedUntilStr = localStorage.getItem(`overdue-dismissed-${x.id}`);
if (!dismissedUntilStr) return true;
const dismissedUntil = parseInt(dismissedUntilStr, 10);
if (!Number.isFinite(dismissedUntil) || Date.now() >= dismissedUntil) {
localStorage.removeItem(`overdue-dismissed-${x.id}`);
return true;
}
return false;
});
if (!cancelled && items.length > 0) {
setHasCheckedDueToday(true);
const first = items[0];
await openDueItem(first);
// Store queue for potential future bills
setDueQueue(items.map((i) => ({
id: i.id,
name: i.name,
dueOn: i.dueOn,
remainingCents: i.remainingCents,
})));
setQueueIndex(0);
}
} catch (e: any) {
// Non-blocking: just ignore failures
try { console.warn("[DueOverlay] due check failed", e); } catch {}
}
}
checkDueToday();
return () => {
cancelled = true;
};
}, [data, shouldLoadDashboard, hasCheckedDueToday, savingsCategories, userTimezone]);
useEffect(() => {
if (
!data ||
showDueOverlay ||
showFundingConfirmation ||
showPaymentReconciliation ||
earlyPaymentPrompt
) {
return;
}
const eligible = data.fixedPlans.find((plan: any) => {
const total = plan.totalCents ?? 0;
const funded = plan.fundedCents ?? 0;
if (total <= 0 || funded < total) return false;
if (plan.isOverdue) return false;
if (plan.needsFundingThisPeriod !== false) return false;
const ahead = getFundingAhead(plan);
if (!ahead || ahead <= 0) return false;
const key = `early-paid-prompt-${plan.id}-${plan.dueOn}`;
if (localStorage.getItem(key)) return false;
return true;
});
if (eligible) {
setEarlyPaymentPrompt({
planId: eligible.id,
planName: eligible.name,
dueOn: eligible.dueOn,
});
}
}, [
data,
showDueOverlay,
showFundingConfirmation,
showPaymentReconciliation,
earlyPaymentPrompt,
]);
useEffect(() => {
if (data && !data.hasBudgetSetup) {
navigate("/onboarding", { replace: true });
}
}, [data?.hasBudgetSetup, navigate]);
if (isLoading) {
return (
<div className="p-6 space-y-4">
<div className="h-6 w-40 bg-[--color-panel] rounded animate-pulse" />
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div
key={idx}
className="h-24 rounded-xl bg-[--color-panel] animate-pulse"
/>
))}
</div>
</div>
);
}
if (isError || !data) {
return (
<div className="p-6 text-red-600 space-y-2">
<p>{(error as any)?.message || "Failed to load dashboard"}</p>
<button className="btn" onClick={() => refetch()}>
Retry
</button>
</div>
);
}
const currency = (cents: number) => (cents / 100).toFixed(2);
// CSV export removed per UI simplification request.
const hasCategories = data.variableCategories.length > 0;
const hasPlans = data.fixedPlans.length > 0;
const hasTx = data.recentTransactions.length > 0;
const hasTransactionsOlderThanSixMonths =
data.trendWindow?.hasTransactionsOlderThanSixMonths ?? false;
const useTrendWindowPagination = hasTransactionsOlderThanSixMonths;
const trendWindowLabel = data.trendWindow?.label ?? "Last 6 months";
const canTrendNewer = data.trendWindow?.canGoNewer ?? trendPage > 0;
const canTrendOlder = data.trendWindow?.canGoOlder ?? true;
const greetingName =
data.user.displayName ||
session.data?.displayName ||
session.data?.email?.split("@")[0] ||
"friend";
// Chart data for analytics section
return (
<div className="p-6 space-y-8 relative">
{/* Payday Overlay */}
<PaydayOverlay />
{/* Due Today Overlay */}
{showDueOverlay && dueTodayPlan && (
<div className="fixed inset-0 bg-[color:var(--color-bg)]/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[--color-bg] border rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
<div className="space-y-1">
<h3 className="font-semibold text-lg">{dueTodayPlan.name} is due today</h3>
<p className="text-sm muted">
Confirm the due date and record the payment. We'll roll the plan forward automatically.
</p>
</div>
{(() => {
const planFull = data?.fixedPlans?.find((p) => p.id === dueTodayPlan.id);
// Extract frequency from plan.frequency or paymentSchedule.frequency
let freq = (planFull as any)?.frequency;
if (!freq && (planFull as any)?.paymentSchedule?.frequency) {
freq = (planFull as any).paymentSchedule.frequency;
}
const suggestedISO = planFull ? computeNextDueDateISO(dueTodayPlan.dueOn, freq) : null;
const suggestedDateOnly = suggestedISO ? suggestedISO.slice(0, 10) : "";
return (
<div className="space-y-2">
<div className="text-sm">Calculated next due date</div>
<div className="text-base font-mono">
{suggestedDateOnly || "n/a"}
</div>
<div className="text-xs muted">
If this looks correct, press Confirm. Or adjust below.
</div>
<label className="stack">
<span className="text-sm muted">Adjust next due date (optional)</span>
<input
type="date"
className="input"
value={overrideDueISO}
placeholder={suggestedDateOnly || dueTodayPlan.dueOn.slice(0,10)}
onChange={(e) => setOverrideDueISO(e.target.value)}
/>
</label>
</div>
);
})()}
<div className="space-y-2">
<div className="text-sm">Remaining</div>
<div className="text-xl font-mono">
<Money cents={dueTodayPlan.remainingCents} />
</div>
<div className="text-xs muted">{queueIndex + 1} of {dueQueue.length}</div>
</div>
{dueTodayPlan.remainingCents > 0 && (
<div className="space-y-2">
<div className="text-sm muted">Funding source</div>
<div className="row gap-2">
<button
type="button"
className={"nav-link " + (fundingSource === "savings" ? "nav-link-active" : "")}
onClick={() => setFundingSource("savings")}
disabled={isSubmittingDue || savingsCategories.length === 0}
>
Use savings
</button>
<button
type="button"
className={"nav-link " + (fundingSource === "deficit" ? "nav-link-active" : "")}
onClick={() => setFundingSource("deficit")}
disabled={isSubmittingDue}
>
Record deficit
</button>
</div>
{fundingSource === "savings" && (
<label className="stack">
<span className="text-sm muted">Savings category</span>
{savingsCategories.length > 0 ? (
<select
className="input"
value={savingsCategoryId ?? ""}
onChange={(e) => setSavingsCategoryId(e.target.value)}
>
{savingsCategories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
) : (
<div className="toast-err">No savings categories available.</div>
)}
</label>
)}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
className="btn bg-gray-600 hover:bg-gray-500"
onClick={() => {
// Skip this item
const nextIdx = queueIndex + 1;
if (nextIdx < dueQueue.length) {
const next = dueQueue[nextIdx];
setQueueIndex(nextIdx);
setDueTodayPlan(next);
setOverrideDueISO(next.dueOn.slice(0, 10));
const initialSavings = savingsCategories[0]?.id || null;
setSavingsCategoryId(initialSavings);
setFundingSource("savings");
} else {
setShowDueOverlay(false);
setDueTodayPlan(null);
setDueQueue([]);
setQueueIndex(0);
}
}}
disabled={isSubmittingDue}
>
Skip
</button>
<button
type="button"
className="btn bg-gray-600 hover:bg-gray-500"
onClick={() => {
setShowDueOverlay(false);
setDueTodayPlan(null);
setDueQueue([]);
setQueueIndex(0);
}}
disabled={isSubmittingDue}
>
Skip all
</button>
<button
type="button"
className="btn ml-auto"
onClick={async () => {
if (!dueTodayPlan) return;
setIsSubmittingDue(true);
try {
const body: any = {};
if (overrideDueISO) {
// Convert date-only (YYYY-MM-DD) to full ISO datetime (UTC midnight)
body.overrideDueOnISO = dateStringToUTCMidnight(overrideDueISO, userTimezone);
}
// Handle shortage funding
if (dueTodayPlan.remainingCents > 0) {
if (fundingSource === "savings") {
if (!savingsCategoryId) {
throw new Error("Select a savings category to cover the shortage.");
}
body.fundingSource = "savings";
body.savingsCategoryId = savingsCategoryId;
} else if (fundingSource === "deficit") {
body.fundingSource = "deficit";
}
}
const res = await fixedPlansApi.payNow(dueTodayPlan.id, body);
const next = res.nextDueOn
? formatDateInTimezone(res.nextDueOn, userTimezone)
: null;
push(
"ok",
next
? `Payment recorded. Next due ${next}.`
: "Payment recorded."
);
await refetch();
// Advance queue
const nextIdx = queueIndex + 1;
if (nextIdx < dueQueue.length) {
const nextItem = dueQueue[nextIdx];
setQueueIndex(nextIdx);
setDueTodayPlan(nextItem);
setOverrideDueISO(nextItem.dueOn.slice(0, 10));
const initialSavings = savingsCategories[0]?.id || null;
setSavingsCategoryId(initialSavings);
setFundingSource("savings");
} else {
setShowDueOverlay(false);
setDueTodayPlan(null);
setDueQueue([]);
setQueueIndex(0);
}
} catch (err: any) {
push("err", err?.message ?? "Failed to record payment.");
} finally {
setIsSubmittingDue(false);
}
}}
disabled={isSubmittingDue}
>
{isSubmittingDue ? "Recording…" : "Confirm & Pay Now"}
</button>
</div>
</div>
</div>
)}
{/* Funding Confirmation Modal */}
{showFundingConfirmation && fundingData && (
<FundingConfirmationModal
planId={fundingData.planId}
planName={fundingData.planName}
totalCents={fundingData.totalCents}
fundedCents={fundingData.fundedCents}
availableBudget={fundingData.availableBudget}
onClose={() => {
setShowFundingConfirmation(false);
setFundingData(null);
}}
onFundingComplete={async (fundingResult) => {
setShowFundingConfirmation(false);
try {
const planDetail = data?.fixedPlans.find(p => p.id === fundingData.planId);
const freq = (planDetail as any)?.frequency;
const fallbackDueISO = dateStringToUTCMidnight(getTodayInTimezone(userTimezone), userTimezone);
const nextDueISO = computeNextDueDateISO(planDetail?.dueOn ?? fallbackDueISO, freq);
const nextDueDate = nextDueISO ? isoToDateString(nextDueISO, userTimezone) : getTodayInTimezone(userTimezone);
setReconciliationData({
planId: fundingData.planId,
planName: fundingData.planName,
totalCents: fundingResult.totalCents,
fundedCents: fundingResult.fundedCents,
isOverdue: !!fundingResult.isOverdue,
overdueAmount: fundingResult.overdueAmount,
message: fundingResult.message ?? "",
nextDueDate: nextDueDate ?? getTodayInTimezone(userTimezone),
});
setFundingData(null);
setShowPaymentReconciliation(true);
await refetch();
} catch (err) {
console.error("Failed to proceed to payment reconciliation", err);
push("err", "Failed to proceed with payment");
setFundingData(null);
}
}}
/>
)}
{/* Payment Reconciliation Modal */}
{showPaymentReconciliation && reconciliationData && (
<PaymentReconciliationModal
planId={reconciliationData.planId}
planName={reconciliationData.planName}
totalCents={reconciliationData.totalCents}
fundedCents={reconciliationData.fundedCents}
isOverdue={reconciliationData.isOverdue}
overdueAmount={reconciliationData.overdueAmount}
message={reconciliationData.message}
nextDueDate={reconciliationData.nextDueDate}
onClose={() => {
setShowPaymentReconciliation(false);
setReconciliationData(null);
}}
onSuccess={async () => {
setShowPaymentReconciliation(false);
setReconciliationData(null);
// Clear any "remind me later" dismissal since they've handled it
if (reconciliationData?.planId) {
localStorage.removeItem(`overdue-dismissed-${reconciliationData.planId}`);
}
push("ok", "Payment recorded successfully");
await refetch();
if (dueQueue.length > 0) {
await advanceDueQueue();
}
}}
/>
)}
{earlyPaymentPrompt && (
<EarlyPaymentPromptModal
planId={earlyPaymentPrompt.planId}
planName={earlyPaymentPrompt.planName}
dueOn={earlyPaymentPrompt.dueOn}
timezone={userTimezone}
onClose={() => {
const key = `early-paid-prompt-${earlyPaymentPrompt.planId}-${earlyPaymentPrompt.dueOn}`;
localStorage.setItem(key, "dismissed");
setEarlyPaymentPrompt(null);
}}
onConfirmed={() => {
const key = `early-paid-prompt-${earlyPaymentPrompt.planId}-${earlyPaymentPrompt.dueOn}`;
localStorage.setItem(key, "confirmed");
refetch();
}}
/>
)}
{/* HEADER */}
<header>
<h1 className="text-2xl font-bold">Hello, {greetingName}!</h1>
<p className="text-sm muted">
Snapshot of balances, plans, and recent activity.
</p>
</header>
{/* OVERDUE BILLS WARNING BANNER */}
{(() => {
const overduePlans = data.fixedPlans.filter((p: any) => p.isOverdue && (p.overdueAmount ?? 0) > 0);
if (overduePlans.length === 0) return null;
const totalOverdue = overduePlans.reduce((sum: number, p: any) => sum + (p.overdueAmount ?? 0), 0);
return (
<div className="bg-red-500/15 border-2 border-red-500 rounded-xl p-4 space-y-2">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<span className="text-2xl">!</span>
<div>
<div className="font-bold text-red-400">
{overduePlans.length === 1
? `${overduePlans[0].name} is overdue`
: `${overduePlans.length} bills are overdue`}
</div>
<div className="text-sm text-red-300">
Total outstanding: <Money cents={totalOverdue} /> · Post income to auto-fund
</div>
</div>
<Link to="/income" className="btn bg-red-500 hover:bg-red-600 w-full sm:w-auto sm:ml-auto">
+ Add Income
</Link>
</div>
{overduePlans.length > 1 && (
<div className="text-xs text-red-300 pl-9">
Priority order: {overduePlans
.sort((a: any, b: any) => {
const aTime = a.overdueSince ? new Date(a.overdueSince).getTime() : 0;
const bTime = b.overdueSince ? new Date(b.overdueSince).getTime() : 0;
return aTime - bTime;
})
.map((p: any) => `${p.name} ($${((p.overdueAmount ?? 0) / 100).toFixed(0)})`)
.join(" → ")}
</div>
)}
</div>
);
})()}
<PercentGuard />
{/* BUDGET OVERVIEW key metrics */}
<section className="grid gap-4 md:grid-cols-3">
<Card label="Total Budget">
<Money cents={data.totals.incomeCents} />
</Card>
<Card label="Available Budget">
<Money cents={data.totals.variableBalanceCents} />
</Card>
<Card label="Funded Towards Fixed Plans">
<Money cents={data.fixedPlans.reduce((sum, plan) => sum + (plan.fundedCents || 0), 0)} />
</Card>
</section>
{/* ANALYTICS SECTION - Desktop: Show early with tabs */}
{isDesktop && (
<section className="space-y-4">
<div className="row items-center gap-3">
<h2 className="font-semibold">Analytics</h2>
<div className="row gap-1 ml-auto rounded-full bg-[--color-panel] p-1">
<AnalyticsTabButton
label="Overview"
active={activeTab === "overview"}
onClick={() => setActiveTab("overview")}
/>
<AnalyticsTabButton
label="Variable"
active={activeTab === "variable"}
onClick={() => setActiveTab("variable")}
/>
<AnalyticsTabButton
label="Fixed"
active={activeTab === "fixed"}
onClick={() => setActiveTab("fixed")}
/>
<AnalyticsTabButton
label="Trend"
active={activeTab === "trend"}
onClick={() => setActiveTab("trend")}
/>
</div>
</div>
{activeTab === "overview" && (
<div className="grid gap-6 lg:grid-cols-2">
<VariableAllocationDonut data={variableSlices} />
<FixedFundingBars data={fixedChartData} />
</div>
)}
{activeTab === "variable" && (
<VariableAllocationDonut data={variableSlices} />
)}
{activeTab === "fixed" && (
<FixedFundingBars data={fixedChartData} />
)}
{activeTab === "trend" && (
<section className="space-y-4">
{useTrendWindowPagination ? (
<TrendWindowControls
label={trendWindowLabel}
canGoOlder={canTrendOlder}
canGoNewer={canTrendNewer}
busy={isFetching}
onOlder={() => setTrendPage((p) => p + 1)}
onNewer={() => setTrendPage((p) => Math.max(0, p - 1))}
/>
) : null}
<section className="grid gap-6 lg:grid-cols-2">
<MonthlyTrendChart data={data.monthlyTrend} />
<RecentTransactionsPanel
transactions={data.recentTransactions}
hasData={hasTx}
rangeLabel={useTrendWindowPagination ? trendWindowLabel : undefined}
userTimezone={userTimezone}
/>
</section>
</section>
)}
</section>
)}
{/* UPCOMING PLAN ALERTS */}
{data.upcomingPlans.length > 0 && (
<UpcomingPlanAlerts plans={data.upcomingPlans} userTimezone={userTimezone} />
)}
{/* VARIABLE / FIXED LISTS */}
<section className="grid gap-6 lg:grid-cols-2">
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="font-semibold">Variable Categories</h2>
<span className="text-sm muted">
{data.percentTotal}%
</span>
</div>
{!hasCategories ? (
<EmptyState
message="No categories yet"
actionLabel="Create categories"
actionTo="/settings/categories"
/>
) : (
<div className="border rounded-xl divide-y">
{data.variableCategories.map((c) => {
return (
<div key={c.id} className={`p-3 space-y-2 ${c.isSavings ? 'bg-gradient-to-br from-emerald-50/10 via-emerald-50/5 to-transparent border-2 border-emerald-400/60 shadow-lg shadow-emerald-500/20 ring-1 ring-emerald-400/30' : ''}`}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="font-medium">{c.name}</div>
<div className="text-xs muted">
{c.isSavings ? "Savings • " : ""}
Priority {c.priority}
</div>
{c.isSavings &&
(c.savingsTargetCents ?? 0) > 0 && (
<div className="text-xs text-emerald-400 font-medium">
Goal ${currency(c.savingsTargetCents ?? 0)}
</div>
)}
</div>
<div className="text-right">
{c.isSavings ? (
<>
<Money
cents={
(c.balanceCents ?? 0) +
(savingsReserveById.get(c.id) ?? 0)
}
/>
<div className="text-xs muted">
Available <Money cents={c.balanceCents ?? 0} /> ·
Reserved{" "}
<Money cents={savingsReserveById.get(c.id) ?? 0} />
</div>
<div className="text-xs muted">{c.percent}%</div>
</>
) : (
<>
<Money cents={c.balanceCents ?? 0} />
<div className="text-xs muted">{c.percent}%</div>
</>
)}
</div>
</div>
<ProgressBar value={c.percent} max={100} />
</div>
);
})}
</div>
)}
</div>
<div className="space-y-3">
<h2 className="font-semibold">Fixed plans</h2>
{!hasPlans ? (
<EmptyState
message="No plans configured"
actionLabel="Add a plan"
actionTo="/settings/plans"
/>
) : (
<div className="border rounded-xl divide-y">
{data.fixedPlans.map((p) => {
const total = p.totalCents ?? 0;
const funded = Math.min(p.fundedCents ?? 0, total);
const remaining = Math.max(total - funded, 0);
const pct = total
? Math.round((funded / total) * 100)
: 0;
const isOverdue = (p as any).isOverdue;
const overdueAmount = (p as any).overdueAmount ?? 0;
const aheadCents = getFundingAhead(p);
return (
<div
key={p.id}
className={`p-3 space-y-2 ${isOverdue ? 'bg-red-500/10 border-l-4 border-red-500' : ''}`}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<div className="font-medium">{p.name}</div>
{isOverdue && (
<span className="text-xs px-2 py-0.5 bg-red-500/20 text-red-400 rounded-full font-semibold">
OVERDUE
</span>
)}
{aheadCents !== null && (
<span className="text-xs px-2 py-0.5 bg-emerald-500/20 text-emerald-400 rounded-full font-semibold">
Ahead {new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format((aheadCents ?? 0) / 100)}
</span>
)}
</div>
<div className="text-xs muted">
Due{" "}
{formatDateInTimezone(p.dueOn, userTimezone)} ·
Priority {p.priority}
</div>
{isOverdue && overdueAmount > 0 && (
<div className="text-xs text-red-400 font-medium mt-1">
Outstanding: <Money cents={overdueAmount} />
</div>
)}
</div>
<div className="text-right text-sm">
Remaining <Money cents={remaining} />
</div>
</div>
<ProgressBar
value={funded}
max={total || 1}
label={`Funded ${pct}%`}
variant={isOverdue ? "danger" : "default"}
/>
</div>
);
})}
</div>
)}
</div>
</section>
{/* SAVINGS GOALS */}
{data.savingsTargets.length > 0 && (
<SavingsGoalsPanel goals={data.savingsTargets} />
)}
{/* Mobile / tablet: tabbed analytics */}
<section className="space-y-4 lg:hidden">
<div className="row items-center gap-3">
<h2 className="font-semibold">Analytics</h2>
<div className="row gap-1 ml-auto rounded-full bg-[--color-panel] p-1">
<AnalyticsTabButton
label="Variable"
active={activeTab === "variable"}
onClick={() => setActiveTab("variable")}
/>
<AnalyticsTabButton
label="Fixed"
active={activeTab === "fixed"}
onClick={() => setActiveTab("fixed")}
/>
<AnalyticsTabButton
label="Trend"
active={activeTab === "trend"}
onClick={() => setActiveTab("trend")}
/>
</div>
</div>
{activeTab === "variable" && (
<VariableAllocationDonut data={variableSlices} />
)}
{activeTab === "fixed" && (
<FixedFundingBars data={fixedChartData} />
)}
{activeTab === "trend" && (
<section className="space-y-4">
{useTrendWindowPagination ? (
<TrendWindowControls
label={trendWindowLabel}
canGoOlder={canTrendOlder}
canGoNewer={canTrendNewer}
busy={isFetching}
onOlder={() => setTrendPage((p) => p + 1)}
onNewer={() => setTrendPage((p) => Math.max(0, p - 1))}
/>
) : null}
<MonthlyTrendChart data={data.monthlyTrend} />
<RecentTransactionsPanel
transactions={data.recentTransactions}
hasData={hasTx}
rangeLabel={useTrendWindowPagination ? trendWindowLabel : undefined}
userTimezone={userTimezone}
/>
</section>
)}
</section>
</div>
);
}
/* ───────────────────────────
* Small components
* ───────────────────────────*/
function AnalyticsTabButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={
"px-3 py-1 rounded-full text-xs font-semibold transition " +
(active
? "bg-[--color-ink] text-[--color-bg]"
: "text-[--color-muted] hover:text-[--color-text]")
}
>
{label}
</button>
);
}
function TrendWindowControls({
label,
canGoOlder,
canGoNewer,
busy,
onOlder,
onNewer,
}: {
label: string;
canGoOlder: boolean;
canGoNewer: boolean;
busy: boolean;
onOlder: () => void;
onNewer: () => void;
}) {
return (
<div className="row items-center gap-2 rounded-xl border px-3 py-2 bg-[--color-panel]">
<button
type="button"
className="btn"
onClick={onNewer}
disabled={!canGoNewer || busy}
>
Newer
</button>
<div className="text-sm muted ml-1">{label}</div>
<button
type="button"
className="btn ml-auto"
onClick={onOlder}
disabled={!canGoOlder || busy}
>
Older
</button>
</div>
);
}
function UpcomingPlanAlerts({
plans,
userTimezone,
}: {
plans: { id: string; name: string; dueOn: string; remainingCents: number }[];
userTimezone: string;
}) {
if (plans.length === 0) return null;
return (
<section className="rounded-xl border border-amber-300/70 bg-amber-50/70 p-4 space-y-3 dark:border-amber-500/40 dark:bg-amber-500/10">
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-50">
Upcoming plan alerts (next 14 days)
</div>
<ul className="space-y-2">
{plans.map((plan) => (
<li
key={plan.id}
className="flex flex-col gap-2 rounded-lg bg-amber-100/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-200/15 dark:text-amber-50 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<div className="font-medium text-amber-950 dark:text-white">{plan.name}</div>
<div className="text-xs text-amber-700 dark:text-amber-200">
Due{" "}
{formatDateInTimezone(plan.dueOn, userTimezone, {
month: "short",
day: "numeric",
})}
</div>
</div>
<div className="text-right">
<div className="text-xs text-amber-700 dark:text-amber-200">Remaining</div>
<Money cents={plan.remainingCents} />
</div>
</li>
))}
</ul>
</section>
);
}
function SavingsGoalsPanel({
goals,
}: {
goals: Array<{
id: string;
name: string;
balanceCents: number;
targetCents: number;
percent: number;
}>;
}) {
return (
<section className="border rounded-xl p-4 space-y-3">
<h2 className="font-semibold">Savings goals</h2>
<ul className="space-y-3">
{goals.map((goal) => (
<li key={goal.id} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{goal.name}</span>
<span className="text-xs muted">{goal.percent}%</span>
</div>
<div className="h-2 rounded-full bg-[--color-panel] overflow-hidden shadow-inner">
<div
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400 shadow-sm"
style={{ width: `${Math.min(100, goal.percent)}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs muted">
<span>
<Money cents={goal.balanceCents} /> saved
</span>
<span>
Goal <Money cents={goal.targetCents} />
</span>
</div>
</li>
))}
</ul>
</section>
);
}
function RecentTransactionsPanel({
transactions,
hasData,
rangeLabel,
userTimezone,
}: {
transactions: DashboardResponse["recentTransactions"];
hasData: boolean;
rangeLabel?: string;
userTimezone: string;
}) {
const title = rangeLabel ? "Transactions in window" : "Recent transactions";
const visibleTransactions = transactions.slice(0, 10);
const currentMonthLabel = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
timeZone: userTimezone,
}).format(new Date());
if (!hasData) {
return (
<div className="space-y-3">
<h2 className="font-semibold">{title}</h2>
{rangeLabel ? <div className="text-xs muted">{rangeLabel}</div> : null}
<div className="text-xs muted">
Tip: Records defaults to This month ({currentMonthLabel}).
</div>
<EmptyState
message="No transactions yet"
actionLabel="Record one"
actionTo="/spend"
/>
</div>
);
}
return (
<div className="space-y-3">
<h2 className="font-semibold">{title}</h2>
{rangeLabel ? <div className="text-xs muted">{rangeLabel}</div> : null}
<div className="text-xs muted">
Tip: Records defaults to This month ({currentMonthLabel}).
</div>
{/* Desktop table view */}
<div className="hidden sm:block border rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[--color-panel]">
<tr>
<th className="text-left p-2">Date</th>
<th className="text-left p-2">Kind</th>
<th className="text-right p-2">Amount</th>
</tr>
</thead>
<tbody>
{visibleTransactions.map((tx) => (
<tr key={tx.id} className="border-t">
<td className="p-2">
{new Date(tx.occurredAt).toLocaleString()}
</td>
<td className="p-2 capitalize">
{tx.kind.replace("_", " ")}
</td>
<td className="p-2 text-right font-mono">
<Money cents={tx.amountCents} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card view */}
<div className="sm:hidden space-y-2">
{visibleTransactions.map((tx) => (
<div key={tx.id} className="border rounded-xl bg-[--color-panel] p-3">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium capitalize">
{tx.kind.replace("_", " ")}
</div>
<div className="text-xs muted">
{new Date(tx.occurredAt).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})}
</div>
</div>
<div className="text-base font-mono font-semibold">
<Money cents={tx.amountCents} />
</div>
</div>
</div>
))}
</div>
<div>
<Link to="/transactions" className="btn text-sm">
View more
</Link>
</div>
</div>
);
}
// Modernized KPI card
function Card({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="card space-y-2">
<div className="text-xs uppercase tracking-[0.2em] muted">
{label}
</div>
<div className="text-2xl font-semibold">{children}</div>
</div>
);
}
function ProgressBar({
value,
max,
label,
variant = "default",
}: {
value: number;
max: number;
label?: string;
variant?: "default" | "danger";
}) {
const pct = Math.min(100, Math.round((value / Math.max(max, 1)) * 100));
const barColor = variant === "danger" ? "bg-red-500" : "bg-[--color-ink]";
return (
<div className="space-y-1">
{label && <div className="text-xs muted">{label}</div>}
<div className="h-2 w-full rounded-full bg-[--color-panel] overflow-hidden">
<div
className={`h-full ${barColor}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function EmptyState({
message,
actionLabel,
actionTo,
}: {
message: string;
actionLabel: string;
actionTo: string;
}) {
return (
<div className="rounded-xl border border-dashed p-6 text-center space-y-2">
<p className="muted">{message}</p>
<Link to={actionTo} className="btn text-sm">
{actionLabel}
</Link>
</div>
);
}