Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 217 additions & 58 deletions app/interactives/compounding-frequency-calculator/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,20 @@ function calculateCompoundInterest(
return { finalAmount, interestEarned, totalPeriods }
}

const MAX_INITIAL_AMOUNT = 50_000_000_000_000 // 50 trillion
const MAX_ANNUAL_RATE = 1000 // 1,000%

export default function CompoundInterestCalculator() {
const [initialAmount, setInitialAmount] = useState<string>("")
const [annualRate, setAnnualRate] = useState<string>("")
const [periods, setPeriods] = useState<string>("")
const [selectedCompounding, setSelectedCompounding] = useState<CompoundingPeriod>("monthly")

const principal = parseFloat(initialAmount.replace('$', '')) || 0
const rate = (parseFloat(annualRate.replace('%', '')) || 0) / 100
const principal = parseFloat(initialAmount) || 0
const rate = (parseFloat(annualRate) || 0) / 100
const totalPeriods = parseFloat(periods) || 0

const selectedOption = useMemo(() =>
const selectedOption = useMemo(() =>
compoundingOptions.find((o) => o.value === selectedCompounding)!,
[selectedCompounding]
)
Expand All @@ -71,94 +74,193 @@ export default function CompoundInterestCalculator() {
}
})
}, [principal, rate, totalPeriods, selectedOption.periodsPerYear])


const [initialAmountError, setInitialAmountError] = useState<string>("")
const [annualRateError, setAnnualRateError] = useState<string>("")

type CompoundingPeriod = 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'semi-annually' | 'annually';

const getPeriodText = (compounding: CompoundingPeriod, periods: number): string => {
const periodMap: Record<CompoundingPeriod, [string, string]> = {
daily: ['day', 'days'],
weekly: ['week', 'weeks'],
biweekly: ['bi-weekly period', 'bi-weekly periods'],
monthly: ['month', 'months'],
quarterly: ['quarter', 'quarters'],
'semi-annually': ['semi-annual period', 'semi-annual periods'],
annually: ['year', 'years']
};
const getPeriodText = (compounding: CompoundingPeriod, periods: number): string => {
const periodMap: Record<CompoundingPeriod, [string, string]> = {
daily: ['day', 'days'],
weekly: ['week', 'weeks'],
biweekly: ['bi-weekly period', 'bi-weekly periods'],
monthly: ['month', 'months'],
quarterly: ['quarter', 'quarters'],
'semi-annually': ['semi-annual period', 'semi-annual periods'],
annually: ['year', 'years']
};

const [singular, plural] = periodMap[compounding];
return periods === 1 ? singular : plural;
};
const [singular, plural] = periodMap[compounding];
return periods === 1 ? singular : plural;
};

return (
<div className=" p-6 max-w-5xl mx-auto">
<div className="p-6 max-w-5xl mx-auto">
<div className="max-w-6xl mx-auto">
{/* Header */}
<h1 className="sr-only mb-2">Compounding Frequency Calculator</h1>
<h1 className="sr-only">Compounding Frequency Calculator</h1>
<ThemeToggle />
<div className="flex flex-col md:flex-row gap-8">

{/* Input Fields */}
<section className="space-y-6 mb-10 w-full lg:w-1/2">
<section
aria-label="Calculator inputs"
className="space-y-6 mb-10 w-full lg:w-1/2"
>
<div>
<label className="block font-semibold text-foreground mb-2">Initial amount</label>
<label
htmlFor="initial-amount"
className="block font-semibold text-foreground mb-2"
>
Initial amount
</label>
<div className="relative">
<Input
id="initial-amount"
type="text"
value={initialAmount}
onChange={(e) => {
const input = e.target.value;
const numericPart = input.replace(/^\$/, '').replace(/[^0-9.]/g, '');
setInitialAmount('$' + numericPart);
const numericPart = input.replace(/[^0-9.]/g, "");
const numericValue = parseFloat(numericPart);
if (
!isNaN(numericValue) &&
numericValue > MAX_INITIAL_AMOUNT
) {
setInitialAmountError(
"Initial amount cannot exceed $50,000,000,000,000.",
);
return;
}
setInitialAmountError("");
setInitialAmount(numericPart);
}}
className="font-bold block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
min="0"
className={`block w-full pl-8 rounded-md shadow-sm border [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${initialAmountError ? "border-[var(--color-inline-error)] border-2" : ""}`}
/>
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-[var(--color-symbols)]">
$
</span>
</div>
{initialAmountError && (
<p
role="alert"
className="mt-1 text-sm text-[var(--color-inline-error)] font-semibold"
>
{initialAmountError}
</p>
)}
</div>

<div>
<label className="block font-semibold text-foreground mb-2">Annual interest rate</label>
<label
htmlFor="annual-rate"
className="block font-semibold text-foreground mb-2"
>
Annual interest rate
</label>
<div className="relative">
<Input
id="annual-rate"
type="text"
value={annualRate}
onChange={(e) => {
const input = e.target.value;
const numericPart = input.replace(/^%/, '').replace(/[^0-9.]/g, '');
setAnnualRate(numericPart + '%');
const numericPart = input.replace(/[^0-9.]/g, "");
const numericValue = parseFloat(numericPart);
if (
!isNaN(numericValue) &&
numericValue > MAX_ANNUAL_RATE
) {
setAnnualRateError(
"Annual interest rate cannot exceed 1,000%.",
);
return;
}
setAnnualRateError("");
setAnnualRate(numericPart);
}}
className="font-bold block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
className={`block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${annualRateError ? "border-[var(--color-inline-error)] border-2" : ""}`}
min="0"
step="0.1"
/>
<span
aria-hidden="true"
className="absolute right-3 top-1/2 -translate-y-1/2 font-medium text-[var(--color-symbols)]"
>
%
</span>
</div>
{annualRateError && (
<p
role="alert"
className="mt-1 text-sm text-[var(--color-inline-error)] font-semibold"
>
{annualRateError}
</p>
)}
</div>

<div>
<div className="flex items-start gap-2">
<label className="block font-semibold text-foreground mb-2">Number of compounding periods</label>
<InfoPopover title="Number of compounding periods">Periods are counted based on the selected compounding frequency. For monthly compounding, 60 periods equals 60 months.</InfoPopover>
<label
htmlFor="periods"
className="block font-semibold text-foreground mb-2"
>
Number of compounding periods
</label>
<InfoPopover title="Number of compounding periods">
Periods are counted based on the selected compounding
frequency. For monthly compounding, 60 periods equals 60
months.
</InfoPopover>
</div>
<div className="relative">
<Input
id="periods"
type="number"
value={periods}
onChange={(e) => setPeriods(e.target.value)}
className="font-bold block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
onChange={(e) => {
const val = e.target.value;
if (val === "" || Number(val) >= 0) setPeriods(val);
}}
onKeyDown={(e) => {
if (e.key === "-" || e.key === "e") e.preventDefault();
}}
onBlur={() => {
if (periods.startsWith(".")) {
setPeriods("0" + periods);
}
}}
aria-describedby="periods-info"
className="block w-full rounded-md shadow-sm py-2 px-3 border pr-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
min="0"
/>
</div>
</div>

<div>
<div className="flex items-start gap-2">
<label className="block font-semibold text-foreground mb-3">Compounding frequency</label>
<InfoPopover title="Compounding frequency">Compounding frequency is how often interest is calculated and added to the balance. For example, monthly compounding applies interest once each month.</InfoPopover>
<label
htmlFor="compounding-frequency"
className="block font-semibold text-foreground mb-3"
>
Compounding frequency
</label>
<InfoPopover title="Compounding frequency">
Compounding frequency is how often interest is calculated and
added to the balance. For example, monthly compounding applies
interest once each month.
</InfoPopover>
</div>
<div className="flex items-center gap-2">
<select
id="compounding-frequency"
value={selectedCompounding}
onChange={(e) => setSelectedCompounding(e.target.value as CompoundingPeriod)}
onChange={(e) =>
setSelectedCompounding(e.target.value as CompoundingPeriod)
}
aria-describedby="compounding-frequency-info"
className="border-1 w-full rounded-md shadow-sm py-2 px-3 appearance-none"
>
{compoundingOptions.map((option) => (
Expand All @@ -167,7 +269,10 @@ const getPeriodText = (compounding: CompoundingPeriod, periods: number): string
</option>
))}
</select>
<div className="pointer-events-none ml-[-40px] text-gray-400 text-lg">
<div
className="pointer-events-none ml-[-40px] text-gray-400 text-lg"
aria-hidden="true"
>
<FaAngleDown />
</div>
</div>
Expand All @@ -177,59 +282,113 @@ const getPeriodText = (compounding: CompoundingPeriod, periods: number): string
{/* Results Section */}
<Card className="w-full lg:w-1/2 bg-[var(--card-background)] rounded-3xl p-[32px]">
<CardContent className="p-0">
<p className="text-[20px] font-bold mb-1">
Balance after {periods} {getPeriodText(selectedCompounding, Number(periods))}
</p>
<h2 className="text-[20px] font-bold mb-1">
Balance after {periods}{" "}
{getPeriodText(selectedCompounding, Number(periods))}
</h2>
<p className="text-3xl font-bold text-lagunita mb-5">
{formatCurrency(selectedResult.finalAmount)}</p>
{formatCurrency(selectedResult.finalAmount)}
</p>
<p className="text-[16px] font-semibold mb-1">
Interest accrued over {periods} {getPeriodText(selectedCompounding, Number(periods))}
Interest accrued over {periods}{" "}
{getPeriodText(selectedCompounding, Number(periods))}
</p>
<p className="text-3xl font-bold text-foreground">
{formatCurrency(selectedResult.interestEarned)}
</p>
<p className="text-[16px] font-semibold text-foreground">
With <span className="text-lagunita">{selectedCompounding === 'semi-annually' ? 'semi-annual' : selectedCompounding === 'annually' ? 'annual' : selectedCompounding}</span> compounding
With{" "}
<span className="text-lagunita">
{selectedCompounding === "semi-annually"
? "semi-annual"
: selectedCompounding === "annually"
? "annual"
: selectedCompounding}
</span>{" "}
compounding
</p>
</CardContent>
</Card>
</div>
{/* Comparison Table */}
<section className="rounded-3xl bg-[var(--grey-background)] p-6 mt-10">
<h2 className="text-xl font-semibold mb-2">Comparison Across Compounding Frequencies</h2>

{/* Comparison Table */}
<section
aria-label="Comparison across compounding frequencies"
className="rounded-3xl bg-[var(--grey-background)] p-6 mt-10"
>
<h2 className="text-xl font-semibold mb-2">
Comparison Across Compounding Frequencies
</h2>
<p className="text-sm mb-4">
See how compounding frequency affects returns over the same time period.
See how compounding frequency affects returns over the same time
period.
</p>

<div className="overflow-hidden bg-card">
<table className="min-w-full">
<thead>
<tr>
<th className="font-semibold text-left px-4 py-3">Compounding Frequency</th>
<th className="font-semibold text-right px-4 py-3">Number of <br/>Compounding Periods</th>
<th className="font-semibold text-right px-4 py-3">Final Amount</th>
<th className="font-semibold text-right px-4 py-3">Interest Accrued</th>
<th scope="col" className="font-semibold text-left px-4 py-3">
Compounding Frequency
</th>
<th
scope="col"
className="font-semibold text-right px-4 py-3"
>
Number of <br />
Compounding Periods
</th>
<th
scope="col"
className="font-semibold text-right px-4 py-3"
>
Final Amount
</th>
<th
scope="col"
className="font-semibold text-right px-4 py-3"
>
Interest Accrued
</th>
</tr>
</thead>
<tbody>
{comparisonResults.map((result) => (
<tr
key={result.value}
className={selectedCompounding === result.value ? "bg-lagunita-lighter text-lagunita font-bold" : ""}
aria-current={
selectedCompounding === result.value ? "true" : undefined
}
className={
selectedCompounding === result.value
? "bg-[var(--grey-background)] text-[var(--color-teal)] font-bold"
: ""
}
>
<td className="px-4 py-3 border-b">{result.label}</td>
<td className="text-right px-4 py-3 border-b text-foreground">{Number(result.totalPeriods.toFixed(2))}</td>
<td className="text-right px-4 py-3 border-b text-foreground">{formatCurrency(result.finalAmount)}</td>
<td className="text-right px-4 py-3 border-b">{formatCurrency(result.interestEarned)}</td>
<td className="text-right px-4 py-3 border-b">
{result.totalPeriods % 1 === 0
? result.totalPeriods.toFixed(0)
: result.totalPeriods.toFixed(2)}
</td>
<td className="text-right px-4 py-3 border-b">
{formatCurrency(result.finalAmount)}
</td>
<td className="text-right px-4 py-3 border-b">
{formatCurrency(result.interestEarned)}
</td>
</tr>
))}
</tbody>
</table>
<p className="pt-3 font-bold text-sm">Over the same time period, more frequent compounding generally results in more interest accrued, assuming the annual interest rate stays the same.</p>
<p className="pt-3 font-bold text-sm">
Over the same time period, more frequent compounding results in
more interest accrued, assuming the annual interest rate stays the
same.
</p>
</div>
</section>

</div>
</div>
)
);
}
Loading