-
Notifications
You must be signed in to change notification settings - Fork 0
IDFM-183: Compounding frequency Calculator - client feedback #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from 6 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
f45d398
IDFM-183: Caps and design edits
jenbreese 86252b7
New feedback from Excel sheet
jenbreese ca3b126
fixup
jenbreese c6c9c7b
fixup
jenbreese 7bb3661
added errors
jenbreese 895a792
fixup
jenbreese 05bf249
feedback fixup
jenbreese 3a46217
merge
jenbreese 1cbffe0
fixup
jenbreese File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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] | ||
| ) | ||
|
|
@@ -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" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| className="border-1 w-full rounded-md shadow-sm py-2 px-3 appearance-none" | ||
| > | ||
| {compoundingOptions.map((option) => ( | ||
|
|
@@ -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> | ||
|
|
@@ -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> | ||
| ) | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
periods-infoisn't an id that's in the page.