Compare commits

...

11 Commits

Author SHA1 Message Date
ed31944963 visual bug
All checks were successful
Lint / Lint and Typecheck (push) Successful in 35s
2025-12-06 15:29:29 +01:00
e8f0269b75 homepage faq 2025-12-06 15:27:36 +01:00
597b7a5883 calc tooltips 2025-12-06 15:20:29 +01:00
14834024ec FAQs 2025-12-06 15:20:23 +01:00
8ac1c1a9df tests 2025-12-06 15:19:53 +01:00
46dd28482f shadcn tooltip 2025-12-06 14:48:41 +01:00
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
22 changed files with 524 additions and 289 deletions

View File

@@ -27,6 +27,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

36
pnpm-lock.yaml generated
View File

@@ -45,6 +45,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.0
version: 1.2.4(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@t3-oss/env-nextjs':
specifier: ^0.13.0
version: 0.13.8(typescript@5.9.3)(zod@4.1.12)
@@ -1117,6 +1120,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3
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
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@@ -4740,6 +4756,26 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.2.1)':
dependencies:
react: 19.2.1

View File

@@ -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>
);
}

View File

@@ -0,0 +1,49 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
export interface FaqItem {
question: string;
answer: string;
}
interface FaqSectionProps {
faqs: FaqItem[];
className?: string;
title?: string;
}
export function FaqSection({
faqs,
className,
title = 'Frequently Asked Questions',
}: Readonly<FaqSectionProps>) {
// JSON-LD FAQPage schema
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
};
return (
<section className={className}>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<h2 className="mb-6 text-2xl font-bold">{title}</h2>
<Accordion type="single" collapsible className="w-full">
{faqs.map((faq) => (
<AccordionItem key={faq.question} value={faq.question}>
<AccordionTrigger className="text-left">{faq.question}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</section>
);
}

View File

@@ -22,8 +22,24 @@ import {
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import { Calculator, Percent } from 'lucide-react';
import { Calculator, Info, Percent } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import BlurThing from './blur-thing';
import Link from 'next/link';
// Helper component for info tooltips next to form labels
function InfoTooltip({ content }: Readonly<{ content: string }>) {
return (
<Tooltip>
<TooltipTrigger type="button" className="ml-1 inline-flex align-middle">
<Info className="text-muted-foreground hover:text-foreground h-3.5 w-3.5 transition-colors" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{content}
</TooltipContent>
</Tooltip>
);
}
// Schema for form validation
const formSchema = z.object({
@@ -143,6 +159,8 @@ export default function FireCalculatorForm() {
baristaIncome: 0,
simulationMode: 'deterministic',
volatility: 15,
withdrawalStrategy: 'fixed',
withdrawalPercentage: 4,
},
});
@@ -357,7 +375,10 @@ export default function FireCalculatorForm() {
name="startingCapital"
render={({ field }) => (
<FormItem>
<FormLabel>Starting Capital</FormLabel>
<FormLabel>
Starting Capital
<InfoTooltip content="Your current invested savings or nest egg. This is the amount you have already saved and invested." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 10000"
@@ -382,7 +403,10 @@ export default function FireCalculatorForm() {
name="monthlySavings"
render={({ field }) => (
<FormItem>
<FormLabel>Monthly Savings</FormLabel>
<FormLabel>
Monthly Savings
<InfoTooltip content="The amount you invest each month. This is added to your portfolio during the accumulation phase." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 500"
@@ -407,7 +431,10 @@ export default function FireCalculatorForm() {
name="currentAge"
render={({ field }) => (
<FormItem>
<FormLabel>Current Age</FormLabel>
<FormLabel>
Current Age
<InfoTooltip content="Your age today. This is used to calculate the timeline to retirement and beyond." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 30"
@@ -432,7 +459,10 @@ export default function FireCalculatorForm() {
name="lifeExpectancy"
render={({ field }) => (
<FormItem>
<FormLabel>Life Expectancy (Age)</FormLabel>
<FormLabel>
Life Expectancy (Age)
<InfoTooltip content="Your estimated age of death for planning purposes. This determines how long your money needs to last." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 90"
@@ -457,7 +487,10 @@ export default function FireCalculatorForm() {
name="cagr"
render={({ field }) => (
<FormItem>
<FormLabel>Expected Annual Growth Rate (%)</FormLabel>
<FormLabel>
Expected Annual Growth Rate (%)
<InfoTooltip content="Average yearly investment return (CAGR). The VTI has historically returned ~7%." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 7"
@@ -483,7 +516,10 @@ export default function FireCalculatorForm() {
name="inflationRate"
render={({ field }) => (
<FormItem>
<FormLabel>Annual Inflation Rate (%)</FormLabel>
<FormLabel>
Annual Inflation Rate (%)
<InfoTooltip content="Expected yearly price increase. Historical average is ~2-3%. This adjusts your spending needs over time." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2"
@@ -509,7 +545,10 @@ export default function FireCalculatorForm() {
name="desiredMonthlyAllowance"
render={({ field }) => (
<FormItem>
<FormLabel>Desired Monthly Allowance (Today&apos;s Value)</FormLabel>
<FormLabel>
Monthly Allowance (Today&apos;s Value)
<InfoTooltip content="Your monthly spending needs in retirement, expressed in today's dollars. This will be adjusted for inflation each year." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2000"
@@ -536,7 +575,10 @@ export default function FireCalculatorForm() {
name="retirementAge"
render={({ field }) => (
<FormItem>
<FormLabel>Retirement Age: {field.value as number}</FormLabel>
<FormLabel>
Retirement Age: {field.value as number}
<InfoTooltip content="The age when you stop working and start withdrawing from your portfolio to cover living expenses." />
</FormLabel>
<FormControl>
<Slider
name="retirementAge"
@@ -561,7 +603,13 @@ export default function FireCalculatorForm() {
name="coastFireAge"
render={({ field }) => (
<FormItem>
<FormLabel>Coast FIRE Age (Optional) - Stop contributing at age:</FormLabel>
<FormLabel>
<Button variant="link" size={'sm'} asChild>
<Link href="/learn/what-is-fire#types-of-fire">Coast FIRE</Link>
</Button>{' '}
Age (Optional):
<InfoTooltip content="The age when you stop making new contributions but keep working to cover current expenses. Your existing investments compound until retirement." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 45 (defaults to Retirement Age)"
@@ -586,7 +634,13 @@ export default function FireCalculatorForm() {
name="baristaIncome"
render={({ field }) => (
<FormItem>
<FormLabel>Barista FIRE Income (Monthly during Retirement)</FormLabel>
<FormLabel>
<Button variant="link" size={'sm'} asChild>
<Link href="/learn/what-is-fire#types-of-fire">Barista FIRE</Link>
</Button>{' '}
Monthly Income
<InfoTooltip content="Part-time income during retirement (e.g., from a low-stress job). This reduces the amount you need to withdraw from your portfolio." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 1000"
@@ -611,7 +665,10 @@ export default function FireCalculatorForm() {
name="simulationMode"
render={({ field }) => (
<FormItem>
<FormLabel>Simulation Mode</FormLabel>
<FormLabel>
Simulation Mode
<InfoTooltip content="Deterministic uses fixed yearly returns. Monte Carlo simulates market randomness with 500 runs to show probability ranges." />
</FormLabel>
<Select
onValueChange={(val) => {
field.onChange(val);
@@ -640,7 +697,10 @@ export default function FireCalculatorForm() {
name="volatility"
render={({ field }) => (
<FormItem>
<FormLabel>Market Volatility (Std Dev %)</FormLabel>
<FormLabel>
Market Volatility (Std Dev %)
<InfoTooltip content="Standard deviation of annual returns. 15% is typical for stocks. Higher values mean more unpredictable year-to-year swings." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 15"
@@ -666,7 +726,10 @@ export default function FireCalculatorForm() {
name="withdrawalStrategy"
render={({ field }) => (
<FormItem>
<FormLabel>Withdrawal Strategy</FormLabel>
<FormLabel>
Withdrawal Strategy
<InfoTooltip content="Fixed inflation-adjusted maintains your purchasing power yearly. Percentage of portfolio adjusts spending based on current balance." />
</FormLabel>
<Select
onValueChange={(val) => {
field.onChange(val);
@@ -695,7 +758,10 @@ export default function FireCalculatorForm() {
name="withdrawalPercentage"
render={({ field }) => (
<FormItem>
<FormLabel>Withdrawal Percentage (%)</FormLabel>
<FormLabel>
Withdrawal Percentage (%)
<InfoTooltip content="Annual withdrawal rate as percentage of current portfolio. 4% is the classic 'safe' rate from the Trinity Study." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 4.0"

View File

@@ -1,13 +1,19 @@
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import FireCalculatorForm from "../FireCalculatorForm";
import { describe, it, expect, vi, beforeAll } from "vitest";
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FireCalculatorForm from '../FireCalculatorForm';
import { describe, it, expect, vi, beforeAll } from 'vitest';
// Mocking ResizeObserver
class ResizeObserver {
observe() { /* noop */ }
unobserve() { /* noop */ }
disconnect() { /* noop */ }
observe() {
/* noop */
}
unobserve() {
/* noop */
}
disconnect() {
/* noop */
}
}
global.ResizeObserver = ResizeObserver;
@@ -20,115 +26,117 @@ beforeAll(() => {
});
// Mock Recharts ResponsiveContainer
vi.mock("recharts", async () => {
const originalModule = await vi.importActual("recharts");
vi.mock('recharts', async () => {
const originalModule = await vi.importActual('recharts');
return {
...originalModule,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div style={{ width: "500px", height: "300px" }}>{children}</div>
<div style={{ width: '500px', height: '300px' }}>{children}</div>
),
};
});
describe("FireCalculatorForm", () => {
it("renders the form with default values", () => {
describe('FireCalculatorForm', () => {
it('renders the form with default values', () => {
render(<FireCalculatorForm />);
expect(screen.getByText("FIRE Calculator")).toBeInTheDocument();
expect(screen.getByLabelText(/Starting Capital/i)).toHaveValue(50000);
expect(screen.getByLabelText(/Monthly Savings/i)).toHaveValue(1500);
expect(screen.getByLabelText(/Current Age/i)).toHaveValue(25);
expect(screen.getByText('FIRE Calculator')).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: /Starting Capital/i })).toHaveValue(50000);
expect(screen.getByRole('spinbutton', { name: /Monthly Savings/i })).toHaveValue(1500);
expect(screen.getByRole('spinbutton', { name: /Current Age/i })).toHaveValue(25);
});
it("calculates and displays results when submitted", async () => {
it('calculates and displays results when submitted', async () => {
const user = userEvent.setup();
render(<FireCalculatorForm />);
const calculateButton = screen.getByRole("button", { name: /Calculate/i });
const calculateButton = screen.getByRole('button', { name: /Calculate/i });
await user.click(calculateButton);
await waitFor(() => {
expect(screen.getByText("Financial Projection")).toBeInTheDocument();
expect(screen.getByText("FIRE Number")).toBeInTheDocument();
expect(screen.getByText('Financial Projection')).toBeInTheDocument();
expect(screen.getByText('FIRE Number')).toBeInTheDocument();
});
});
it("allows changing inputs", () => {
// using fireEvent for reliability with number inputs in jsdom
render(<FireCalculatorForm />);
const savingsInput = screen.getByLabelText(/Monthly Savings/i);
fireEvent.change(savingsInput, { target: { value: "2000" } });
expect(savingsInput).toHaveValue(2000);
});
it("validates inputs", async () => {
const user = userEvent.setup();
render(<FireCalculatorForm />);
const ageInput = screen.getByLabelText(/Current Age/i);
// Use fireEvent to set invalid value directly
fireEvent.change(ageInput, { target: { value: "-5" } });
const calculateButton = screen.getByRole("button", { name: /Calculate/i });
await user.click(calculateButton);
// Look for error message text
expect(await screen.findByText(/Age must be at least 1/i)).toBeInTheDocument();
it('allows changing inputs', () => {
// using fireEvent for reliability with number inputs in jsdom
render(<FireCalculatorForm />);
const savingsInput = screen.getByRole('spinbutton', { name: /Monthly Savings/i });
fireEvent.change(savingsInput, { target: { value: '2000' } });
expect(savingsInput).toHaveValue(2000);
});
it("toggles Monte Carlo simulation mode", async () => {
it('validates inputs', async () => {
const user = userEvent.setup();
render(<FireCalculatorForm />);
const ageInput = screen.getByRole('spinbutton', { name: /Current Age/i });
// Use fireEvent to set invalid value directly
fireEvent.change(ageInput, { target: { value: '-5' } });
const calculateButton = screen.getByRole('button', { name: /Calculate/i });
await user.click(calculateButton);
// Look for error message text
expect(await screen.findByText(/Age must be at least 1/i)).toBeInTheDocument();
});
it('toggles Monte Carlo simulation mode', async () => {
const user = userEvent.setup();
render(<FireCalculatorForm />);
// Select Trigger
const modeTrigger = screen.getByRole("combobox", { name: /Simulation Mode/i });
const modeTrigger = screen.getByRole('combobox', { name: /Simulation Mode/i });
await user.click(modeTrigger);
// Select Monte Carlo from dropdown
const monteCarloOption = await screen.findByRole("option", { name: /Monte Carlo/i });
const monteCarloOption = await screen.findByRole('option', { name: /Monte Carlo/i });
await user.click(monteCarloOption);
// Verify Volatility input appears
expect(await screen.findByLabelText(/Market Volatility/i)).toBeInTheDocument();
expect(await screen.findByRole('spinbutton', { name: /Market Volatility/i })).toBeInTheDocument();
});
it("toggles 4% Rule overlay", async () => {
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 });
const calculateButton = screen.getByRole('button', { name: /Calculate/i });
await user.click(calculateButton);
// Wait for results
await waitFor(() => {
expect(screen.getByText("Financial Projection")).toBeInTheDocument();
expect(screen.getByText('Financial Projection')).toBeInTheDocument();
});
// Find the Show 4%-Rule button
const showButton = screen.getByRole("button", { name: /Show 4%-Rule/i });
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();
expect(await screen.findByText('4%-Rule FIRE Number')).toBeInTheDocument();
// Button text should change
expect(screen.getByRole("button", { name: /Hide 4%-Rule/i })).toBeInTheDocument();
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();
render(<FireCalculatorForm />);
const strategyTrigger = screen.getByRole("combobox", { name: /Withdrawal Strategy/i });
const strategyTrigger = screen.getByRole('combobox', { name: /Withdrawal Strategy/i });
await user.click(strategyTrigger);
const percentageOption = await screen.findByRole("option", { name: /Percentage of Portfolio/i });
const percentageOption = await screen.findByRole('option', { name: /Percentage of Portfolio/i });
await user.click(percentageOption);
expect(await screen.findByLabelText(/Withdrawal Percentage/i)).toBeInTheDocument();
expect(
await screen.findByRole('spinbutton', { name: /Withdrawal Percentage/i }),
).toBeInTheDocument();
});
});

View File

@@ -2,7 +2,7 @@ export default function BlurThing() {
return (
<>
{/* Decorative background elements */}
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="from-primary/25 to-primary/15 absolute -top-24 -right-24 h-64 w-64 rounded-full bg-gradient-to-br blur-3xl" />
<div className="absolute -bottom-24 -left-24 h-64 w-64 rounded-full bg-gradient-to-br from-orange-500/25 to-red-500/15 blur-3xl" />
</div>

View File

@@ -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>
);

View File

@@ -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 &quot;sweet spot.&quot;
</p>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -4,6 +4,40 @@ import { Separator } from '@/components/ui/separator';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CoastFireChart } from '@/app/components/charts/CoastFireChart';
import { AuthorBio } from '@/app/components/AuthorBio';
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
const faqs: FaqItem[] = [
{
question: 'What is the main difference between Coast FIRE and Lean FIRE?',
answer:
'Coast FIRE focuses on front-loading your savings early so compound interest does the rest—you still work but only to cover current expenses. Lean FIRE means fully retiring but on a minimal budget, typically under $40,000/year.',
},
{
question: 'How do I calculate my Coast FIRE number?',
answer:
'Your Coast FIRE number depends on your target retirement age, expected investment returns, and desired retirement spending. Use the formula: Coast Number = Target FIRE Number ÷ (1 + annual return)^(years until traditional retirement). Our calculator handles this automatically.',
},
{
question: 'Is Lean FIRE sustainable long-term?',
answer:
'Lean FIRE can be sustainable if you genuinely enjoy a minimalist lifestyle and have low-cost hobbies. However, it has less margin for unexpected expenses like healthcare or inflation spikes. Consider building a buffer or having flexible spending categories.',
},
{
question: 'Can I combine Coast FIRE and Lean FIRE strategies?',
answer:
'Absolutely. Many people save aggressively (Lean FIRE mindset) to hit their Coast number early, then switch to a lower-stress job while their investments compound. This hybrid approach offers flexibility and reduced burnout.',
},
{
question: 'Which strategy is better for someone in their 20s?',
answer:
'Coast FIRE often works well for young savers because you have decades for compound growth. Save aggressively for 10-15 years, hit your Coast number, then enjoy career flexibility. Lean FIRE might suit those who want to exit the workforce entirely ASAP.',
},
{
question: 'What are the biggest risks of each strategy?',
answer:
'Coast FIRE risks include poor market returns during your coasting years or lifestyle inflation. Lean FIRE risks include unexpected expenses, healthcare costs, or finding the frugal lifestyle unsustainable over decades.',
},
];
export const metadata = {
title: `Coast FIRE vs. Lean FIRE: Which Strategy Is Right For You? (${new Date().getFullYear().toString()})`,
@@ -69,7 +103,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 +181,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>
@@ -180,6 +214,8 @@ export default function CoastVsLeanPage() {
The most important step is to just <strong>start</strong>.
</p>
<FaqSection faqs={faqs} className="my-12" />
<AuthorBio />
</div>
</article>

View File

@@ -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>

View File

@@ -4,6 +4,40 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Info } from 'lucide-react';
import { FourPercentRuleChart } from '@/app/components/charts/FourPercentRuleChart';
import { AuthorBio } from '@/app/components/AuthorBio';
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
const faqs: FaqItem[] = [
{
question: 'What is the 4% rule and where does it come from?',
answer:
'The 4% rule comes from the Trinity Study (1998), which analyzed historical data to find a sustainable withdrawal rate. It states that withdrawing 4% of your initial portfolio in year one, then adjusting for inflation each year, has historically survived 95% of 30-year periods.',
},
{
question: 'Is 4% still safe for early retirees?',
answer:
'For early retirees with 40-50+ year horizons, many experts recommend a more conservative 3.25-3.5% withdrawal rate. The original study only covered 30-year periods, and current market valuations may lead to lower future returns.',
},
{
question: 'What is sequence of returns risk?',
answer:
'Sequence of returns risk is the danger of experiencing poor market returns early in retirement. If you withdraw from a declining portfolio, you sell more shares to maintain income, leaving less to recover when markets rebound. This can deplete your portfolio even if long-term average returns are good.',
},
{
question: 'Should I withdraw 4% of my current balance each year?',
answer:
'No. The 4% rule uses your initial retirement portfolio value. You withdraw 4% of that starting amount, then increase it by inflation each year—regardless of market performance. Some prefer percentage-of-portfolio strategies, which adjust spending to market conditions.',
},
{
question: 'What is the guardrails withdrawal strategy?',
answer:
'The guardrails approach sets upper and lower bounds on spending. If your portfolio drops significantly, you reduce withdrawals (skip discretionary spending). If it grows substantially, you give yourself a raise. This flexibility dramatically improves portfolio survival rates.',
},
{
question: 'How does inflation affect the 4% rule?',
answer:
'Inflation is built into the 4% rule—you increase withdrawals by the inflation rate each year to maintain purchasing power. However, periods of unexpectedly high inflation (like recent years) can stress portfolios more than historical averages suggest.',
},
];
export const metadata = {
title: 'Safe Withdrawal Rates & The 4% Rule Explained (2025 Update)',
@@ -64,7 +98,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
@@ -158,6 +192,8 @@ export default function SafeWithdrawalPage() {
trigger on retirement.
</p>
<FaqSection faqs={faqs} className="my-12" />
<AuthorBio />
</div>
</article>

View File

@@ -1,8 +1,41 @@
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';
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
const faqs: FaqItem[] = [
{
question: 'How much money do I need to achieve FIRE?',
answer:
'The amount depends on your annual expenses. Using the Rule of 25, multiply your yearly spending by 25. For example, if you spend $40,000 per year, you need $1,000,000 invested. This is based on the 4% safe withdrawal rate.',
},
{
question: 'What savings rate do I need to retire early?',
answer:
'The higher your savings rate, the faster you can retire. At a 50% savings rate, you can retire in about 17 years. At 70%, it drops to around 8.5 years. The key is the gap between your income and expenses, not your absolute income.',
},
{
question: 'Is FIRE only for high-income earners?',
answer:
'No. While higher income makes it easier, FIRE is fundamentally about the savings rate—the percentage of income you save. Someone earning $50,000 saving 50% can reach FIRE faster than someone earning $200,000 saving 10%.',
},
{
question: 'What is the difference between Lean FIRE and Fat FIRE?',
answer:
'Lean FIRE means retiring on a minimal budget (typically under $40,000/year), requiring a smaller nest egg but more frugal living. Fat FIRE means retiring with a larger budget ($100,000+/year) for a more comfortable lifestyle, requiring a much larger portfolio.',
},
{
question: 'Where should I invest for FIRE?',
answer:
'Most FIRE practitioners favor low-cost index funds (like total stock market funds) due to their diversification and minimal fees. Tax-advantaged accounts (401k, IRA, Roth IRA) should generally be maxed out before taxable accounts.',
},
{
question: 'Can I still pursue FIRE if I have debt?',
answer:
'Yes, but prioritization matters. High-interest debt (credit cards, personal loans) should typically be paid off first. Low-interest debt like mortgages can often be managed alongside investing, depending on rates and your risk tolerance.',
},
];
export const metadata = {
title: `What is FIRE? The Ultimate Guide to Financial Independence (${new Date().getFullYear().toString()})`,
@@ -68,7 +101,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&apos;t have to rush to a
commute, sit in traffic, or answer to a boss. Instead, you have the ultimate luxury:{' '}
@@ -137,7 +170,7 @@ export default function WhatIsFirePage() {
</Link>
</div>
<h2>Types of FIRE</h2>
<h2 id="types-of-fire">Types of FIRE</h2>
<p>FIRE isn&apos;t one-size-fits-all. Over the years, several variations have emerged:</p>
<ul>
<li>
@@ -183,6 +216,8 @@ export default function WhatIsFirePage() {
best time to plant a tree was 20 years ago. The second best time is today.
</p>
<FaqSection faqs={faqs} className="my-12" />
<AuthorBio />
</div>
</article>

View File

@@ -1,66 +1,43 @@
import Image from 'next/image';
import FireCalculatorForm from './components/FireCalculatorForm';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import BackgroundPattern from './components/BackgroundPattern';
import { FaqSection, type FaqItem } from './components/FaqSection';
import { Testimonials } from './components/Testimonials';
export default function HomePage() {
const faqData = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'What methodology does this calculator use?',
acceptedAnswer: {
'@type': 'Answer',
text: "We run a multi-year projection in two phases: 1. Accumulation: Your balance grows by CAGR and you add monthly savings. 2. Retirement: The balance continues compounding, but you withdraw an inflation-adjusted monthly allowance. The result: a precise estimate of the capital you'll have at retirement (your “FIRE Number”) and how long it will last until your chosen life expectancy.",
},
},
{
'@type': 'Question',
name: "Why isn't this just the 4% rule?",
acceptedAnswer: {
'@type': 'Answer',
text: "The 4% rule is a useful starting point (25× annual spending), but it assumes a fixed withdrawal rate with inflation adjustments and doesn't model ongoing savings or dynamic market returns. Our calculator simulates each year's growth, contributions, and inflation-indexed withdrawals to give you a tailored picture.",
},
},
{
'@type': 'Question',
name: 'How do I choose a realistic growth rate?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Historically, a diversified portfolio of equities and bonds has returned around 7-10% per year before inflation. We recommend starting around 6-8% (net of fees), then running “what-if” scenarios—5% on the conservative side, 10% on the aggressive side—to see how they affect your timeline.',
},
},
{
'@type': 'Question',
name: 'How does inflation factor into my FIRE Number?',
acceptedAnswer: {
'@type': 'Answer',
text: "Cost of living rises. To maintain today's lifestyle, your monthly allowance must grow each year by your inflation rate. This calculator automatically inflates your desired monthly spending and subtracts it from your portfolio during retirement, ensuring your FIRE Number keeps pace with rising expenses.",
},
},
{
'@type': 'Question',
name: 'Can I really retire early with FIRE?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Early retirement is achievable with disciplined saving, smart investing, and realistic assumptions. This tool helps you set targets, visualize outcomes, and adjust inputs—so you can build confidence in your plan and make informed trade-offs between lifestyle, risk, and timeline.',
},
},
{
'@type': 'Question',
name: 'How should I use this calculator effectively?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Start with your actual numbers (capital, savings, age). Set conservative - mid - aggressive growth rates to bound possibilities. Slide your retirement age to explore “early” vs. “traditional” scenarios. Review the chart—especially the reference lines—to see when you hit FI and how withdrawals impact your balance. Experiment with higher savings rates or lower target spending to accelerate your path.',
},
},
],
};
const faqs: FaqItem[] = [
{
question: 'What methodology does this calculator use?',
answer:
'We run a multi-year projection in two phases: 1. Accumulation: Your balance grows by CAGR and you add monthly savings. 2. Retirement: The balance continues compounding, but you withdraw an inflation-adjusted monthly allowance. The result: a precise estimate of the capital you\'ll have at retirement (your "FIRE Number") and how long it will last until your chosen life expectancy.',
},
{
question: "Why isn't this just the 4% rule?",
answer:
"The 4% rule is a useful starting point (25x annual spending), but it assumes a fixed withdrawal rate with inflation adjustments and doesn't model ongoing savings or dynamic market returns. Our calculator simulates each year's growth, contributions, and inflation-indexed withdrawals to give you a tailored picture.",
},
{
question: 'How do I choose a realistic growth rate?',
answer:
'Historically, a diversified portfolio of equities and bonds has returned around 7-10% per year before inflation. We recommend starting around 6-8% (net of fees), then running "what-if" scenarios—5% on the conservative side, 10% on the aggressive side—to see how they affect your timeline.',
},
{
question: 'How does inflation factor into my FIRE Number?',
answer:
"Cost of living rises. To maintain today's lifestyle, your monthly allowance must grow each year by your inflation rate. This calculator automatically inflates your desired monthly spending and subtracts it from your portfolio during retirement, ensuring your FIRE Number keeps pace with rising expenses.",
},
{
question: 'Can I really retire early with FIRE?',
answer:
'Early retirement is achievable with disciplined saving, smart investing, and realistic assumptions. This tool helps you set targets, visualize outcomes, and adjust inputs—so you can build confidence in your plan and make informed trade-offs between lifestyle, risk, and timeline.',
},
{
question: 'How should I use this calculator effectively?',
answer:
'Start with your actual numbers (capital, savings, age). Set conservative - mid - aggressive growth rates to bound possibilities. Slide your retirement age to explore "early" vs. "traditional" scenarios. Review the chart—especially the reference lines—to see when you hit FI and how withdrawals impact your balance. Experiment with higher savings rates or lower target spending to accelerate your path.',
},
];
export default function HomePage() {
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">
<BackgroundPattern />
@@ -176,105 +153,11 @@ export default function HomePage() {
</p>
</section>
<section className="mb-12">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqData) }}
/>
<h2 className="mb-4 text-3xl font-bold">FIRE & Investing Frequently Asked Questions (FAQ)</h2>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger className="text-xl font-semibold">
What methodology does this calculator use?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
We run a multi-year projection in two phases:
<ol className="ml-6 list-decimal space-y-1">
<li>
<strong>Accumulation:</strong> Your balance grows by CAGR and you add monthly
savings.
</li>
<li>
<strong>Retirement:</strong> The balance continues compounding, but you withdraw an
inflation-adjusted monthly allowance.
</li>
</ol>
The result: a precise estimate of the capital you&apos;ll have at retirement (your FIRE
Number) and how long it will last until your chosen life expectancy.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger className="text-xl font-semibold">
Why isn&apos;t this just the 4% rule?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
The 4% rule is a useful starting point (25× annual spending), but it assumes a fixed
withdrawal rate with inflation adjustments and doesn&apos;t model ongoing savings or
dynamic market returns. Our calculator simulates each year&apos;s growth, contributions,
and inflation-indexed withdrawals to give you a tailored picture.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger className="text-xl font-semibold">
How do I choose a realistic growth rate?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
Historically, a diversified portfolio of equities and bonds has returned around 7-10% per
year before inflation. We recommend starting around 6-8% (net of fees), then running
what-if scenarios5% on the conservative side, 10% on the aggressive sideto see how
they affect your timeline.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-4">
<AccordionTrigger className="text-xl font-semibold">
How does inflation factor into my FIRE Number?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
Cost of living rises. To maintain today&apos;s lifestyle, your monthly allowance must
grow each year by your inflation rate. This calculator automatically inflates your
desired monthly spending and subtracts it from your portfolio during retirement, ensuring
your FIRE Number keeps pace with rising expenses.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-5">
<AccordionTrigger className="text-xl font-semibold">
Can I really retire early with FIRE?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
Early retirement is achievable with disciplined saving, smart investing, and realistic
assumptions. This tool helps you set targets, visualize outcomes, and adjust inputsso
you can build confidence in your plan and make informed trade-offs between lifestyle,
risk, and timeline.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-6">
<AccordionTrigger className="text-xl font-semibold">
How should I use this calculator effectively?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
<ul className="ml-6 list-disc space-y-1">
<li>Start with your actual numbers (capital, savings, age).</li>
<li>Set conservative - mid - aggressive growth rates to bound possibilities.</li>
<li>Slide your retirement age to explore early vs. traditional scenarios.</li>
<li>
Review the chartespecially the reference linesto see when you hit FI and how
withdrawals impact your balance.
</li>
<li>
Experiment with higher savings rates or lower target spending to accelerate your
path.
</li>
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
</section>
<FaqSection
faqs={faqs}
title="FIRE & Investing Frequently Asked Questions (FAQ)"
className="mb-12"
/>
{/* Optional: Add a section for relevant resources/links here */}
<section className="mb-12">

View File

@@ -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,
},
];

View File

@@ -5,19 +5,19 @@ 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',
link: 'text-primary underline-offset-4 hover:underline',
ghost: 'text-foreground/80 hover:bg-primary/10 hover:text-foreground',
link: 'text-primary underline-offset-4 hover:underline px-0! h-auto!',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3.5',

View File

@@ -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>
)}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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}

View File

@@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: Readonly<React.ComponentProps<typeof TooltipPrimitive.Provider>>) {
return (
<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
);
}
function Tooltip({ ...props }: Readonly<React.ComponentProps<typeof TooltipPrimitive.Root>>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -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;
}
}