Files
SkyMoney/web/src/components/OnboardingTracker.tsx

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>
);
}