Compare commits
5 Commits
8714d3a30d
...
288a9b4992
| Author | SHA1 | Date | |
|---|---|---|---|
| 288a9b4992 | |||
| 37d8511da7 | |||
| cd2179f7a0 | |||
| 21a8c95a2b | |||
| 1711c2d16b |
@@ -1,22 +1,22 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export function AuthorBio() {
|
||||
return (
|
||||
<Card className="mt-12 bg-muted/50">
|
||||
<Card className="bg-muted/50 mt-12">
|
||||
<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" />
|
||||
<AvatarFallback>IF</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Written by The InvestingFIRE Team</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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 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.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,8 @@ export default function FireCalculatorForm() {
|
||||
baristaIncome: 0,
|
||||
simulationMode: 'deterministic',
|
||||
volatility: 15,
|
||||
withdrawalStrategy: 'fixed',
|
||||
withdrawalPercentage: 4,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
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 {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
@@ -45,6 +52,9 @@ const generateData = () => {
|
||||
const data = generateData();
|
||||
|
||||
const chartConfig = {
|
||||
age: {
|
||||
label: 'Age',
|
||||
},
|
||||
Standard: {
|
||||
label: 'Standard Path',
|
||||
color: 'var(--chart-4)',
|
||||
@@ -75,16 +85,22 @@ export function CoastFireChart() {
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
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
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => `Age ${String(value)}`}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
content={<ChartTooltipContent indicator="line" labelKey="age" />}
|
||||
/>
|
||||
<Line
|
||||
dataKey="Standard"
|
||||
@@ -103,6 +119,12 @@ export function CoastFireChart() {
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</LineChart>
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
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 {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
@@ -96,6 +103,15 @@ export function FourPercentRuleChart() {
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</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 "sweet spot."
|
||||
</p>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function CoastVsLeanPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<div className="max-w-none">
|
||||
<h2>The Quick Summary</h2>
|
||||
<p>Not sure which one fits you? Here is the high-level breakdown:</p>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function CoastVsLeanPage() {
|
||||
discipline.
|
||||
</p>
|
||||
|
||||
<Separator className="my-12" />
|
||||
<Separator className="my-16" />
|
||||
|
||||
<h2>Run The Numbers</h2>
|
||||
<p>The best way to decide is to see the math. Use our calculator to simulate both scenarios:</p>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function LearnHubPage() {
|
||||
<BlurThing />
|
||||
<CardHeader>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@ export default function LearnHubPage() {
|
||||
<Card className="hover:border-primary/50 h-full cursor-pointer border-2">
|
||||
<CardHeader>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
@@ -75,7 +75,7 @@ export default function LearnHubPage() {
|
||||
<Card className="hover:border-primary/50 h-full cursor-pointer border-2">
|
||||
<CardHeader>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function SafeWithdrawalPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<div className="max-w-none">
|
||||
<h2>What is the 4% Rule?</h2>
|
||||
<p>
|
||||
The rule comes from the <strong>Trinity Study</strong> (1998), which looked at historical
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { FireFlowchart } from '@/app/components/charts/FireFlowchart';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
|
||||
@@ -68,7 +67,7 @@ export default function WhatIsFirePage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<div className="max-w-none">
|
||||
<p>
|
||||
Imagine waking up on a Monday morning without an alarm clock. You don't have to rush to a
|
||||
commute, sit in traffic, or answer to a boss. Instead, you have the ultimate luxury:{' '}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BASE_URL } from "@/lib/constants";
|
||||
import { type MetadataRoute } from "next";
|
||||
import { BASE_URL } from '@/lib/constants';
|
||||
import { type MetadataRoute } from 'next';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: BASE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
changeFrequency: 'yearly',
|
||||
priority: 1,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,18 +5,18 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
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:
|
||||
'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:
|
||||
'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',
|
||||
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',
|
||||
},
|
||||
size: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as RechartsPrimitive from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
const THEMES = { light: '' } as const;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
@@ -209,7 +209,7 @@ function ChartTooltipContent({
|
||||
<span className="text-muted-foreground">{itemConfig?.label ?? item.name}</span>
|
||||
</div>
|
||||
{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()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -59,7 +59,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
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]',
|
||||
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
||||
className,
|
||||
|
||||
@@ -31,7 +31,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -60,9 +60,7 @@
|
||||
--secondary: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--secondary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--muted: oklch(0.67 0.0763 198.81 / 20%); /* verdigris with opacity */
|
||||
--muted-foreground: oklch(
|
||||
0.39 0.0215 96.47 / 80%
|
||||
); /* black olive with opacity */
|
||||
--muted-foreground: oklch(0.39 0.0215 96.47 / 80%); /* black olive with opacity */
|
||||
--accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--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-accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-border: oklch(
|
||||
0.67 0.0763 198.81 / 20%
|
||||
); /* verdigris with opacity */
|
||||
--sidebar-border: oklch(0.67 0.0763 198.81 / 20%); /* verdigris with opacity */
|
||||
--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-accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-border: oklch(
|
||||
0.97 0.0228 95.96 / 10%
|
||||
); /* cosmic latte with opacity */
|
||||
--sidebar-border: oklch(0.97 0.0228 95.96 / 10%); /* cosmic latte with opacity */
|
||||
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
}
|
||||
|
||||
@@ -142,7 +136,7 @@
|
||||
@apply scroll-m-20 text-xl font-semibold tracking-tight;
|
||||
}
|
||||
p {
|
||||
@apply leading-7 [&:not(:first-child)]:mt-6;
|
||||
@apply mb-2 leading-7 [&:not(:first-child)]:mt-6;
|
||||
}
|
||||
blockquote {
|
||||
@apply mt-6 border-l-2 pl-6 italic;
|
||||
@@ -151,6 +145,6 @@
|
||||
@apply my-6 ml-6 list-disc [&>li]:mt-2;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user