Compare commits

..

No commits in common. "886afab1efa826237a17404c67445c533de98596" and "c3867ccbd4195b291e7b8d14cffe4a90894f9c6a" have entirely different histories.

3 changed files with 198 additions and 216 deletions

View File

@ -368,8 +368,8 @@ export default function FireCalculatorForm() {
};
return (
<>
<Card className="mb-4">
<div className="w-full max-w-3xl">
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-2xl">FIRE Calculator</CardTitle>
<CardDescription>
@ -541,199 +541,181 @@ export default function FireCalculatorForm() {
</Card>
{result && (
<div className="mb-4 grid grid-cols-1 gap-2 md:grid-cols-2">
{result.error ? (
<Card className="col-span-full">
<CardContent className="pt-6">
<>
<Card className="mb-8">
<CardHeader>
<CardTitle>Results</CardTitle>
</CardHeader>
<CardContent>
{result.error ? (
<p className="text-destructive">{result.error}</p>
) : (
<div className="space-y-4">
<div>
<Label>
FIRE Number (Required Capital at Retirement - Strategy:{" "}
{form.getValues().retirementStrategy})
</Label>
<p className="text-2xl font-bold">
{formatNumber(result.fireNumber)}
</p>
</div>
<div>
<Label>Estimated Retirement Age</Label>
<p className="text-2xl font-bold">
{result.retirementAge ?? "N/A"}
</p>
</div>
{result.inflationAdjustedAllowance && (
<div>
<Label>
Monthly Allowance at Retirement (Inflation Adjusted)
</Label>
<p className="text-2xl font-bold">
{formatNumber(result.inflationAdjustedAllowance)}
</p>
</div>
)}
{result.retirementYears && (
<div>
<Label>Retirement Duration (Years)</Label>
<p className="text-2xl font-bold">
{result.retirementYears}
</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
{result.yearlyData && result.yearlyData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Financial Projection</CardTitle>
<CardDescription>
Projected balance growth and FIRE number threshold
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
className="h-80"
config={{
balance: {
label: "Balance",
color: "var(--chart-1)",
},
fireNumber: {
label: "FIRE Number",
color: "var(--chart-3)",
},
}}
>
<AreaChart
data={result.yearlyData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
label={{
value: "Year",
position: "insideBottom",
offset: -10,
}}
/>
<YAxis
tickFormatter={(value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toFixed(0)}K`;
}
return value.toString();
}}
width={80}
/>
<ChartTooltip
content={({ active, payload }) => {
if (active && payload?.[0]?.payload) {
const data = payload[0]
.payload as (typeof result.yearlyData)[0];
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
<p className="text-primary">{`Balance: ${formatNumber(data.balance)}`}</p>
{result.fireNumber !== null && (
<p className="text-destructive">{`Target FIRE Number: ${formatNumber(result.fireNumber)}`}</p>
)}
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
}
return null;
}}
/>
<defs>
<linearGradient
id="fillBalance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--chart-1)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="balance"
name="balance"
stroke="var(--chart-1)"
fill="url(#fillBalance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
{result.fireNumber && (
<ReferenceLine
y={result.fireNumber}
stroke="var(--chart-3)"
strokeWidth={2}
strokeDasharray="5 5"
label={{
value: "FIRE Number",
position: "insideBottomRight",
}}
/>
)}
{result.retirementAge && (
<ReferenceLine
x={
currentYear +
(result.retirementAge - form.getValues().currentAge)
}
stroke="var(--chart-2)"
strokeWidth={2}
label={{
value: "Retirement",
position: "insideTopRight",
}}
/>
)}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>FIRE Number</CardTitle>
<CardDescription className="text-xs">
Required capital at retirement using{" "}
{form.getValues().retirementStrategy}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.fireNumber)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retirement Age</CardTitle>
<CardDescription className="text-xs">
Estimated age when you can retire
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{result.retirementAge ?? "N/A"}
</p>
</CardContent>
</Card>
{result.inflationAdjustedAllowance && (
<Card>
<CardHeader>
<CardTitle>Monthly Allowance</CardTitle>
<CardDescription className="text-xs">
At retirement (inflation adjusted)
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.inflationAdjustedAllowance)}
</p>
</CardContent>
</Card>
)}
{result.retirementYears && (
<Card>
<CardHeader>
<CardTitle>Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years in retirement
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{result.retirementYears}
</p>
</CardContent>
</Card>
)}
</>
)}
</div>
</>
)}
{result?.yearlyData && result.yearlyData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Financial Projection</CardTitle>
<CardDescription>
Projected balance growth and FIRE number threshold
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
className="aspect-auto h-80 w-full"
config={{
balance: {
label: "Balance",
color: "var(--chart-1)",
},
fireNumber: {
label: "FIRE Number",
color: "var(--chart-3)",
},
}}
>
<AreaChart
data={result.yearlyData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
label={{
value: "Year",
position: "insideBottom",
offset: -10,
}}
/>
<YAxis
tickFormatter={(value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return value.toString();
}}
width={25}
/>
<ChartTooltip
content={({ active, payload }) => {
if (active && payload?.[0]?.payload) {
const data = payload[0]
.payload as (typeof result.yearlyData)[0];
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
<p className="text-primary">{`Balance: ${formatNumber(data.balance)}`}</p>
{result.fireNumber !== null && (
<p className="text-destructive">{`Target FIRE Number: ${formatNumber(result.fireNumber)}`}</p>
)}
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
}
return null;
}}
/>
<defs>
<linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--chart-1)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="balance"
name="balance"
stroke="var(--chart-1)"
fill="url(#fillBalance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
{result.fireNumber && (
<ReferenceLine
y={result.fireNumber}
stroke="var(--chart-3)"
strokeWidth={2}
strokeDasharray="5 5"
label={{
value: "FIRE Number",
position: "insideBottomRight",
}}
/>
)}
{result.retirementAge && (
<ReferenceLine
x={
currentYear +
(result.retirementAge - form.getValues().currentAge)
}
stroke="var(--chart-2)"
strokeWidth={2}
label={{
value: "Retirement",
position: "insideTopRight",
}}
/>
)}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)}
</>
</div>
);
}

View File

@ -10,7 +10,7 @@ import {
export default function HomePage() {
return (
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-4">
<div className="mx-auto flex flex-col items-center justify-center gap-4 text-center">
<div className="container mx-auto flex flex-col items-center justify-center gap-4 px-4 py-16 text-center">
<div className="flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
<Image
src="/investingfire_logo_no-bg.svg"
@ -31,7 +31,7 @@ export default function HomePage() {
</div>
{/* Added SEO Content Sections */}
<div className="mx-auto max-w-2xl py-12 text-left">
<div className="container mx-auto max-w-4xl px-4 py-8 text-left">
<section className="mb-12">
<h2 className="mb-4 text-3xl font-bold">
What is FIRE? Understanding Financial Independence and Early

View File

@ -1,27 +1,27 @@
"use client";
"use client"
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
@ -30,7 +30,7 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
@ -38,7 +38,7 @@ function SelectTrigger({
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 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent 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}
>
@ -47,7 +47,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
)
}
function SelectContent({
@ -64,7 +64,7 @@ function SelectContent({
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
className
)}
position={position}
{...props}
@ -74,7 +74,7 @@ function SelectContent({
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
@ -82,7 +82,7 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
)
}
function SelectLabel({
@ -95,7 +95,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
)
}
function SelectItem({
@ -108,7 +108,7 @@ function SelectItem({
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
className
)}
{...props}
>
@ -119,7 +119,7 @@ function SelectItem({
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
)
}
function SelectSeparator({
@ -132,7 +132,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
)
}
function SelectScrollUpButton({
@ -144,13 +144,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
)
}
function SelectScrollDownButton({
@ -162,13 +162,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
)
}
export {
@ -182,4 +182,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
};
}