196 lines
5.5 KiB
TypeScript
196 lines
5.5 KiB
TypeScript
import { Chart, registerables } from "https://esm.sh/chart.js@4";
|
|
Chart.register(...registerables);
|
|
|
|
function monthlyInterest(apy: number) {
|
|
return Math.pow(1 + apy, 1 / 12) - 1;
|
|
}
|
|
|
|
function rawGrowth(base: number, monthlyFunc: (_: number) => number) {
|
|
const data: number[] = Array(13).fill(base);
|
|
for (let i = 1; i < data.length; i++) {
|
|
data[i] = (data[i - 1] * (1 + monthlyFunc(data[i - 1])));
|
|
}
|
|
return data;
|
|
}
|
|
|
|
const PLAT_HONORS_THRESHOLD = 100_000;
|
|
const PLAT_THRESHOLD = 50_000;
|
|
const GOLD_THRESHOLD = 20_000;
|
|
|
|
function bofaInterestRateFromSavings(savings: number) {
|
|
if (savings >= PLAT_HONORS_THRESHOLD) {
|
|
return 0.0004;
|
|
} else if (savings >= PLAT_THRESHOLD) {
|
|
return 0.0003;
|
|
} else if (savings >= GOLD_THRESHOLD) {
|
|
return 0.0002;
|
|
} else {
|
|
return 0.0001;
|
|
}
|
|
}
|
|
|
|
function bofaAdditionalPoints(basePoints: number, savings: number) {
|
|
if (savings >= PLAT_HONORS_THRESHOLD) {
|
|
return basePoints * 0.75;
|
|
} else if (savings >= PLAT_THRESHOLD) {
|
|
return basePoints * 0.5;
|
|
} else if (savings >= GOLD_THRESHOLD) {
|
|
return basePoints * 0.25;
|
|
} else {
|
|
return basePoints;
|
|
}
|
|
}
|
|
|
|
function hysaGrowth(base: number, monthly: number) {
|
|
return rawGrowth(base, () => monthly).map((v) => v - base);
|
|
}
|
|
|
|
function bofaSavingsGrowth(base: number) {
|
|
return rawGrowth(base, (savings) => bofaInterestRateFromSavings(savings)).map(
|
|
(v) => v - base
|
|
);
|
|
}
|
|
|
|
function creditCardGrowth(
|
|
baseSaved: number,
|
|
basePoints: number,
|
|
monthlySpending: number,
|
|
) {
|
|
return bofaSavingsGrowth(baseSaved).map((growth: number) =>
|
|
monthlySpending * bofaAdditionalPoints(basePoints, baseSaved + growth) / 100
|
|
);
|
|
}
|
|
|
|
function bofaCombined(
|
|
baseSaved: number,
|
|
basePoints: number,
|
|
monthlySpending: number,
|
|
) {
|
|
const savings = bofaSavingsGrowth(baseSaved);
|
|
return creditCardGrowth(baseSaved, basePoints, monthlySpending).map((v, i) =>
|
|
v + savings[i]
|
|
);
|
|
}
|
|
|
|
function bofaGrossCombined(
|
|
baseSaved: number,
|
|
basePoints: number,
|
|
monthlySpending: number,
|
|
) {
|
|
const savings = bofaSavingsGrowth(baseSaved);
|
|
return creditCardGrowth(baseSaved, basePoints, monthlySpending).map((v, i) =>
|
|
v + savings[i] - monthlySpending * i
|
|
);
|
|
}
|
|
|
|
function getDatasets(
|
|
saved: number,
|
|
hysaPercent: number,
|
|
monthlySpending: number,
|
|
avgBasePoints: number,
|
|
) {
|
|
return [
|
|
{
|
|
label: "HYSA",
|
|
data: hysaGrowth(saved, monthlyInterest(hysaPercent / 100)).map(v => v.toFixed(2)),
|
|
borderWidth: 1,
|
|
},
|
|
{
|
|
label: "BofA Savings Interest",
|
|
data: bofaSavingsGrowth(saved).map(v => v.toFixed(2)),
|
|
borderWidth: 1,
|
|
},
|
|
{
|
|
label: "Credit Card Bonus",
|
|
data: creditCardGrowth(saved, avgBasePoints, monthlySpending).map(v => v.toFixed(2)),
|
|
borderWidth: 1,
|
|
},
|
|
{
|
|
label: "BofA Savings Interest + Credit Card Bonus",
|
|
data: bofaCombined(saved, avgBasePoints, monthlySpending).map(v => v.toFixed(2)),
|
|
borderWidth: 1,
|
|
},
|
|
{
|
|
label: "BofA Savings Interest + Credit Card Bonus - Amount spent",
|
|
data: bofaGrossCombined(saved, avgBasePoints, monthlySpending).map(v => v.toFixed(2)),
|
|
borderWidth: 1,
|
|
hidden: true,
|
|
},
|
|
];
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
function getOrInit(id: string, value: number): number {
|
|
const ele = document.getElementById(id) as HTMLInputElement;
|
|
if (ele.value) {
|
|
document.getElementById(id + "Display").textContent = ele.value;
|
|
return Number(ele.value);
|
|
} else {
|
|
document.getElementById(id + "Display").textContent = String(value);
|
|
ele.value = String(value);
|
|
return value;
|
|
}
|
|
}
|
|
|
|
let saved = getOrInit("saved", 50_000);
|
|
let hysaPercent = getOrInit("hysaPercent", 3.75);
|
|
let monthlySpending = getOrInit("monthlySpending", 2_000);
|
|
let avgBasePoints = getOrInit("avgBasePoints", 1.5);
|
|
|
|
Chart.defaults.color = "#ccc";
|
|
Chart.defaults.borderColor = "#9993";
|
|
Chart.defaults.font.family = "'M PLUS Code Latin', sans-serif";
|
|
Chart.defaults.font.size = 16;
|
|
const chart = new Chart(document.getElementById('myChart'), {
|
|
normalized: true,
|
|
type: 'line',
|
|
data: {
|
|
labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
|
|
datasets: getDatasets(saved, hysaPercent, monthlySpending, avgBasePoints),
|
|
},
|
|
options: {
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
}
|
|
},
|
|
animation: false,
|
|
}
|
|
});
|
|
|
|
function updateChart() {
|
|
chart.data.datasets = getDatasets(saved, hysaPercent, monthlySpending, avgBasePoints);
|
|
chart.update();
|
|
}
|
|
|
|
document.getElementById("saved")?.addEventListener("input", (ev) => {
|
|
if (ev.target.value) {
|
|
saved = ev.target.value;
|
|
document.getElementById("savedDisplay").textContent = ev.target.value;
|
|
updateChart();
|
|
}
|
|
});
|
|
document.getElementById("monthlySpending")?.addEventListener("input", (ev) => {
|
|
if (ev.target.value) {
|
|
monthlySpending = ev.target.value;
|
|
document.getElementById("monthlySpendingDisplay").textContent = ev.target.value;
|
|
updateChart();
|
|
}
|
|
});
|
|
document.getElementById("avgBasePoints")?.addEventListener("input", (ev) => {
|
|
if (ev.target.value) {
|
|
avgBasePoints = ev.target.value;
|
|
document.getElementById("avgBasePointsDisplay").textContent = ev.target.value;
|
|
updateChart();
|
|
}
|
|
});
|
|
document.getElementById("hysaPercent")?.addEventListener("input", (ev: Event) => {
|
|
const value = (ev.target as HTMLInputElement).value;
|
|
if (value) {
|
|
hysaPercent = Number(value);
|
|
document.getElementById("hysaPercentDisplay").textContent = value;
|
|
updateChart();
|
|
}
|
|
});
|
|
});
|