Compare commits

..

5 Commits

4 changed files with 434 additions and 482 deletions

34
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@t3-oss/env-nextjs": "^0.12.0",
"class-variance-authority": "^0.7.1",
@ -1358,6 +1359,39 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.2.tgz",
"integrity": "sha512-oQnqfgSiYkxZ1MrF6672jw2/zZvpB+PJsrIc3Zm1zof1JHf/kj7WhmROw7JahLfOwYQ5/+Ip0rFORgF1tjSiaQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.0",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",

View File

@ -20,6 +20,7 @@
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@t3-oss/env-nextjs": "^0.12.0",
"class-variance-authority": "^0.7.1",

View File

@ -1,6 +1,5 @@
"use client";
import * as React from "react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@ -8,7 +7,6 @@ import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Form,
FormControl,
@ -32,24 +30,25 @@ import {
XAxis,
YAxis,
ReferenceLine,
type TooltipProps,
} from "recharts";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import assert from "assert";
import type {
NameType,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
// Schema for form validation
const formSchema = z.object({
startingCapital: z.coerce
.number()
.min(0, "Starting capital must be a non-negative number"),
startingCapital: z.coerce.number(),
monthlySavings: z.coerce
.number()
.min(0, "Monthly savings must be a non-negative number"),
currentAge: z.coerce.number().min(18, "Age must be at least 18"),
currentAge: z.coerce
.number()
.min(1, "Age must be at least 1")
.max(100, "No point in starting this late"),
cagr: z.coerce.number().min(0, "Growth rate must be a non-negative number"),
desiredMonthlyAllowance: z.coerce
.number()
@ -59,30 +58,61 @@ const formSchema = z.object({
.min(0, "Inflation rate must be a non-negative number"),
lifeExpectancy: z.coerce
.number()
.min(50, "Life expectancy must be at least 50"),
retirementStrategy: z.enum(["Depletion", "Maintenance", "4% Rule"]),
.min(40, "Be a bit more optimistic buddy :(")
.max(100, "You should be more realistic..."),
retirementAge: z.coerce
.number()
.min(18, "Retirement age must be at least 18")
.max(100, "Retirement age must be at most 100"),
});
// Type for form values
type FormValues = z.infer<typeof formSchema>;
interface YearlyData {
age: number;
year: number;
balance: number;
phase: "accumulation" | "retirement";
monthlyAllowance: number;
}
interface CalculationResult {
fireNumber: number | null;
retirementAge: number | null;
inflationAdjustedAllowance: number | null;
retirementYears: number | null;
yearlyData: YearlyData[];
error?: string;
yearlyData?: Array<{
age: number;
year: number;
balance: number;
phase: "accumulation" | "retirement";
}>;
}
// Helper function to format currency without specific symbols
const formatNumber = (value: number | null) => {
if (!value) return "N/A";
return new Intl.NumberFormat("en", {
maximumFractionDigits: 0,
}).format(value);
};
// Helper function to render tooltip for chart
const tooltipRenderer = ({
active,
payload,
}: TooltipProps<ValueType, NameType>) => {
if (active && payload?.[0]?.payload) {
const data = payload[0].payload as YearlyData;
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
<p className="text-chart-1">{`Balance: ${formatNumber(data.balance)}`}</p>
<p className="text-chart-2">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
}
return null;
};
export default function FireCalculatorForm() {
const [result, setResult] = useState<CalculationResult | null>(null);
const currentYear = new Date().getFullYear();
const irlYear = new Date().getFullYear();
// Initialize form with default values
const form = useForm<FormValues>({
@ -92,10 +122,10 @@ export default function FireCalculatorForm() {
monthlySavings: 1500,
currentAge: 25,
cagr: 7,
desiredMonthlyAllowance: 2000,
desiredMonthlyAllowance: 3000,
inflationRate: 2,
lifeExpectancy: 84,
retirementStrategy: "Depletion",
retirementAge: 55,
},
});
@ -104,269 +134,79 @@ export default function FireCalculatorForm() {
const startingCapital = values.startingCapital;
const monthlySavings = values.monthlySavings;
const currentAge = values.currentAge;
const annualGrowthRate = values.cagr / 100;
const age = values.currentAge;
const annualGrowthRate = 1 + values.cagr / 100;
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
const annualInflation = values.inflationRate / 100;
const lifeExpectancy = values.lifeExpectancy;
const retirementStrategy = values.retirementStrategy;
const monthlyGrowthRate = Math.pow(1 + annualGrowthRate, 1 / 12) - 1;
const monthlyInflationRate = Math.pow(1 + annualInflation, 1 / 12) - 1;
const maxIterations = 100; // Adjusted max iterations for age limit
let requiredCapital: number | null = null;
let retirementAge: number | null = null;
let finalInflationAdjustedAllowance: number | null = null;
let canRetire = false;
let errorMessage: string | undefined = undefined;
const annualInflation = 1 + values.inflationRate / 100;
const ageOfDeath = values.lifeExpectancy;
const retirementAge = values.retirementAge;
// Array to store yearly data for the chart
const yearlyData: CalculationResult["yearlyData"] = [];
const yearlyData: YearlyData[] = [];
// Initial year data
yearlyData.push({
age: currentAge,
year: currentYear,
age: age,
year: irlYear,
balance: startingCapital,
phase: "accumulation",
monthlyAllowance: initialMonthlyAllowance,
});
let currentCapital = startingCapital;
let age = currentAge;
let monthlyAllowance = initialMonthlyAllowance;
// Calculate accumulation phase (before retirement)
for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) {
const currentAge = age + (year - irlYear);
const previousYearData = yearlyData[yearlyData.length - 1];
const inflatedAllowance =
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
// --- Calculation Logic based on Strategy ---
const isRetirementYear = currentAge >= retirementAge;
const phase = isRetirementYear ? "retirement" : "accumulation";
if (retirementStrategy === "4% Rule") {
// --- 4% Rule Calculation ---
requiredCapital = (initialMonthlyAllowance * 12) / 0.04;
// Simulate accumulation until the 4% rule target is met
while (age < lifeExpectancy) {
if (currentCapital >= requiredCapital) {
canRetire = true;
retirementAge = age;
finalInflationAdjustedAllowance = monthlyAllowance;
break; // Found retirement age
}
// Simulate one year of saving and growth
for (let month = 0; month < 12; month++) {
currentCapital += monthlySavings;
currentCapital *= 1 + monthlyGrowthRate;
monthlyAllowance *= 1 + monthlyInflationRate; // Keep track of inflation-adjusted allowance
}
age++;
yearlyData.push({
age: age,
year: currentYear + (age - currentAge),
balance: Math.round(currentCapital),
phase: "accumulation",
});
if (age >= lifeExpectancy) break; // Stop if life expectancy is reached
assert(!!previousYearData);
// Calculate balance based on phase
let newBalance;
if (phase === "accumulation") {
// During accumulation: grow previous balance + add savings
newBalance =
previousYearData.balance * annualGrowthRate + monthlySavings * 12;
} else {
// During retirement: grow previous balance - withdraw allowance
newBalance =
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
}
if (!canRetire) {
errorMessage =
"Cannot reach FIRE goal (4% Rule) before life expectancy.";
requiredCapital = null; // Cannot retire, so no specific FIRE number applies this way
} else if (retirementAge !== null) {
// Simulate retirement phase for chart data (using 4% withdrawal adjusted for inflation)
let simulationCapital = currentCapital;
let simulationAge = retirementAge;
// Mark retirement phase in existing data
yearlyData.forEach((data) => {
if (data.age >= retirementAge!) {
data.phase = "retirement";
}
});
while (simulationAge < lifeExpectancy) {
let yearlyWithdrawal = requiredCapital * 0.04; // Initial 4%
// Adjust for inflation annually from retirement start
yearlyWithdrawal *= Math.pow(
1 + annualInflation,
simulationAge - retirementAge,
);
const monthlyWithdrawal = yearlyWithdrawal / 12;
for (let month = 0; month < 12; month++) {
simulationCapital -=
monthlyWithdrawal * Math.pow(1 + monthlyInflationRate, month); // Approximate intra-year inflation on withdrawal
simulationCapital *= 1 + monthlyGrowthRate;
}
simulationAge++;
yearlyData.push({
age: simulationAge,
year: currentYear + (simulationAge - currentAge),
balance: Math.round(simulationCapital),
phase: "retirement",
});
}
}
} else {
// --- Depletion and Maintenance Calculation (Simulation-based) ---
let iterations = 0;
while (age < lifeExpectancy && iterations < maxIterations) {
// Simulate one year of saving and growth
for (let month = 0; month < 12; month++) {
currentCapital += monthlySavings;
currentCapital *= 1 + monthlyGrowthRate;
monthlyAllowance *= 1 + monthlyInflationRate;
}
age++;
iterations++;
yearlyData.push({
age: age,
year: currentYear + (age - currentAge),
balance: Math.round(currentCapital),
phase: "accumulation",
});
// --- Check if retirement is possible at this age ---
let testCapital = currentCapital;
let testAge = age;
let testAllowance = monthlyAllowance;
let isSufficient = true;
// Simulate retirement phase to check sufficiency
while (testAge < lifeExpectancy) {
const yearlyStartCapital = testCapital;
for (let month = 0; month < 12; month++) {
const withdrawal = testAllowance;
testCapital -= withdrawal;
const growth = testCapital * monthlyGrowthRate;
testCapital += growth; // Apply growth *after* withdrawal for the month
testAllowance *= 1 + monthlyInflationRate; // Inflate allowance for next month
}
testAge++;
if (testCapital <= 0) {
// Depleted capital before life expectancy
isSufficient = false;
break;
}
if (retirementStrategy === "Maintenance") {
// Maintenance check: Withdrawal should not exceed growth for the year
// Use average capital for a slightly more stable check? Or end-of-year growth vs start-of-year withdrawal?
// Let's check if end-of-year capital is less than start-of-year capital
if (testCapital < yearlyStartCapital) {
isSufficient = false;
break; // Capital decreased, maintenance failed
}
// Alternative check: yearlyWithdrawal > yearlyGrowth
// if (yearlyWithdrawal > yearlyGrowth) {
// isSufficient = false;
// break; // Withdrawals exceed growth, maintenance failed
// }
}
} // End retirement simulation check
if (isSufficient) {
canRetire = true;
retirementAge = age;
requiredCapital = currentCapital; // The capital needed at this point
finalInflationAdjustedAllowance = monthlyAllowance; // Allowance level at retirement
break; // Found retirement age
}
} // End accumulation simulation loop
if (!canRetire) {
errorMessage = `Cannot reach FIRE goal (${retirementStrategy}) before life expectancy or within ${maxIterations} years.`;
requiredCapital = null;
} else if (retirementAge !== null) {
// Simulate the actual retirement phase for chart data if retirement is possible
let simulationCapital = requiredCapital!;
let simulationAge = retirementAge;
let simulationAllowance = finalInflationAdjustedAllowance!;
// Mark retirement phase in existing data
yearlyData.forEach((data) => {
if (data.age >= retirementAge!) {
data.phase = "retirement";
}
});
// Simulate remaining years until life expectancy
while (simulationAge < lifeExpectancy) {
for (let month = 0; month < 12; month++) {
simulationCapital -= simulationAllowance;
simulationCapital *= 1 + monthlyGrowthRate;
simulationAllowance *= 1 + monthlyInflationRate;
}
simulationAge++;
// Ensure capital doesn't go below zero for chart visibility in Depletion
const displayBalance =
retirementStrategy === "Depletion"
? Math.max(0, simulationCapital)
: simulationCapital;
yearlyData.push({
age: simulationAge,
year: currentYear + (simulationAge - currentAge),
balance: Math.round(displayBalance),
phase: "retirement",
});
}
}
} // End Depletion/Maintenance logic
// --- Set Final Result ---
if (
canRetire &&
retirementAge !== null &&
requiredCapital !== null &&
finalInflationAdjustedAllowance !== null
) {
// Ensure yearlyData covers up to lifeExpectancy if retirement happens early
const lastDataYear =
yearlyData[yearlyData.length - 1]?.year ?? currentYear;
const expectedEndYear = currentYear + (lifeExpectancy - currentAge);
if (lastDataYear < expectedEndYear) {
// Need to continue simulation purely for charting if the main calc stopped early
// (This might already be covered by the post-retirement simulation loops added above)
console.warn(
"Chart data might not extend fully to life expectancy in some scenarios.",
);
}
setResult({
fireNumber: requiredCapital,
retirementAge: retirementAge,
inflationAdjustedAllowance: finalInflationAdjustedAllowance,
retirementYears: lifeExpectancy - retirementAge,
yearlyData: yearlyData,
error: undefined,
yearlyData.push({
age: currentAge,
year: year,
balance: newBalance,
phase: phase,
monthlyAllowance: inflatedAllowance,
});
} else {
}
// Calculate FIRE number at retirement
const retirementYear = irlYear + (retirementAge - age);
const retirementIndex = yearlyData.findIndex(
(data) => data.year === retirementYear,
);
const retirementData = yearlyData[retirementIndex];
if (retirementIndex === -1 || !retirementData) {
setResult({
fireNumber: null,
retirementAge: null,
inflationAdjustedAllowance: null,
retirementYears: null,
yearlyData: yearlyData, // Show accumulation data even if goal not reached
error:
errorMessage ?? "Calculation failed to find a retirement scenario.",
error: "Could not calculate retirement data",
yearlyData: yearlyData,
});
} else {
// Set the result
setResult({
fireNumber: retirementData.balance,
yearlyData: yearlyData,
});
}
}
// Helper function to format currency without specific symbols
const formatNumber = (value: number | null) => {
if (value === null) return "N/A";
return new Intl.NumberFormat("en", {
maximumFractionDigits: 0,
}).format(value);
};
return (
<>
<Card className="mb-4">
@ -391,6 +231,10 @@ export default function FireCalculatorForm() {
placeholder="e.g., 10000"
type="number"
{...field}
onChange={(value) => {
field.onChange(value);
void form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
@ -408,6 +252,10 @@ export default function FireCalculatorForm() {
placeholder="e.g., 500"
type="number"
{...field}
onChange={(value) => {
field.onChange(value);
void form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
@ -425,6 +273,31 @@ export default function FireCalculatorForm() {
placeholder="e.g., 30"
type="number"
{...field}
onChange={(value) => {
field.onChange(value);
void form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lifeExpectancy"
render={({ field }) => (
<FormItem>
<FormLabel>Life Expectancy (Age)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 90"
type="number"
{...field}
onChange={(value) => {
field.onChange(value);
void form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
@ -443,6 +316,32 @@ export default function FireCalculatorForm() {
type="number"
step="0.1"
{...field}
onChange={(value) => {
field.onChange(value);
void form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="inflationRate"
render={({ field }) => (
<FormItem>
<FormLabel>Annual Inflation Rate (%)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2"
type="number"
step="0.1"
{...field}
onChange={(value) => {
field.onChange(value);
void form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
@ -462,79 +361,186 @@ export default function FireCalculatorForm() {
placeholder="e.g., 2000"
type="number"
{...field}
onChange={(value) => {
field.onChange(value);
void form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Retirement Age Slider */}
<FormField
control={form.control}
name="inflationRate"
name="retirementAge"
render={({ field }) => (
<FormItem>
<FormLabel>Annual Inflation Rate (%)</FormLabel>
<FormLabel>Retirement Age: {field.value}</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2"
type="number"
step="0.1"
{...field}
<Slider
name="retirementAge"
value={[field.value]}
min={18}
max={form.getValues("lifeExpectancy")}
step={1}
onValueChange={(value) => {
field.onChange(...value);
void form.handleSubmit(onSubmit)();
}}
className="py-4"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lifeExpectancy"
render={({ field }) => (
<FormItem>
<FormLabel>Life Expectancy (Age)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 90"
type="number"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="retirementStrategy"
render={({ field }) => (
<FormItem>
<FormLabel>Retirement Strategy</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a retirement strategy" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Depletion">Depletion</SelectItem>
<SelectItem value="Maintenance">
Maintenance
</SelectItem>
<SelectItem value="4% Rule">4% Rule</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-full">
Calculate
</Button>
{!result && (
<Button type="submit" className="w-full">
Calculate
</Button>
)}
{result?.yearlyData && (
<Card className="rounded-md shadow-none">
<CardHeader>
<CardTitle>Financial Projection</CardTitle>
<CardDescription>
Projected balance growth with your selected retirement age
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
className="aspect-auto h-80 w-full"
config={{
balance: {
label: "Balance",
color: "var(--chart-1)",
},
realBalance: {
label: "Real Balance",
color: "var(--chart-3)",
},
}}
>
<AreaChart
data={result.yearlyData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
label={{
value: "Year",
position: "insideBottom",
offset: -10,
}}
/>
<YAxis
tickFormatter={(value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
} else if (value <= -1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value <= -1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return value.toString();
}}
width={25}
/>
<ChartTooltip content={tooltipRenderer} />
<defs>
<linearGradient
id="fillBalance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--chart-1)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="fillAllowance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--chart-2)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-2)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="balance"
name="balance"
stroke="var(--chart-1)"
fill="url(#fillBalance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
<Area
type="monotone"
dataKey="monthlyAllowance"
name="allowance"
stroke="var(--chart-2)"
fill="url(#fillAllowance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
{result.fireNumber && (
<ReferenceLine
y={result.fireNumber}
stroke="var(--chart-3)"
strokeWidth={1}
strokeDasharray="2 2"
label={{
value: "FIRE Number",
position: "insideBottomRight",
}}
/>
)}
<ReferenceLine
x={
irlYear +
(form.getValues("retirementAge") -
form.getValues("currentAge"))
}
stroke="var(--chart-2)"
strokeWidth={2}
label={{
value: "Retirement",
position: "insideTopRight",
}}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)}
</form>
</Form>
</CardContent>
@ -554,8 +560,7 @@ export default function FireCalculatorForm() {
<CardHeader>
<CardTitle>FIRE Number</CardTitle>
<CardDescription className="text-xs">
Required capital at retirement using{" "}
{form.getValues().retirementStrategy}
Capital at retirement
</CardDescription>
</CardHeader>
<CardContent>
@ -567,173 +572,22 @@ export default function FireCalculatorForm() {
<Card>
<CardHeader>
<CardTitle>Retirement Age</CardTitle>
<CardTitle>Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Estimated age when you can retire
Years to enjoy your financial independence
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{result.retirementAge ?? "N/A"}
{form.getValues("lifeExpectancy") -
form.getValues("retirementAge")}
</p>
</CardContent>
</Card>
{result.inflationAdjustedAllowance && (
<Card>
<CardHeader>
<CardTitle>Monthly Allowance</CardTitle>
<CardDescription className="text-xs">
At retirement (inflation adjusted)
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.inflationAdjustedAllowance)}
</p>
</CardContent>
</Card>
)}
{result.retirementYears && (
<Card>
<CardHeader>
<CardTitle>Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years in retirement
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{result.retirementYears}
</p>
</CardContent>
</Card>
)}
</>
)}
</div>
)}
{result?.yearlyData && result.yearlyData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Financial Projection</CardTitle>
<CardDescription>
Projected balance growth and FIRE number threshold
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
className="aspect-auto h-80 w-full"
config={{
balance: {
label: "Balance",
color: "var(--chart-1)",
},
fireNumber: {
label: "FIRE Number",
color: "var(--chart-3)",
},
}}
>
<AreaChart
data={result.yearlyData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
label={{
value: "Year",
position: "insideBottom",
offset: -10,
}}
/>
<YAxis
tickFormatter={(value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return value.toString();
}}
width={25}
/>
<ChartTooltip
content={({ active, payload }) => {
if (active && payload?.[0]?.payload) {
const data = payload[0]
.payload as (typeof result.yearlyData)[0];
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
<p className="text-primary">{`Balance: ${formatNumber(data.balance)}`}</p>
{result.fireNumber !== null && (
<p className="text-destructive">{`Target FIRE Number: ${formatNumber(result.fireNumber)}`}</p>
)}
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
}
return null;
}}
/>
<defs>
<linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--chart-1)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="balance"
name="balance"
stroke="var(--chart-1)"
fill="url(#fillBalance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
{result.fireNumber && (
<ReferenceLine
y={result.fireNumber}
stroke="var(--chart-3)"
strokeWidth={2}
strokeDasharray="5 5"
label={{
value: "FIRE Number",
position: "insideBottomRight",
}}
/>
)}
{result.retirementAge && (
<ReferenceLine
x={
currentYear +
(result.retirementAge - form.getValues().currentAge)
}
stroke="var(--chart-2)"
strokeWidth={2}
label={{
value: "Retirement",
position: "insideTopRight",
}}
/>
)}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)}
</>
);
}

View File

@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };