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 (