Removes 4% rule overlays and adds URL hydration to form
Eliminates all 4%-rule related overlays, buttons, and UI elements from the calculator for a simpler experience. Introduces hydration of calculator inputs from URL search params, enabling sharing of form state via URLs and restoring state on page reload. Updates the form's share button styling and ensures all necessary URL parameters are set for sharing. Also refactors tests to remove 4%-rule tests and adds mocks for next/navigation. Simplifies calculator behavior and improves accessibility for stateful URLs.
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { extractNumericSearchParam } from '@/lib/retire-at';
|
||||||
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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
@@ -22,7 +24,7 @@ import {
|
|||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||||
import { Calculator, Info, Percent, Share2, Check } from 'lucide-react';
|
import { Calculator, Info, Share2, Check } from 'lucide-react';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import BlurThing from './blur-thing';
|
import BlurThing from './blur-thing';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -63,8 +65,6 @@ interface YearlyData {
|
|||||||
|
|
||||||
interface CalculationResult {
|
interface CalculationResult {
|
||||||
fireNumber: number | null;
|
fireNumber: number | null;
|
||||||
fireNumber4percent: number | null;
|
|
||||||
retirementAge4percent: number | null;
|
|
||||||
yearlyData: YearlyData[];
|
yearlyData: YearlyData[];
|
||||||
error?: string;
|
error?: string;
|
||||||
successRate?: number; // For Monte Carlo
|
successRate?: number; // For Monte Carlo
|
||||||
@@ -119,7 +119,6 @@ export default function FireCalculatorForm({
|
|||||||
}) {
|
}) {
|
||||||
const [result, setResult] = useState<CalculationResult | null>(null);
|
const [result, setResult] = useState<CalculationResult | null>(null);
|
||||||
const irlYear = new Date().getFullYear();
|
const irlYear = new Date().getFullYear();
|
||||||
const [showing4percent, setShowing4percent] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// Initialize form with default values
|
// Initialize form with default values
|
||||||
@@ -128,6 +127,83 @@ export default function FireCalculatorForm({
|
|||||||
defaultValues: initialValues ?? fireCalculatorDefaultValues,
|
defaultValues: initialValues ?? fireCalculatorDefaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hydrate from URL search params
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [hasHydrated, setHasHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasHydrated) return;
|
||||||
|
if (searchParams.size === 0) {
|
||||||
|
setHasHydrated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValues: Partial<FormValues> = {};
|
||||||
|
const getParam = (key: string) => searchParams.get(key) ?? undefined;
|
||||||
|
const getNum = (key: string, bounds: { min?: number; max?: number } = {}) =>
|
||||||
|
extractNumericSearchParam(getParam(key), bounds);
|
||||||
|
|
||||||
|
const startingCapital = getNum('startingCapital', { min: 0 });
|
||||||
|
if (startingCapital !== undefined) newValues.startingCapital = startingCapital;
|
||||||
|
|
||||||
|
const monthlySavings = getNum('monthlySavings', { min: 0, max: 50000 });
|
||||||
|
if (monthlySavings !== undefined) newValues.monthlySavings = monthlySavings;
|
||||||
|
|
||||||
|
const currentAge = getNum('currentAge', { min: 1, max: 100 });
|
||||||
|
if (currentAge !== undefined) newValues.currentAge = currentAge;
|
||||||
|
|
||||||
|
const cagr = getNum('cagr') ?? getNum('growthRate', { min: 0, max: 30 });
|
||||||
|
if (cagr !== undefined) newValues.cagr = cagr;
|
||||||
|
|
||||||
|
const desiredMonthlyAllowance =
|
||||||
|
getNum('monthlySpend', { min: 0, max: 20000 }) ??
|
||||||
|
getNum('monthlyAllowance', { min: 0, max: 20000 });
|
||||||
|
if (desiredMonthlyAllowance !== undefined)
|
||||||
|
newValues.desiredMonthlyAllowance = desiredMonthlyAllowance;
|
||||||
|
|
||||||
|
const inflationRate = getNum('inflationRate', { min: 0, max: 20 });
|
||||||
|
if (inflationRate !== undefined) newValues.inflationRate = inflationRate;
|
||||||
|
|
||||||
|
const lifeExpectancy = getNum('lifeExpectancy', { min: 40, max: 110 });
|
||||||
|
if (lifeExpectancy !== undefined) newValues.lifeExpectancy = lifeExpectancy;
|
||||||
|
|
||||||
|
const retirementAge = getNum('retirementAge', { min: 18, max: 100 });
|
||||||
|
if (retirementAge !== undefined) newValues.retirementAge = retirementAge;
|
||||||
|
|
||||||
|
const coastFireAge = getNum('coastFireAge', { min: 18, max: 100 });
|
||||||
|
if (coastFireAge !== undefined) newValues.coastFireAge = coastFireAge;
|
||||||
|
|
||||||
|
const baristaIncome = getNum('baristaIncome', { min: 0 });
|
||||||
|
if (baristaIncome !== undefined) newValues.baristaIncome = baristaIncome;
|
||||||
|
|
||||||
|
const volatility = getNum('volatility', { min: 0 });
|
||||||
|
if (volatility !== undefined) newValues.volatility = volatility;
|
||||||
|
|
||||||
|
const withdrawalPercentage = getNum('withdrawalPercentage', { min: 0, max: 100 });
|
||||||
|
if (withdrawalPercentage !== undefined) newValues.withdrawalPercentage = withdrawalPercentage;
|
||||||
|
|
||||||
|
const simMode = searchParams.get('simulationMode');
|
||||||
|
if (simMode === 'deterministic' || simMode === 'monte-carlo') {
|
||||||
|
newValues.simulationMode = simMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wStrategy = searchParams.get('withdrawalStrategy');
|
||||||
|
if (wStrategy === 'fixed' || wStrategy === 'percentage') {
|
||||||
|
newValues.withdrawalStrategy = wStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(newValues).length > 0) {
|
||||||
|
// We merge with current values (which are defaults initially)
|
||||||
|
const merged = { ...form.getValues(), ...newValues };
|
||||||
|
form.reset(merged);
|
||||||
|
// Trigger calculation
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}
|
||||||
|
setHasHydrated(true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchParams, hasHydrated]); // form is stable, but adding it causes no harm, excluding for cleaner hook deps
|
||||||
|
|
||||||
function onSubmit(values: FormValues) {
|
function onSubmit(values: FormValues) {
|
||||||
setResult(null); // Reset previous results
|
setResult(null); // Reset previous results
|
||||||
|
|
||||||
@@ -271,42 +347,16 @@ export default function FireCalculatorForm({
|
|||||||
const retirementIndex = yearlyData.findIndex((data) => data.year === retirementYear);
|
const retirementIndex = yearlyData.findIndex((data) => data.year === retirementYear);
|
||||||
const retirementData = yearlyData[retirementIndex];
|
const retirementData = yearlyData[retirementIndex];
|
||||||
|
|
||||||
const [fireNumber4percent, retirementAge4percent] = (() => {
|
|
||||||
// Re-enable 4% rule for deterministic mode or use p50 for MC
|
|
||||||
// For MC, "untouchedBalance" isn't tracked per run in aggregate, but we can use balanceP50 roughly
|
|
||||||
// or just disable it as it's a different philosophy.
|
|
||||||
// For now, let's calculate it based on the main "balance" field (which is p50 in MC)
|
|
||||||
for (const yearData of yearlyData) {
|
|
||||||
// Note: This is imperfect for MC because 'balance' includes withdrawals in retirement
|
|
||||||
// whereas 4% rule check usually looks at "if I retired now with this balance".
|
|
||||||
// The original code had `untouchedBalance` which grew without withdrawals.
|
|
||||||
// Since we removed `untouchedBalance` calculation in the aggregate loop, let's skip 4% for MC for now.
|
|
||||||
|
|
||||||
if (
|
|
||||||
simulationMode === 'deterministic' &&
|
|
||||||
yearData.untouchedBalance &&
|
|
||||||
yearData.untouchedBalance > (yearData.untouchedMonthlyAllowance * 12) / 0.04
|
|
||||||
) {
|
|
||||||
return [yearData.untouchedBalance, yearData.age];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [null, null];
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (retirementIndex === -1) {
|
if (retirementIndex === -1) {
|
||||||
setResult({
|
setResult({
|
||||||
fireNumber: null,
|
fireNumber: null,
|
||||||
fireNumber4percent: null,
|
|
||||||
retirementAge4percent: null,
|
|
||||||
error: 'Could not calculate retirement data',
|
|
||||||
yearlyData: yearlyData,
|
yearlyData: yearlyData,
|
||||||
|
error: 'Could not calculate retirement data',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Set the result
|
// Set the result
|
||||||
setResult({
|
setResult({
|
||||||
fireNumber: retirementData.balance,
|
fireNumber: retirementData.balance,
|
||||||
fireNumber4percent: fireNumber4percent,
|
|
||||||
retirementAge4percent: retirementAge4percent,
|
|
||||||
yearlyData: yearlyData,
|
yearlyData: yearlyData,
|
||||||
successRate:
|
successRate:
|
||||||
simulationMode === 'monte-carlo' ? (successCount / numSimulations) * 100 : undefined,
|
simulationMode === 'monte-carlo' ? (successCount / numSimulations) * 100 : undefined,
|
||||||
@@ -324,34 +374,23 @@ export default function FireCalculatorForm({
|
|||||||
}, [autoCalculate]);
|
}, [autoCalculate]);
|
||||||
|
|
||||||
const handleShare = () => {
|
const handleShare = () => {
|
||||||
const values = form.getValues();
|
const values = form.getValues() as FireCalculatorFormValues;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (values.startingCapital !== undefined && values.startingCapital !== null)
|
params.set('startingCapital', String(values.startingCapital));
|
||||||
params.set('startingCapital', String(values.startingCapital));
|
params.set('monthlySavings', String(values.monthlySavings));
|
||||||
if (values.monthlySavings !== undefined && values.monthlySavings !== null)
|
params.set('currentAge', String(values.currentAge));
|
||||||
params.set('monthlySavings', String(values.monthlySavings));
|
params.set('cagr', String(values.cagr));
|
||||||
if (values.currentAge !== undefined && values.currentAge !== null)
|
params.set('monthlySpend', String(values.desiredMonthlyAllowance));
|
||||||
params.set('currentAge', String(values.currentAge));
|
params.set('inflationRate', String(values.inflationRate));
|
||||||
if (values.cagr !== undefined && values.cagr !== null) params.set('cagr', String(values.cagr));
|
params.set('lifeExpectancy', String(values.lifeExpectancy));
|
||||||
if (values.desiredMonthlyAllowance !== undefined && values.desiredMonthlyAllowance !== null)
|
params.set('retirementAge', String(values.retirementAge));
|
||||||
params.set('monthlySpend', String(values.desiredMonthlyAllowance));
|
params.set('coastFireAge', String(values.coastFireAge));
|
||||||
if (values.inflationRate !== undefined && values.inflationRate !== null)
|
params.set('baristaIncome', String(values.baristaIncome));
|
||||||
params.set('inflationRate', String(values.inflationRate));
|
params.set('simulationMode', values.simulationMode);
|
||||||
if (values.lifeExpectancy !== undefined && values.lifeExpectancy !== null)
|
params.set('volatility', String(values.volatility));
|
||||||
params.set('lifeExpectancy', String(values.lifeExpectancy));
|
params.set('withdrawalStrategy', values.withdrawalStrategy);
|
||||||
if (values.retirementAge !== undefined && values.retirementAge !== null)
|
params.set('withdrawalPercentage', String(values.withdrawalPercentage));
|
||||||
params.set('retirementAge', String(values.retirementAge));
|
|
||||||
if (values.coastFireAge !== undefined && values.coastFireAge !== null)
|
|
||||||
params.set('coastFireAge', String(values.coastFireAge));
|
|
||||||
if (values.baristaIncome !== undefined && values.baristaIncome !== null)
|
|
||||||
params.set('baristaIncome', String(values.baristaIncome));
|
|
||||||
if (values.simulationMode) params.set('simulationMode', values.simulationMode);
|
|
||||||
if (values.volatility !== undefined && values.volatility !== null)
|
|
||||||
params.set('volatility', String(values.volatility));
|
|
||||||
if (values.withdrawalStrategy) params.set('withdrawalStrategy', values.withdrawalStrategy);
|
|
||||||
if (values.withdrawalPercentage !== undefined && values.withdrawalPercentage !== null)
|
|
||||||
params.set('withdrawalPercentage', String(values.withdrawalPercentage));
|
|
||||||
|
|
||||||
const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
@@ -924,19 +963,6 @@ export default function FireCalculatorForm({
|
|||||||
yAxisId={'right'}
|
yAxisId={'right'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{result.fireNumber4percent && showing4percent && (
|
|
||||||
<ReferenceLine
|
|
||||||
y={result.fireNumber4percent}
|
|
||||||
stroke="var(--secondary)"
|
|
||||||
strokeWidth={1}
|
|
||||||
strokeDasharray="1 1"
|
|
||||||
label={{
|
|
||||||
value: '4%-Rule FIRE Number',
|
|
||||||
position: 'insideBottomLeft',
|
|
||||||
}}
|
|
||||||
yAxisId={'right'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
x={
|
x={
|
||||||
irlYear +
|
irlYear +
|
||||||
@@ -951,45 +977,18 @@ export default function FireCalculatorForm({
|
|||||||
}}
|
}}
|
||||||
yAxisId={'left'}
|
yAxisId={'left'}
|
||||||
/>
|
/>
|
||||||
{result.retirementAge4percent && showing4percent && (
|
|
||||||
<ReferenceLine
|
|
||||||
x={
|
|
||||||
irlYear +
|
|
||||||
(result.retirementAge4percent - Number(form.getValues('currentAge')))
|
|
||||||
}
|
|
||||||
stroke="var(--secondary)"
|
|
||||||
strokeWidth={1}
|
|
||||||
label={{
|
|
||||||
value: '4%-Rule Retirement',
|
|
||||||
position: 'insideBottomLeft',
|
|
||||||
}}
|
|
||||||
yAxisId={'left'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{result && (
|
{result && (
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap justify-end gap-2">
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowing4percent(!showing4percent);
|
|
||||||
}}
|
|
||||||
variant={showing4percent ? 'secondary' : 'default'}
|
|
||||||
size={'sm'}
|
|
||||||
className="gap-2"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Percent className="h-4 w-4" />
|
|
||||||
{showing4percent ? 'Hide' : 'Show'} 4%-Rule
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
variant="outline"
|
variant="default"
|
||||||
size={'sm'}
|
size={'lg'}
|
||||||
className="gap-2"
|
className="w-full gap-2 md:w-auto"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{copied ? <Check className="h-4 w-4" /> : <Share2 className="h-4 w-4" />}
|
{copied ? <Check className="h-4 w-4" /> : <Share2 className="h-4 w-4" />}
|
||||||
@@ -1035,35 +1034,6 @@ export default function FireCalculatorForm({
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{showing4percent && (
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>4%-Rule FIRE Number</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Capital needed for 4% of it to be greater than your yearly allowance
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-3xl font-bold">{formatNumber(result.fireNumber4percent)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>4%-Rule Retirement Duration</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Years to enjoy your financial independence if you follow the 4% rule
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-3xl font-bold">
|
|
||||||
{Number(form.getValues('lifeExpectancy')) - (result.retirementAge4percent ?? 0)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ vi.mock('recharts', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useSearchParams: () => new URLSearchParams(),
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
prefetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
usePathname: () => '/',
|
||||||
|
}));
|
||||||
|
|
||||||
describe('FireCalculatorForm', () => {
|
describe('FireCalculatorForm', () => {
|
||||||
it('renders the form with default values', () => {
|
it('renders the form with default values', () => {
|
||||||
render(<FireCalculatorForm />);
|
render(<FireCalculatorForm />);
|
||||||
@@ -101,30 +112,6 @@ describe('FireCalculatorForm', () => {
|
|||||||
expect(await screen.findByRole('spinbutton', { name: /Market Volatility/i })).toBeInTheDocument();
|
expect(await screen.findByRole('spinbutton', { name: /Market Volatility/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toggles 4% Rule overlay', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<FireCalculatorForm />);
|
|
||||||
|
|
||||||
// Calculate first to show results
|
|
||||||
const calculateButton = screen.getByRole('button', { name: /Calculate/i });
|
|
||||||
await user.click(calculateButton);
|
|
||||||
|
|
||||||
// Wait for results
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Financial Projection')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the Show 4%-Rule button
|
|
||||||
const showButton = screen.getByRole('button', { name: /Show 4%-Rule/i });
|
|
||||||
await user.click(showButton);
|
|
||||||
|
|
||||||
// Should now see 4%-Rule stats
|
|
||||||
expect(await screen.findByText('4%-Rule FIRE Number')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Button text should change
|
|
||||||
expect(screen.getByRole('button', { name: /Hide 4%-Rule/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles withdrawal strategy selection', async () => {
|
it('handles withdrawal strategy selection', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<FireCalculatorForm />);
|
render(<FireCalculatorForm />);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import FireCalculatorForm from './components/FireCalculatorForm';
|
import FireCalculatorForm from './components/FireCalculatorForm';
|
||||||
import BackgroundPattern from './components/BackgroundPattern';
|
import BackgroundPattern from './components/BackgroundPattern';
|
||||||
import { FaqSection, type FaqItem } from './components/FaqSection';
|
import { FaqSection, type FaqItem } from './components/FaqSection';
|
||||||
import { Testimonials } from './components/Testimonials';
|
import { Testimonials } from './components/Testimonials';
|
||||||
import { extractCalculatorValuesFromSearch } from '@/lib/retire-at';
|
|
||||||
|
|
||||||
const faqs: FaqItem[] = [
|
const faqs: FaqItem[] = [
|
||||||
{
|
{
|
||||||
@@ -38,23 +38,7 @@ const faqs: FaqItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default async function HomePage({
|
export default function HomePage() {
|
||||||
searchParams,
|
|
||||||
}: Readonly<{
|
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
||||||
}>) {
|
|
||||||
const resolvedParams = await searchParams;
|
|
||||||
|
|
||||||
// Parse target age from params to seed defaults correctly (e.g. currentAge logic depends on it)
|
|
||||||
const paramRetireAge = Array.isArray(resolvedParams.retirementAge)
|
|
||||||
? resolvedParams.retirementAge[0]
|
|
||||||
: resolvedParams.retirementAge;
|
|
||||||
|
|
||||||
const targetAge =
|
|
||||||
paramRetireAge && !Number.isNaN(Number(paramRetireAge)) ? Number(paramRetireAge) : 55;
|
|
||||||
|
|
||||||
const initialValues = extractCalculatorValuesFromSearch(resolvedParams, targetAge);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="from-background via-primary/10 to-secondary/10 text-foreground relative flex min-h-screen w-full flex-col items-center overflow-hidden bg-gradient-to-b px-4 pt-6 pb-16">
|
<div className="from-background via-primary/10 to-secondary/10 text-foreground relative flex min-h-screen w-full flex-col items-center overflow-hidden bg-gradient-to-b px-4 pt-6 pb-16">
|
||||||
<BackgroundPattern />
|
<BackgroundPattern />
|
||||||
@@ -81,7 +65,9 @@ export default async function HomePage({
|
|||||||
how FIRE works.
|
how FIRE works.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 w-full max-w-2xl">
|
<div className="mt-8 w-full max-w-2xl">
|
||||||
<FireCalculatorForm initialValues={initialValues} />
|
<Suspense fallback={<div>Loading calculator...</div>}>
|
||||||
|
<FireCalculatorForm />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type NumericParam = string | number | null | undefined;
|
|||||||
|
|
||||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||||
|
|
||||||
const numericFromParam = (value: NumericParam) => {
|
export const numericFromParam = (value: NumericParam) => {
|
||||||
if (value === null || value === undefined) return undefined;
|
if (value === null || value === undefined) return undefined;
|
||||||
const parsed = typeof value === 'string' ? Number(value) : value;
|
const parsed = typeof value === 'string' ? Number(value) : value;
|
||||||
if (!Number.isFinite(parsed)) return undefined;
|
if (!Number.isFinite(parsed)) return undefined;
|
||||||
@@ -143,7 +143,7 @@ export const deriveDefaultInputs = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractNumericSearchParam = (
|
export const extractNumericSearchParam = (
|
||||||
value: string | string[] | undefined,
|
value: string | string[] | undefined,
|
||||||
bounds?: { min?: number; max?: number },
|
bounds?: { min?: number; max?: number },
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user