Compare commits
No commits in common. "541c443efd2c571d47223cdfc9e0751093f136fd" and "886afab1efa826237a17404c67445c533de98596" have entirely different histories.
541c443efd
...
886afab1ef
34
package-lock.json
generated
34
package-lock.json
generated
@ -12,7 +12,6 @@
|
|||||||
"@radix-ui/react-accordion": "^1.2.8",
|
"@radix-ui/react-accordion": "^1.2.8",
|
||||||
"@radix-ui/react-label": "^2.1.4",
|
"@radix-ui/react-label": "^2.1.4",
|
||||||
"@radix-ui/react-select": "^2.2.2",
|
"@radix-ui/react-select": "^2.2.2",
|
||||||
"@radix-ui/react-slider": "^1.3.2",
|
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@ -1359,39 +1358,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
||||||
|
@ -20,7 +20,6 @@
|
|||||||
"@radix-ui/react-accordion": "^1.2.8",
|
"@radix-ui/react-accordion": "^1.2.8",
|
||||||
"@radix-ui/react-label": "^2.1.4",
|
"@radix-ui/react-label": "^2.1.4",
|
||||||
"@radix-ui/react-select": "^2.2.2",
|
"@radix-ui/react-select": "^2.2.2",
|
||||||
"@radix-ui/react-slider": "^1.3.2",
|
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@ -7,6 +8,7 @@ import * as z from "zod";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -30,25 +32,24 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
type TooltipProps,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import {
|
||||||
import assert from "assert";
|
Select,
|
||||||
import type {
|
SelectContent,
|
||||||
NameType,
|
SelectItem,
|
||||||
ValueType,
|
SelectTrigger,
|
||||||
} from "recharts/types/component/DefaultTooltipContent";
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
// Schema for form validation
|
// Schema for form validation
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
startingCapital: z.coerce.number(),
|
startingCapital: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(0, "Starting capital must be a non-negative number"),
|
||||||
monthlySavings: z.coerce
|
monthlySavings: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.min(0, "Monthly savings must be a non-negative number"),
|
.min(0, "Monthly savings must be a non-negative number"),
|
||||||
currentAge: z.coerce
|
currentAge: z.coerce.number().min(18, "Age must be at least 18"),
|
||||||
.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"),
|
cagr: z.coerce.number().min(0, "Growth rate must be a non-negative number"),
|
||||||
desiredMonthlyAllowance: z.coerce
|
desiredMonthlyAllowance: z.coerce
|
||||||
.number()
|
.number()
|
||||||
@ -58,61 +59,30 @@ const formSchema = z.object({
|
|||||||
.min(0, "Inflation rate must be a non-negative number"),
|
.min(0, "Inflation rate must be a non-negative number"),
|
||||||
lifeExpectancy: z.coerce
|
lifeExpectancy: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.min(40, "Be a bit more optimistic buddy :(")
|
.min(50, "Life expectancy must be at least 50"),
|
||||||
.max(100, "You should be more realistic..."),
|
retirementStrategy: z.enum(["Depletion", "Maintenance", "4% Rule"]),
|
||||||
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 for form values
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
interface YearlyData {
|
interface CalculationResult {
|
||||||
|
fireNumber: number | null;
|
||||||
|
retirementAge: number | null;
|
||||||
|
inflationAdjustedAllowance: number | null;
|
||||||
|
retirementYears: number | null;
|
||||||
|
error?: string;
|
||||||
|
yearlyData?: Array<{
|
||||||
age: number;
|
age: number;
|
||||||
year: number;
|
year: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
phase: "accumulation" | "retirement";
|
phase: "accumulation" | "retirement";
|
||||||
monthlyAllowance: number;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalculationResult {
|
|
||||||
fireNumber: number | null;
|
|
||||||
yearlyData: YearlyData[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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() {
|
export default function FireCalculatorForm() {
|
||||||
const [result, setResult] = useState<CalculationResult | null>(null);
|
const [result, setResult] = useState<CalculationResult | null>(null);
|
||||||
const irlYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
// Initialize form with default values
|
// Initialize form with default values
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
@ -122,10 +92,10 @@ export default function FireCalculatorForm() {
|
|||||||
monthlySavings: 1500,
|
monthlySavings: 1500,
|
||||||
currentAge: 25,
|
currentAge: 25,
|
||||||
cagr: 7,
|
cagr: 7,
|
||||||
desiredMonthlyAllowance: 3000,
|
desiredMonthlyAllowance: 2000,
|
||||||
inflationRate: 2,
|
inflationRate: 2,
|
||||||
lifeExpectancy: 84,
|
lifeExpectancy: 84,
|
||||||
retirementAge: 55,
|
retirementStrategy: "Depletion",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -134,79 +104,269 @@ export default function FireCalculatorForm() {
|
|||||||
|
|
||||||
const startingCapital = values.startingCapital;
|
const startingCapital = values.startingCapital;
|
||||||
const monthlySavings = values.monthlySavings;
|
const monthlySavings = values.monthlySavings;
|
||||||
const age = values.currentAge;
|
const currentAge = values.currentAge;
|
||||||
const annualGrowthRate = 1 + values.cagr / 100;
|
const annualGrowthRate = values.cagr / 100;
|
||||||
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
||||||
const annualInflation = 1 + values.inflationRate / 100;
|
const annualInflation = values.inflationRate / 100;
|
||||||
const ageOfDeath = values.lifeExpectancy;
|
const lifeExpectancy = values.lifeExpectancy;
|
||||||
const retirementAge = values.retirementAge;
|
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;
|
||||||
|
|
||||||
// Array to store yearly data for the chart
|
// Array to store yearly data for the chart
|
||||||
const yearlyData: YearlyData[] = [];
|
const yearlyData: CalculationResult["yearlyData"] = [];
|
||||||
|
|
||||||
// Initial year data
|
|
||||||
yearlyData.push({
|
|
||||||
age: age,
|
|
||||||
year: irlYear,
|
|
||||||
balance: startingCapital,
|
|
||||||
phase: "accumulation",
|
|
||||||
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);
|
|
||||||
|
|
||||||
const isRetirementYear = currentAge >= retirementAge;
|
|
||||||
const phase = isRetirementYear ? "retirement" : "accumulation";
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
yearlyData.push({
|
yearlyData.push({
|
||||||
age: currentAge,
|
age: currentAge,
|
||||||
year: year,
|
year: currentYear,
|
||||||
balance: newBalance,
|
balance: startingCapital,
|
||||||
phase: phase,
|
phase: "accumulation",
|
||||||
monthlyAllowance: inflatedAllowance,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let currentCapital = startingCapital;
|
||||||
|
let age = currentAge;
|
||||||
|
let monthlyAllowance = initialMonthlyAllowance;
|
||||||
|
|
||||||
|
// --- Calculation Logic based on Strategy ---
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate FIRE number at retirement
|
// Simulate one year of saving and growth
|
||||||
const retirementYear = irlYear + (retirementAge - age);
|
for (let month = 0; month < 12; month++) {
|
||||||
const retirementIndex = yearlyData.findIndex(
|
currentCapital += monthlySavings;
|
||||||
(data) => data.year === retirementYear,
|
currentCapital *= 1 + monthlyGrowthRate;
|
||||||
);
|
monthlyAllowance *= 1 + monthlyInflationRate; // Keep track of inflation-adjusted allowance
|
||||||
const retirementData = yearlyData[retirementIndex];
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (retirementIndex === -1 || !retirementData) {
|
|
||||||
setResult({
|
setResult({
|
||||||
fireNumber: null,
|
fireNumber: requiredCapital,
|
||||||
error: "Could not calculate retirement data",
|
retirementAge: retirementAge,
|
||||||
|
inflationAdjustedAllowance: finalInflationAdjustedAllowance,
|
||||||
|
retirementYears: lifeExpectancy - retirementAge,
|
||||||
yearlyData: yearlyData,
|
yearlyData: yearlyData,
|
||||||
|
error: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Set the result
|
|
||||||
setResult({
|
setResult({
|
||||||
fireNumber: retirementData.balance,
|
fireNumber: null,
|
||||||
yearlyData: yearlyData,
|
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.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
@ -231,10 +391,6 @@ export default function FireCalculatorForm() {
|
|||||||
placeholder="e.g., 10000"
|
placeholder="e.g., 10000"
|
||||||
type="number"
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -252,10 +408,6 @@ export default function FireCalculatorForm() {
|
|||||||
placeholder="e.g., 500"
|
placeholder="e.g., 500"
|
||||||
type="number"
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -273,31 +425,6 @@ export default function FireCalculatorForm() {
|
|||||||
placeholder="e.g., 30"
|
placeholder="e.g., 30"
|
||||||
type="number"
|
type="number"
|
||||||
{...field}
|
{...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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -316,32 +443,6 @@ export default function FireCalculatorForm() {
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
{...field}
|
{...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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -361,55 +462,165 @@ export default function FireCalculatorForm() {
|
|||||||
placeholder="e.g., 2000"
|
placeholder="e.g., 2000"
|
||||||
type="number"
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Retirement Age Slider */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="retirementAge"
|
name="inflationRate"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Retirement Age: {field.value}</FormLabel>
|
<FormLabel>Annual Inflation Rate (%)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Slider
|
<Input
|
||||||
name="retirementAge"
|
placeholder="e.g., 2"
|
||||||
value={[field.value]}
|
type="number"
|
||||||
min={18}
|
step="0.1"
|
||||||
max={form.getValues("lifeExpectancy")}
|
{...field}
|
||||||
step={1}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
field.onChange(...value);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
className="py-4"
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</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>
|
</div>
|
||||||
|
|
||||||
{!result && (
|
|
||||||
<Button type="submit" className="w-full">
|
<Button type="submit" className="w-full">
|
||||||
Calculate
|
Calculate
|
||||||
</Button>
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="mb-4 grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||||
|
{result.error ? (
|
||||||
|
<Card className="col-span-full">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-destructive">{result.error}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>FIRE Number</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Required capital at retirement using{" "}
|
||||||
|
{form.getValues().retirementStrategy}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{formatNumber(result.fireNumber)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Retirement Age</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Estimated age when you can retire
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{result.retirementAge ?? "N/A"}
|
||||||
|
</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?.yearlyData && (
|
|
||||||
<Card className="rounded-md shadow-none">
|
{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>
|
<CardHeader>
|
||||||
<CardTitle>Financial Projection</CardTitle>
|
<CardTitle>Financial Projection</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Projected balance growth with your selected retirement age
|
Projected balance growth and FIRE number threshold
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -420,8 +631,8 @@ export default function FireCalculatorForm() {
|
|||||||
label: "Balance",
|
label: "Balance",
|
||||||
color: "var(--chart-1)",
|
color: "var(--chart-1)",
|
||||||
},
|
},
|
||||||
realBalance: {
|
fireNumber: {
|
||||||
label: "Real Balance",
|
label: "FIRE Number",
|
||||||
color: "var(--chart-3)",
|
color: "var(--chart-3)",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@ -445,24 +656,32 @@ export default function FireCalculatorForm() {
|
|||||||
return `${(value / 1000000).toPrecision(3)}M`;
|
return `${(value / 1000000).toPrecision(3)}M`;
|
||||||
} else if (value >= 1000) {
|
} else if (value >= 1000) {
|
||||||
return `${(value / 1000).toPrecision(3)}K`;
|
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();
|
return value.toString();
|
||||||
}}
|
}}
|
||||||
width={25}
|
width={25}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={tooltipRenderer} />
|
<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>
|
<defs>
|
||||||
<linearGradient
|
<linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1">
|
||||||
id="fillBalance"
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
<stop
|
||||||
offset="5%"
|
offset="5%"
|
||||||
stopColor="var(--chart-1)"
|
stopColor="var(--chart-1)"
|
||||||
@ -474,24 +693,6 @@ export default function FireCalculatorForm() {
|
|||||||
stopOpacity={0.1}
|
stopOpacity={0.1}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</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>
|
</defs>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@ -502,32 +703,23 @@ export default function FireCalculatorForm() {
|
|||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
activeDot={{ r: 6 }}
|
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 && (
|
{result.fireNumber && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={result.fireNumber}
|
y={result.fireNumber}
|
||||||
stroke="var(--chart-3)"
|
stroke="var(--chart-3)"
|
||||||
strokeWidth={1}
|
strokeWidth={2}
|
||||||
strokeDasharray="2 2"
|
strokeDasharray="5 5"
|
||||||
label={{
|
label={{
|
||||||
value: "FIRE Number",
|
value: "FIRE Number",
|
||||||
position: "insideBottomRight",
|
position: "insideBottomRight",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{result.retirementAge && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
x={
|
x={
|
||||||
irlYear +
|
currentYear +
|
||||||
(form.getValues("retirementAge") -
|
(result.retirementAge - form.getValues().currentAge)
|
||||||
form.getValues("currentAge"))
|
|
||||||
}
|
}
|
||||||
stroke="var(--chart-2)"
|
stroke="var(--chart-2)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
@ -536,58 +728,12 @@ export default function FireCalculatorForm() {
|
|||||||
position: "insideTopRight",
|
position: "insideTopRight",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div className="mb-4 grid grid-cols-1 gap-2 md:grid-cols-2">
|
|
||||||
{result.error ? (
|
|
||||||
<Card className="col-span-full">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<p className="text-destructive">{result.error}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>FIRE Number</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Capital at retirement
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-3xl font-bold">
|
|
||||||
{formatNumber(result.fireNumber)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Retirement Duration</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Years to enjoy your financial independence
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-3xl font-bold">
|
|
||||||
{form.getValues("lifeExpectancy") -
|
|
||||||
form.getValues("retirementAge")}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,63 +0,0 @@
|
|||||||
"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 };
|
|
Loading…
x
Reference in New Issue
Block a user