Compare commits

...

5 Commits

Author SHA1 Message Date
288a9b4992 calculator fix
All checks were successful
Lint / Lint and Typecheck (push) Successful in 36s
2025-12-06 14:47:38 +01:00
37d8511da7 chart style and descripitons 2025-12-06 14:23:06 +01:00
cd2179f7a0 formatting 2025-12-06 14:15:18 +01:00
21a8c95a2b style and visual fixes 2025-12-06 14:05:39 +01:00
1711c2d16b lint fix 2025-12-06 13:40:38 +01:00
15 changed files with 85 additions and 52 deletions

View File

@@ -1,22 +1,22 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from '@/components/ui/card';
export function AuthorBio() { export function AuthorBio() {
return ( return (
<Card className="mt-12 bg-muted/50"> <Card className="bg-muted/50 mt-12">
<CardContent className="flex items-center gap-4 p-6"> <CardContent className="flex items-center gap-4 p-6">
<Avatar className="h-16 w-16 border-2 border-background"> <Avatar className="border-background h-16 w-16 border-2">
<AvatarImage src="/images/author-profile.jpg" alt="Author" /> <AvatarImage src="/images/author-profile.jpg" alt="Author" />
<AvatarFallback>IF</AvatarFallback> <AvatarFallback>IF</AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<p className="text-sm font-semibold">Written by The InvestingFIRE Team</p> <p className="text-sm font-semibold">Written by The InvestingFIRE Team</p>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
We are a group of financial data enthusiasts and early retirees dedicated to building the most accurate FIRE tools on the web. Our goal is to replace guesswork with math. We are a group of financial data enthusiasts and early retirees dedicated to building the
most accurate FIRE tools on the web. Our goal is to replace guesswork with math.
</p> </p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }

View File

@@ -143,6 +143,8 @@ export default function FireCalculatorForm() {
baristaIncome: 0, baristaIncome: 0,
simulationMode: 'deterministic', simulationMode: 'deterministic',
volatility: 15, volatility: 15,
withdrawalStrategy: 'fixed',
withdrawalPercentage: 4,
}, },
}); });

View File

@@ -1,7 +1,14 @@
'use client'; 'use client';
import { Line, LineChart, CartesianGrid, XAxis, YAxis } from 'recharts'; import { Line, LineChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { import {
ChartContainer, ChartContainer,
ChartLegend, ChartLegend,
@@ -45,6 +52,9 @@ const generateData = () => {
const data = generateData(); const data = generateData();
const chartConfig = { const chartConfig = {
age: {
label: 'Age',
},
Standard: { Standard: {
label: 'Standard Path', label: 'Standard Path',
color: 'var(--chart-4)', color: 'var(--chart-4)',
@@ -75,16 +85,22 @@ export function CoastFireChart() {
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickMargin={8} tickMargin={8}
tickFormatter={(value: number) => `$${String(value / 1000)}k`} tickFormatter={(value: number) => {
if (value < 1000) {
return `$${String(value)}`;
}
if (value < 1000000) {
return `$${String(value / 1000)}k`;
}
if (value < 1000000000) {
return `$${String(value / 1000000)}M`;
}
return `$${String(value / 1000000000)}B`;
}}
/> />
<ChartTooltip <ChartTooltip
cursor={false} cursor={false}
content={ content={<ChartTooltipContent indicator="line" labelKey="age" />}
<ChartTooltipContent
labelFormatter={(value) => `Age ${String(value)}`}
indicator="line"
/>
}
/> />
<Line <Line
dataKey="Standard" dataKey="Standard"
@@ -103,6 +119,12 @@ export function CoastFireChart() {
<ChartLegend content={<ChartLegendContent />} /> <ChartLegend content={<ChartLegendContent />} />
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
<CardFooter>
<p className="text-muted-foreground text-sm">
Simulation assumes 7% returns. Standard: Save $10k/yr (age 25-65). Coast: Save $30k/yr (age
25-35), then $0.
</p>
</CardFooter>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -1,7 +1,14 @@
'use client'; 'use client';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { import {
ChartContainer, ChartContainer,
ChartLegend, ChartLegend,
@@ -96,6 +103,15 @@ export function FourPercentRuleChart() {
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
</CardContent> </CardContent>
<CardFooter>
<div>
<p className="font-medium">4% balances safety and spending power</p>
<p className="text-muted-foreground leading-none">
A 5% withdrawal rate risks depleting your portfolio within 30 years, while 3% leaves a large
surplus. The 4% rule is widely considered the safe &quot;sweet spot.&quot;
</p>
</div>
</CardFooter>
</Card> </Card>
); );
} }

View File

@@ -69,7 +69,7 @@ export default function CoastVsLeanPage() {
</p> </p>
</header> </header>
<div className="prose prose-lg dark:prose-invert max-w-none"> <div className="max-w-none">
<h2>The Quick Summary</h2> <h2>The Quick Summary</h2>
<p>Not sure which one fits you? Here is the high-level breakdown:</p> <p>Not sure which one fits you? Here is the high-level breakdown:</p>
@@ -147,7 +147,7 @@ export default function CoastVsLeanPage() {
discipline. discipline.
</p> </p>
<Separator className="my-12" /> <Separator className="my-16" />
<h2>Run The Numbers</h2> <h2>Run The Numbers</h2>
<p>The best way to decide is to see the math. Use our calculator to simulate both scenarios:</p> <p>The best way to decide is to see the math. Use our calculator to simulate both scenarios:</p>

View File

@@ -25,7 +25,7 @@ export default function LearnHubPage() {
<BlurThing /> <BlurThing />
<CardHeader> <CardHeader>
<div className="mb-2"> <div className="mb-2">
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-300"> <span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
Beginner Beginner
</span> </span>
</div> </div>
@@ -51,7 +51,7 @@ export default function LearnHubPage() {
<Card className="hover:border-primary/50 h-full cursor-pointer border-2"> <Card className="hover:border-primary/50 h-full cursor-pointer border-2">
<CardHeader> <CardHeader>
<div className="mb-2"> <div className="mb-2">
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300"> <span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
Strategy Strategy
</span> </span>
</div> </div>
@@ -75,7 +75,7 @@ export default function LearnHubPage() {
<Card className="hover:border-primary/50 h-full cursor-pointer border-2"> <Card className="hover:border-primary/50 h-full cursor-pointer border-2">
<CardHeader> <CardHeader>
<div className="mb-2"> <div className="mb-2">
<span className="rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800 dark:bg-purple-900 dark:text-purple-300"> <span className="rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800">
Comparison Comparison
</span> </span>
</div> </div>

View File

@@ -64,7 +64,7 @@ export default function SafeWithdrawalPage() {
</p> </p>
</header> </header>
<div className="prose prose-lg dark:prose-invert max-w-none"> <div className="max-w-none">
<h2>What is the 4% Rule?</h2> <h2>What is the 4% Rule?</h2>
<p> <p>
The rule comes from the <strong>Trinity Study</strong> (1998), which looked at historical The rule comes from the <strong>Trinity Study</strong> (1998), which looked at historical

View File

@@ -1,6 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { FireFlowchart } from '@/app/components/charts/FireFlowchart'; import { FireFlowchart } from '@/app/components/charts/FireFlowchart';
import { AuthorBio } from '@/app/components/AuthorBio'; import { AuthorBio } from '@/app/components/AuthorBio';
@@ -68,7 +67,7 @@ export default function WhatIsFirePage() {
</p> </p>
</header> </header>
<div className="prose prose-lg dark:prose-invert max-w-none"> <div className="max-w-none">
<p> <p>
Imagine waking up on a Monday morning without an alarm clock. You don&apos;t have to rush to a Imagine waking up on a Monday morning without an alarm clock. You don&apos;t have to rush to a
commute, sit in traffic, or answer to a boss. Instead, you have the ultimate luxury:{' '} commute, sit in traffic, or answer to a boss. Instead, you have the ultimate luxury:{' '}

View File

@@ -1,12 +1,12 @@
import { BASE_URL } from "@/lib/constants"; import { BASE_URL } from '@/lib/constants';
import { type MetadataRoute } from "next"; import { type MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
return [ return [
{ {
url: BASE_URL, url: BASE_URL,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "yearly", changeFrequency: 'yearly',
priority: 1, priority: 1,
}, },
]; ];

View File

@@ -5,18 +5,18 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-semibold transition-[transform,colors,shadow] shadow-[0_10px_30px_-18px_rgba(0,0,0,0.45)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "z-30 inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-semibold transition-[transform,colors,shadow] shadow-[0_10px_30px_-18px_rgba(0,0,0,0.45)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
default: default:
'border-primary/20 bg-gradient-to-r from-primary to-secondary text-primary-foreground shadow-lg shadow-primary/30 hover:from-primary/90 hover:to-secondary/90', 'bg-gradient-to-r from-primary to-secondary text-primary-foreground shadow-lg shadow-primary/30 hover:from-primary/90 hover:to-secondary/90',
destructive: destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20',
outline: outline:
'border border-primary/25 bg-background/80 shadow-sm hover:bg-primary/10 hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 'border border-primary/25 bg-background/80 shadow-sm hover:bg-primary/10 hover:text-foreground',
secondary: 'bg-secondary/90 text-secondary-foreground shadow-md hover:bg-secondary', secondary: 'bg-secondary/90 text-secondary-foreground shadow-md hover:bg-secondary',
ghost: 'text-foreground/80 hover:bg-primary/10 hover:text-foreground dark:hover:bg-accent/50', ghost: 'text-foreground/80 hover:bg-primary/10 hover:text-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {

View File

@@ -7,7 +7,7 @@ import * as RechartsPrimitive from 'recharts';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const; const THEMES = { light: '' } as const;
export type ChartConfig = Record< export type ChartConfig = Record<
string, string,
@@ -209,7 +209,7 @@ function ChartTooltipContent({
<span className="text-muted-foreground">{itemConfig?.label ?? item.name}</span> <span className="text-muted-foreground">{itemConfig?.label ?? item.name}</span>
</div> </div>
{item.value && ( {item.value && (
<span className="text-foreground font-mono font-medium tabular-nums"> <span className="text-foreground pl-2 font-mono font-medium tabular-nums">
{item.value.toLocaleString()} {item.value.toLocaleString()}
</span> </span>
)} )}

View File

@@ -59,7 +59,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input bg-background z-30 flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive', 'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
className, className,

View File

@@ -31,7 +31,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive bg-background flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive bg-background z-30 flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,12 +1,12 @@
@import "tailwindcss"; @import 'tailwindcss';
@import "tw-animate-css"; @import 'tw-animate-css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {
--font-sans: --font-sans:
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 'Segoe UI Symbol', 'Noto Color Emoji';
} }
@theme inline { @theme inline {
@@ -60,9 +60,7 @@
--secondary: oklch(0.49 0.1326 259.29); /* denim */ --secondary: oklch(0.49 0.1326 259.29); /* denim */
--secondary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */ --secondary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--muted: oklch(0.67 0.0763 198.81 / 20%); /* verdigris with opacity */ --muted: oklch(0.67 0.0763 198.81 / 20%); /* verdigris with opacity */
--muted-foreground: oklch( --muted-foreground: oklch(0.39 0.0215 96.47 / 80%); /* black olive with opacity */
0.39 0.0215 96.47 / 80%
); /* black olive with opacity */
--accent: oklch(0.49 0.1326 259.29); /* denim */ --accent: oklch(0.49 0.1326 259.29); /* denim */
--accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */ --accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--destructive: oklch(0.33 0.1316 336.24); /* palatinate */ --destructive: oklch(0.33 0.1316 336.24); /* palatinate */
@@ -80,9 +78,7 @@
--sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */ --sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */ --sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */ --sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--sidebar-border: oklch( --sidebar-border: oklch(0.67 0.0763 198.81 / 20%); /* verdigris with opacity */
0.67 0.0763 198.81 / 20%
); /* verdigris with opacity */
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */ --sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
} }
@@ -116,9 +112,7 @@
--sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */ --sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */ --sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */ --sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--sidebar-border: oklch( --sidebar-border: oklch(0.97 0.0228 95.96 / 10%); /* cosmic latte with opacity */
0.97 0.0228 95.96 / 10%
); /* cosmic latte with opacity */
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */ --sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
} }
@@ -142,7 +136,7 @@
@apply scroll-m-20 text-xl font-semibold tracking-tight; @apply scroll-m-20 text-xl font-semibold tracking-tight;
} }
p { p {
@apply leading-7 [&:not(:first-child)]:mt-6; @apply mb-2 leading-7 [&:not(:first-child)]:mt-6;
} }
blockquote { blockquote {
@apply mt-6 border-l-2 pl-6 italic; @apply mt-6 border-l-2 pl-6 italic;
@@ -151,6 +145,6 @@
@apply my-6 ml-6 list-disc [&>li]:mt-2; @apply my-6 ml-6 list-disc [&>li]:mt-2;
} }
code { code {
@apply bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold @apply bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold;
} }
} }