Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
caf6222
IFDM-153: Set up a staging site
imonroe May 26, 2026
1478c0e
Client feedback changes
jenbreese Jun 5, 2026
b01395d
fixup
jenbreese Jun 8, 2026
67ed153
Time Value of Money client feedback updates
jenbreese Jun 8, 2026
e7f87ad
Merge branch '1.x' into dev
imonroe Jun 9, 2026
8e0f34f
Updated yarn script
imonroe Jun 9, 2026
fc5a850
Updated documentation
imonroe Jun 9, 2026
a696c87
fixup
jenbreese Jun 9, 2026
61b8e75
Testing dev branch flow
jenbreese Jun 9, 2026
1033a8c
Merge pull request #124 from SU-SWS/IFDM-153--testing
jenbreese Jun 9, 2026
773c6fa
Merge pull request #121 from SU-SWS/present-val-v2
jenbreese Jun 9, 2026
c43cce1
Fixups to questions and added errors
jenbreese Jun 9, 2026
bfdd454
feedback
jenbreese Jun 10, 2026
2276b69
Fixed edgecase bug if pmt === 0, typo
imonroe Jun 10, 2026
c8616e5
Fixes from spreadsheet to three calculators
jenbreese Jun 10, 2026
d310a74
Merge pull request #122 from SU-SWS/time-value-cal-0608
jenbreese Jun 11, 2026
a6b948a
Merge branch 'dev' into misc-cals-fixes
jenbreese Jun 11, 2026
218691e
retirement
jenbreese Jun 11, 2026
220db99
fixup
jenbreese Jun 11, 2026
2b49c8f
fixup
jenbreese Jun 11, 2026
bc073d3
Error states for compounding frequency calculator
imonroe Jun 11, 2026
f089a03
Error states and cleanup for present-value-v2
imonroe Jun 11, 2026
043cb38
fixups
jenbreese Jun 11, 2026
3f3943c
two little fixes
imonroe Jun 11, 2026
574ad7d
Merge pull request #126 from SU-SWS/compounding-frequency-errors
imonroe Jun 12, 2026
c50dd86
Merge pull request #127 from SU-SWS/present-value-error-states
imonroe Jun 12, 2026
f888b91
Merge pull request #125 from SU-SWS/misc-cals-fixes
jenbreese Jun 12, 2026
87454ee
Fixed the build error and the text color
jenbreese Jun 12, 2026
4db95bb
Merge pull request #128 from SU-SWS/build-fix
jenbreese Jun 12, 2026
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
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,32 @@ This repository serves as source/hosting for an interactive learning tool. The g

---

## Deployment
## Environments

Please see documentation on deployment and embedding here: [deployment.md](docs/deployment.md)
| Environment | URL | Branch | Deploy command |
|---|---|---|---|
| Production | https://ifdm-learning.stanford.edu/ | `1.x` | `yarn deploy` |
| Staging | https://su-sws.github.io/ifdm_learning_apps_staging/ | `dev` | `yarn deploy:staging` |

### Development workflow

New features are developed on feature branches, reviewed via pull request, then deployed to staging for QA before going to production:

```
feature/my-branch → PR into dev → yarn deploy:staging → review on staging
↓ approved
PR into 1.x → yarn deploy → production
```

1. Create a feature branch off `dev` (or `1.x` for urgent fixes)
2. Open a PR targeting `dev`
3. After merge, deploy to staging and verify: `git checkout dev && git pull && yarn deploy:staging`
4. Once staging looks good, open a PR from `dev` into `1.x`
5. After merge, deploy to production: `git checkout 1.x && git pull && yarn deploy`

The deploy scripts enforce branch — `yarn deploy` will fail if you're not on `1.x`, and `yarn deploy:staging` will fail if you're not on `dev`.

For full deployment details and troubleshooting, see [docs/deployment.md](docs/deployment.md).

## Local Setup

Expand Down
87 changes: 65 additions & 22 deletions app/interactives/compounding-frequency-calculator/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ function calculateCompoundInterest(
const MAX_INITIAL_AMOUNT = 100_000_000 // 100 million
const MAX_ANNUAL_RATE = 1000 // 1,000%

const periodPluralLabels: Record<CompoundingPeriod, string> = {
annually: "years",
"semi-annually": "semi-annual periods",
quarterly: "quarters",
monthly: "months",
biweekly: "bi-weekly periods",
weekly: "weeks",
daily: "days",
}

const freqLabels: Record<CompoundingPeriod, string> = {
annually: "annual",
"semi-annually": "semiannual",
quarterly: "quarterly",
monthly: "monthly",
biweekly: "biweekly",
weekly: "weekly",
daily: "daily",
}

function buildPeriodsRangeError(compounding: CompoundingPeriod, max: number): string {
const label = periodPluralLabels[compounding]
const maxFormatted = max.toLocaleString("en-US")
const base = `Enter a number of ${label} between 0 and ${maxFormatted}.`
if (compounding === "annually") return base
return `${base} (${maxFormatted} periods = 100 years with ${freqLabels[compounding]} compounding).`
}

export default function CompoundInterestCalculator() {
const [initialAmount, setInitialAmount] = useState<string>("")
const [annualRate, setAnnualRate] = useState<string>("")
Expand Down Expand Up @@ -81,10 +109,8 @@ export default function CompoundInterestCalculator() {

const [initialAmountError, setInitialAmountError] = useState<string>("")
const [annualRateError, setAnnualRateError] = useState<string>("")
const hasError =
!!initialAmountError ||
!!annualRateError ||
Number(periods) > maxPeriods
const [periodsError, setPeriodsError] = useState<string>("")
const hasError = !!initialAmountError || !!annualRateError || !!periodsError

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

Expand Down Expand Up @@ -136,9 +162,9 @@ export default function CompoundInterestCalculator() {
numericValue > MAX_INITIAL_AMOUNT
) {
setInitialAmountError(
"Initial amount cannot exceed $100,000,000.",
"Enter an amount between $0 and $100,000,000.",
);
setInitialAmount(numericPart); // ← update state even on error
setInitialAmount(numericPart);
} else {
setInitialAmountError("");
setInitialAmount(numericPart);
Expand All @@ -147,6 +173,8 @@ export default function CompoundInterestCalculator() {
onBlur={() => {
if (initialAmount.startsWith("."))
setInitialAmount("0" + initialAmount);
if (!initialAmount)
setTimeout(() => setInitialAmountError("Enter an initial amount."), 150);
}}
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" : ""}`}
Expand Down Expand Up @@ -177,7 +205,6 @@ export default function CompoundInterestCalculator() {
id="annual-rate"
type="text"
value={annualRate}
// Annual rate onChange
onChange={(e) => {
const input = e.target.value;
const numericPart = input.replace(/[^0-9.]/g, "");
Expand All @@ -187,9 +214,9 @@ export default function CompoundInterestCalculator() {
numericValue > MAX_ANNUAL_RATE
) {
setAnnualRateError(
"Annual interest rate cannot exceed 1,000%.",
"Enter a rate between 0% and 1,000%.",
);
setAnnualRate(numericPart); // ← update state even on error
setAnnualRate(numericPart);
} else {
setAnnualRateError("");
setAnnualRate(numericPart);
Expand All @@ -198,6 +225,8 @@ export default function CompoundInterestCalculator() {
onBlur={() => {
if (annualRate.startsWith("."))
setAnnualRate("0" + annualRate);
if (!annualRate)
setTimeout(() => setAnnualRateError("Please enter an interest rate."), 150);
}}
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"
Expand Down Expand Up @@ -241,29 +270,33 @@ export default function CompoundInterestCalculator() {
value={periods}
onChange={(e) => {
const val = e.target.value;
if (val === "" || Number(val) >= 0) setPeriods(val);
if (val === "" || Number(val) >= 0) {
setPeriods(val);
if (val !== "" && Number(val) > maxPeriods) {
setPeriodsError(buildPeriodsRangeError(selectedCompounding, maxPeriods));
} else {
setPeriodsError("");
}
}
}}
onKeyDown={(e) => {
if (e.key === "-" || e.key === "e") e.preventDefault();
}}
onBlur={() => {
if (periods.startsWith(".")) {
setPeriods("0" + periods);
}
if (periods.startsWith(".")) setPeriods("0" + periods);
if (!periods)
setTimeout(() => setPeriodsError("Enter a number of compounding periods."), 150);
}}
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"
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 ${periodsError ? "border-[var(--color-inline-error)] border-2" : ""}`}
min="0"
/>
</div>
{Number(periods) > maxPeriods && (
{periodsError && (
<p
role="alert"
className="mt-1 text-sm text-[var(--color-inline-error)] font-semibold"
>
Number of periods cannot exceed{" "}
{maxPeriods.toLocaleString("en-US")} (
{selectedOption.label.toLowerCase()} compounding caps at 100
years).
{periodsError}
</p>
)}
</div>
Expand All @@ -286,9 +319,19 @@ export default function CompoundInterestCalculator() {
<select
id="compounding-frequency"
value={selectedCompounding}
onChange={(e) =>
setSelectedCompounding(e.target.value as CompoundingPeriod)
}
onChange={(e) => {
const newFreq = e.target.value as CompoundingPeriod
setSelectedCompounding(newFreq)
if (periods !== "") {
const newOption = compoundingOptions.find(o => o.value === newFreq)!
const newMax = newOption.periodsPerYear * 100
if (Number(periods) > newMax) {
setPeriodsError(buildPeriodsRangeError(newFreq, newMax))
} else {
setPeriodsError("")
}
}
}}
className="border-1 w-full rounded-md shadow-sm py-2 px-3 appearance-none"
>
{compoundingOptions.map((option) => (
Expand Down
Loading