399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
import { useMemo } from "react";
|
|
import { previewAllocation } from "../utils/allocatorPreview";
|
|
import { dateStringToUTCMidnight, getBrowserTimezone, isoToDateString } from "../utils/timezone";
|
|
|
|
type VariableCat = {
|
|
id: string;
|
|
name: string;
|
|
percent: number;
|
|
priority: number;
|
|
isSavings?: boolean;
|
|
};
|
|
|
|
type FixedItem = {
|
|
id: string;
|
|
name: string;
|
|
amountCents: number;
|
|
priority: number;
|
|
dueOn: string;
|
|
autoPayEnabled?: boolean;
|
|
};
|
|
|
|
type OnboardingTrackerProps = {
|
|
step: number;
|
|
budgetCents: number;
|
|
vars: VariableCat[];
|
|
fixeds: FixedItem[];
|
|
incomeType: "regular" | "irregular";
|
|
budgetPeriod?: "weekly" | "biweekly" | "monthly";
|
|
conservatismPercent?: number; // For irregular income: percentage to allocate to fixed expenses
|
|
firstIncomeDate?: Date | string; // For accurate pay period calculation
|
|
userTimezone?: string;
|
|
};
|
|
|
|
const fmtMoney = (cents: number) =>
|
|
new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: "USD",
|
|
maximumFractionDigits: 0,
|
|
}).format((cents ?? 0) / 100);
|
|
|
|
// Calculate expected funding per paycheck for regular income users
|
|
// Count actual pay periods between two dates, matching API logic
|
|
function countPayPeriodsBetween(
|
|
startDate: Date,
|
|
endDate: Date,
|
|
firstIncomeDate: Date,
|
|
frequency: "weekly" | "biweekly" | "monthly",
|
|
timezone: string
|
|
): number {
|
|
let count = 0;
|
|
let nextPayDate = new Date(firstIncomeDate);
|
|
|
|
const targetDay = Number(isoToDateString(firstIncomeDate.toISOString(), 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);
|
|
}
|
|
};
|
|
|
|
// Advance to the first pay date on or after startDate
|
|
while (nextPayDate < startDate) {
|
|
advanceByPeriod();
|
|
}
|
|
|
|
// Count all pay dates up to (but not including) the end date
|
|
while (nextPayDate < endDate) {
|
|
count++;
|
|
advanceByPeriod();
|
|
}
|
|
|
|
// Ensure at least 1 period to avoid division by zero
|
|
return Math.max(1, count);
|
|
}
|
|
|
|
function calculateExpectedFunding(
|
|
totalCents: number,
|
|
dueDate: string,
|
|
incomeFrequency: "weekly" | "biweekly" | "monthly",
|
|
firstIncomeDate?: Date | string,
|
|
now = new Date(),
|
|
timezone = getBrowserTimezone()
|
|
): number {
|
|
const daysPerPaycheck = incomeFrequency === "weekly" ? 7 : incomeFrequency === "biweekly" ? 14 : 30;
|
|
const todayIso = dateStringToUTCMidnight(isoToDateString(now.toISOString(), timezone), timezone);
|
|
const dueIso = dateStringToUTCMidnight(dueDate, timezone);
|
|
|
|
const due = new Date(dueIso);
|
|
const userNow = new Date(todayIso);
|
|
|
|
let totalPaychecks: number;
|
|
|
|
if (firstIncomeDate) {
|
|
// Use the same logic as the API: count actual pay dates
|
|
const firstIncomeIso =
|
|
typeof firstIncomeDate === "string"
|
|
? firstIncomeDate.includes("T")
|
|
? dateStringToUTCMidnight(isoToDateString(firstIncomeDate, timezone), timezone)
|
|
: dateStringToUTCMidnight(firstIncomeDate, timezone)
|
|
: dateStringToUTCMidnight(isoToDateString(firstIncomeDate.toISOString(), timezone), timezone);
|
|
const firstIncome = new Date(firstIncomeIso);
|
|
totalPaychecks = countPayPeriodsBetween(userNow, due, firstIncome, incomeFrequency, timezone);
|
|
} else {
|
|
// Fallback to simple calculation if firstIncomeDate not provided
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
const daysUntilDue = Math.max(0, Math.ceil((due.getTime() - userNow.getTime()) / DAY_MS));
|
|
totalPaychecks = Math.max(1, Math.ceil(daysUntilDue / daysPerPaycheck));
|
|
}
|
|
|
|
// Amount to fund per paycheck - use ceil to match API
|
|
const perPaycheck = Math.ceil(totalCents / totalPaychecks);
|
|
|
|
return perPaycheck;
|
|
}
|
|
|
|
export default function OnboardingTracker({
|
|
step,
|
|
budgetCents,
|
|
vars,
|
|
fixeds,
|
|
incomeType,
|
|
budgetPeriod = "monthly",
|
|
conservatismPercent = 40,
|
|
firstIncomeDate,
|
|
userTimezone,
|
|
}: OnboardingTrackerProps) {
|
|
const timezone = userTimezone || getBrowserTimezone();
|
|
// Only show tracker on steps 4, 5, and 6 (categories, fixed plans, review)
|
|
const shouldShow = step >= 4;
|
|
|
|
// Calculate totals
|
|
const eligibleFixeds = useMemo(
|
|
() => fixeds.filter((f) => f.autoPayEnabled),
|
|
[fixeds]
|
|
);
|
|
|
|
const fixedTotal = useMemo(
|
|
() => eligibleFixeds.reduce((sum, f) => sum + (f.amountCents || 0), 0),
|
|
[eligibleFixeds]
|
|
);
|
|
|
|
const varsTotal = useMemo(
|
|
() => vars.reduce((sum, v) => sum + (v.percent || 0), 0),
|
|
[vars]
|
|
);
|
|
|
|
// Preview allocation if we have a budget
|
|
const preview = useMemo(() => {
|
|
if (budgetCents <= 0) return null;
|
|
|
|
// Convert onboarding data to the format expected by previewAllocation
|
|
const fixedPlans = eligibleFixeds.map(f => {
|
|
let totalCents = f.amountCents;
|
|
let fundedCents = 0;
|
|
|
|
if (incomeType === "regular" && f.autoPayEnabled) {
|
|
// Regular income: calculate per-paycheck amount based on due date
|
|
totalCents = calculateExpectedFunding(f.amountCents, f.dueOn, budgetPeriod, firstIncomeDate, new Date(), timezone);
|
|
}
|
|
// For irregular income, we pass the full amount as the need
|
|
// The conservatism will be applied by limiting the total budget passed to previewAllocation
|
|
|
|
return {
|
|
id: f.id,
|
|
name: f.name,
|
|
totalCents,
|
|
fundedCents,
|
|
dueOn: f.dueOn,
|
|
priority: f.priority,
|
|
cycleStart: f.dueOn,
|
|
};
|
|
});
|
|
|
|
const variableCategories = vars.map(v => ({
|
|
id: v.id,
|
|
name: v.name,
|
|
percent: v.percent,
|
|
balanceCents: 0,
|
|
isSavings: v.isSavings || false,
|
|
priority: v.priority,
|
|
}));
|
|
|
|
// For irregular income, apply conservatism to split budget between fixed and variable
|
|
if (incomeType === "irregular") {
|
|
const fixedPercentage = conservatismPercent / 100;
|
|
const fixedBudget = Math.floor(budgetCents * fixedPercentage);
|
|
const variableBudget = budgetCents - fixedBudget;
|
|
|
|
// Allocate fixed budget to fixed plans (by priority)
|
|
const fixedResult = previewAllocation(fixedBudget, fixedPlans, []);
|
|
|
|
// Allocate remaining budget to variables
|
|
const variableResult = previewAllocation(variableBudget, [], variableCategories);
|
|
|
|
return {
|
|
fixed: fixedResult.fixed,
|
|
variable: variableResult.variable,
|
|
unallocatedCents: fixedResult.unallocatedCents + variableResult.unallocatedCents,
|
|
};
|
|
}
|
|
|
|
// For regular income, use standard allocation (fixed first, then variable)
|
|
return previewAllocation(budgetCents, fixedPlans, variableCategories);
|
|
}, [
|
|
budgetCents,
|
|
eligibleFixeds,
|
|
vars,
|
|
incomeType,
|
|
budgetPeriod,
|
|
conservatismPercent,
|
|
firstIncomeDate,
|
|
timezone,
|
|
]);
|
|
|
|
// Calculate actual fixed allocation amount from preview
|
|
const fixedAllocated = preview
|
|
? preview.fixed.reduce((sum, f) => sum + f.amountCents, 0)
|
|
: 0;
|
|
|
|
const variableAmount = preview
|
|
? preview.variable.reduce((sum, v) => sum + v.amountCents, 0)
|
|
: budgetCents - fixedTotal;
|
|
|
|
const isValid = varsTotal === 100 && budgetCents > 0;
|
|
|
|
if (!shouldShow || budgetCents <= 0) return null;
|
|
|
|
return (
|
|
<div className="onboarding-tracker">
|
|
<div className="row items-center gap-2 pb-3 border-b border-[--color-ink]/20">
|
|
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
|
|
<h3 className="font-bold text-sm">Live Budget Tracker</h3>
|
|
</div>
|
|
|
|
{/* Total Budget */}
|
|
<div className="tracker-section tracker-budget">
|
|
<div className="text-xs muted">Total Budget</div>
|
|
<div className="font-bold text-xl sm:text-2xl font-mono">{fmtMoney(budgetCents)}</div>
|
|
<div className="text-xs text-blue-600 dark:text-blue-400">
|
|
Available to allocate
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fixed Expenses */}
|
|
<div className="tracker-section tracker-fixed">
|
|
<div className="row items-center justify-between">
|
|
<div className="text-xs muted">Fixed Expenses</div>
|
|
<div className="text-xs font-bold">{eligibleFixeds.length} items</div>
|
|
</div>
|
|
<div className="font-bold text-lg sm:text-xl font-mono">{fmtMoney(fixedTotal)}</div>
|
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
<div
|
|
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${Math.min(100, (fixedAllocated / budgetCents) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-xs muted">
|
|
{budgetCents > 0
|
|
? `${Math.round((fixedAllocated / budgetCents) * 100)}% allocated`
|
|
: "0% of budget"}
|
|
</div>
|
|
|
|
{/* Individual fixed expenses breakdown */}
|
|
{eligibleFixeds.length > 0 && (
|
|
<div className="tracker-breakdown">
|
|
<div className="text-xs font-semibold mb-1">Breakdown:</div>
|
|
{eligibleFixeds.map((f) => {
|
|
const previewItem = preview?.fixed.find(item => item.id === f.id);
|
|
const totalAmount = f.amountCents;
|
|
const fundedAmount = previewItem?.amountCents || 0;
|
|
|
|
const fundingPercentage = totalAmount > 0
|
|
? Math.round((fundedAmount / totalAmount) * 100)
|
|
: 0;
|
|
|
|
return (
|
|
<div key={f.id} className="stack gap-1 py-1">
|
|
<div className="tracker-breakdown-row">
|
|
<span className="tracker-breakdown-name">
|
|
{f.name || "Unnamed"}
|
|
</span>
|
|
<span className="font-mono text-xs">
|
|
{fmtMoney(totalAmount)}
|
|
</span>
|
|
</div>
|
|
{f.autoPayEnabled && fundedAmount > 0 && (
|
|
<div className={`text-xs ${
|
|
incomeType === "regular"
|
|
? "text-green-700 dark:text-green-600"
|
|
: "text-blue-600 dark:text-blue-400"
|
|
}`}>
|
|
{incomeType === "regular" ? "Auto" : "Plan"}: {fmtMoney(fundedAmount)} ({fundingPercentage}%)
|
|
</div>
|
|
)}
|
|
{f.autoPayEnabled && fundedAmount === 0 && (
|
|
<div className="text-xs text-amber-600 dark:text-amber-400">
|
|
Plan enabled (no funds yet)
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Variable Categories */}
|
|
<div className="tracker-section tracker-variable">
|
|
<div className="row items-center justify-between">
|
|
<div className="text-xs muted">Variable Budget</div>
|
|
<div className="text-xs font-bold">{vars.length} categories</div>
|
|
</div>
|
|
<div className="font-bold text-lg sm:text-xl font-mono">{fmtMoney(variableAmount)}</div>
|
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
<div
|
|
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${Math.min(100, (variableAmount / budgetCents) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-xs muted">
|
|
{budgetCents > 0
|
|
? `${Math.round((variableAmount / budgetCents) * 100)}% of budget`
|
|
: "0% of budget"}
|
|
</div>
|
|
|
|
{/* Individual variable categories breakdown */}
|
|
{vars.length > 0 && preview && (
|
|
<div className="tracker-breakdown">
|
|
<div className="text-xs font-semibold mb-1">Breakdown:</div>
|
|
{preview.variable.map((item) => {
|
|
const originalVar = vars.find(v => v.id === item.id);
|
|
const percentage = originalVar?.percent || 0;
|
|
|
|
return (
|
|
<div key={item.id} className="tracker-breakdown-row py-1">
|
|
<span className="tracker-breakdown-name">
|
|
{item.name || "Unnamed"}
|
|
{originalVar?.isSavings && (
|
|
<span className="text-green-700 dark:text-green-400 font-bold"> (Savings)</span>
|
|
)}
|
|
</span>
|
|
<span className="font-mono text-xs whitespace-nowrap">
|
|
{fmtMoney(item.amountCents)} ({percentage}%)
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Percentage validation */}
|
|
<div className="pt-2 border-t border-green-300 dark:border-green-700">
|
|
<div className="row items-center justify-between">
|
|
<span className="text-xs muted">Total percentage:</span>
|
|
<span
|
|
className={`text-xs font-bold ${
|
|
varsTotal === 100
|
|
? "text-green-700 dark:text-green-400"
|
|
: "text-red-600 dark:text-red-400"
|
|
}`}
|
|
>
|
|
{varsTotal}%
|
|
</span>
|
|
</div>
|
|
{varsTotal !== 100 && (
|
|
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
|
|
Must equal 100%
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Remaining/Unallocated */}
|
|
{preview && preview.unallocatedCents > 0 && (
|
|
<div className="tracker-section bg-gray-50 dark:bg-gray-800/50">
|
|
<div className="text-xs muted">Unallocated</div>
|
|
<div className="font-bold text-lg font-mono text-gray-600 dark:text-gray-400">
|
|
{fmtMoney(preview.unallocatedCents)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status indicator */}
|
|
<div className={`text-center text-xs p-2 rounded-lg ${
|
|
isValid
|
|
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
|
: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300"
|
|
}`}>
|
|
{isValid ? "Ready to continue" : "Complete setup to continue"}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|