final touches for beta skymoney (at least i think)
This commit is contained in:
398
web/src/components/OnboardingTracker.tsx
Normal file
398
web/src/components/OnboardingTracker.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user