@jjlmoya/utils-finance 1.40.0 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +3 -2
- package/src/entries.ts +5 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/savingsRate/bibliography.astro +11 -0
- package/src/tool/savingsRate/bibliography.ts +12 -0
- package/src/tool/savingsRate/component.astro +326 -0
- package/src/tool/savingsRate/content.ts +1877 -0
- package/src/tool/savingsRate/entry.ts +31 -0
- package/src/tool/savingsRate/i18n/de.ts +3 -0
- package/src/tool/savingsRate/i18n/en.ts +1 -0
- package/src/tool/savingsRate/i18n/es.ts +3 -0
- package/src/tool/savingsRate/i18n/fr.ts +3 -0
- package/src/tool/savingsRate/i18n/id.ts +3 -0
- package/src/tool/savingsRate/i18n/it.ts +3 -0
- package/src/tool/savingsRate/i18n/ja.ts +3 -0
- package/src/tool/savingsRate/i18n/ko.ts +3 -0
- package/src/tool/savingsRate/i18n/nl.ts +3 -0
- package/src/tool/savingsRate/i18n/pl.ts +3 -0
- package/src/tool/savingsRate/i18n/pt.ts +3 -0
- package/src/tool/savingsRate/i18n/ru.ts +3 -0
- package/src/tool/savingsRate/i18n/sv.ts +3 -0
- package/src/tool/savingsRate/i18n/tr.ts +3 -0
- package/src/tool/savingsRate/i18n/zh.ts +3 -0
- package/src/tool/savingsRate/index.ts +11 -0
- package/src/tool/savingsRate/logic.test.ts +87 -0
- package/src/tool/savingsRate/logic.ts +121 -0
- package/src/tool/savingsRate/savings-rate-calculator.css +575 -0
- package/src/tool/savingsRate/seo.astro +11 -0
- package/src/tool/savingsRate/ui.ts +48 -0
- package/src/tools.ts +2 -1
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FinanceCategoryEntry } from '../types';
|
|
2
2
|
import { compoundInterest } from '../tool/compoundInterest/entry';
|
|
3
|
-
import { financialFreedom } from '../tool/financialFreedom/entry';
|
|
3
|
+
import { financialFreedom } from '../tool/financialFreedom/entry';
|
|
4
|
+
import { savingsRate } from '../tool/savingsRate/entry';
|
|
4
5
|
import { profitability } from '../tool/profitability/entry';
|
|
5
6
|
import { mortgage } from '../tool/mortgage/entry';
|
|
6
7
|
import { inflation } from '../tool/inflation/entry';
|
|
@@ -23,7 +24,7 @@ import { loanToIncomeRatio } from '../tool/loanToIncomeRatio/entry';
|
|
|
23
24
|
|
|
24
25
|
export const financeCategory: FinanceCategoryEntry = {
|
|
25
26
|
icon: 'mdi:finance',
|
|
26
|
-
tools: [compoundInterest, financialFreedom, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio],
|
|
27
|
+
tools: [compoundInterest, financialFreedom, savingsRate, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio],
|
|
27
28
|
i18n: {
|
|
28
29
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
29
30
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { savingsRate } from './tool/savingsRate/entry';
|
|
2
|
+
|
|
1
3
|
export { compoundInterest } from './tool/compoundInterest/entry';
|
|
2
4
|
export type { CompoundInterestLocaleContent } from './tool/compoundInterest/entry';
|
|
3
5
|
export { financialFreedom } from './tool/financialFreedom/entry';
|
|
4
6
|
export type { FinancialFreedomLocaleContent } from './tool/financialFreedom/entry';
|
|
7
|
+
export { savingsRate };
|
|
8
|
+
export type { SavingsRateLocaleContent } from './tool/savingsRate/entry';
|
|
5
9
|
export { profitability } from './tool/profitability/entry';
|
|
6
10
|
export type { ProfitabilityLocaleContent } from './tool/profitability/entry';
|
|
7
11
|
export { courtFeeCalculator } from './tool/courtFeeCalculator/entry';
|
|
@@ -63,4 +67,4 @@ import { mortgageExtraPaymentPlanner } from './tool/mortgageExtraPaymentPlanner/
|
|
|
63
67
|
import { inflationPurchasingPower } from './tool/inflationPurchasingPower/entry';
|
|
64
68
|
import { subscriptionCostAnnualizer } from './tool/subscriptionCostAnnualizer/entry';
|
|
65
69
|
import { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
|
|
66
|
-
export const ALL_ENTRIES = [compoundInterest, financialFreedom, profitability, courtFeeCalculator, debtSnowball, fireCalculator, ibanBicSwift, inflation, lateInterest, legalInterestRate, lotteryOptimizer, mortgage, percentageCalculator, rentIncrease, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, mortgageExtraPaymentPlanner, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio];
|
|
70
|
+
export const ALL_ENTRIES = [compoundInterest, financialFreedom, savingsRate, profitability, courtFeeCalculator, debtSnowball, fireCalculator, ibanBicSwift, inflation, lateInterest, legalInterestRate, lotteryOptimizer, mortgage, percentageCalculator, rentIncrease, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, mortgageExtraPaymentPlanner, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio];
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type {
|
|
|
18
18
|
export { ALL_ENTRIES, ALL_TOOLS } from './tools';
|
|
19
19
|
|
|
20
20
|
export { COMPOUND_INTEREST_TOOL } from './tool/compoundInterest';
|
|
21
|
+
export { SAVINGS_RATE_TOOL } from './tool/savingsRate';
|
|
21
22
|
export { MORTGAGE_TOOL } from './tool/mortgage';
|
|
22
23
|
export { INFLATION_TOOL } from './tool/inflation';
|
|
23
24
|
export { PERCENTAGE_CALCULATOR_TOOL } from './tool/percentageCalculator';
|
|
@@ -4,8 +4,8 @@ import { financeCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 23 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(23);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('financeCategory should be defined', () => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { savingsRate } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props { locale?: KnownLocale; }
|
|
7
|
+
const { locale = 'en' } = Astro.props;
|
|
8
|
+
const content = await savingsRate.i18n[locale]?.() ?? await savingsRate.i18n.en?.();
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from "../../types";
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: "Consumer Financial Protection Bureau: Your Money, Your Goals toolkit",
|
|
6
|
+
url: "https://www.consumerfinance.gov/consumer-tools/educator-tools/your-money-your-goals/toolkit/",
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: "MoneyHelper: Getting into the savings habit",
|
|
10
|
+
url: "https://www.moneyhelper.org.uk/en/savings/how-to-save/getting-into-the-savings-habit",
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { SavingsRateUI } from './ui';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
ui: SavingsRateUI;
|
|
6
|
+
locale?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { ui, locale = 'en' } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<div
|
|
13
|
+
class="savings-rate-tool"
|
|
14
|
+
id="savings-rate-tool"
|
|
15
|
+
data-locale={locale}
|
|
16
|
+
data-label-target-met={ui.labelTargetMet}
|
|
17
|
+
data-label-more-needed={ui.labelMoreNeeded}
|
|
18
|
+
data-label-scenario-target-met={ui.labelScenarioTargetMet}
|
|
19
|
+
data-label-scenario-more-needed={ui.labelScenarioMoreNeeded}
|
|
20
|
+
data-label-copied={ui.labelCopied}
|
|
21
|
+
data-label-invalid={ui.labelInvalidInput}
|
|
22
|
+
data-label-rate={ui.labelRate}
|
|
23
|
+
data-label-percentage-points={ui.labelPercentagePoints}
|
|
24
|
+
>
|
|
25
|
+
<form class="savings-rate-workbench savings-rate-card" id="savings-rate-form" novalidate>
|
|
26
|
+
<section class="savings-rate-input-panel" aria-labelledby="savings-rate-input-title">
|
|
27
|
+
<div class="savings-rate-kicker">{ui.labelMoneyPicture}</div>
|
|
28
|
+
<h2 id="savings-rate-input-title">Your numbers</h2>
|
|
29
|
+
|
|
30
|
+
<div class="savings-rate-period-row">
|
|
31
|
+
<span class="savings-rate-field-label">{ui.labelPeriod}</span>
|
|
32
|
+
<div class="savings-rate-segmented" role="group" aria-label={ui.labelPeriod}>
|
|
33
|
+
<button type="button" class="savings-rate-segment is-active" data-period="monthly" aria-pressed="true">{ui.labelMonthly}</button>
|
|
34
|
+
<button type="button" class="savings-rate-segment" data-period="yearly" aria-pressed="false">{ui.labelYearly}</button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="savings-rate-inline-row">
|
|
39
|
+
<label for="savings-rate-currency">{ui.labelCurrency}</label>
|
|
40
|
+
<select id="savings-rate-currency" class="savings-rate-select">
|
|
41
|
+
<option value="USD">USD, {ui.labelCurrencyUSD}</option>
|
|
42
|
+
<option value="EUR">EUR, {ui.labelCurrencyEUR}</option>
|
|
43
|
+
<option value="GBP">GBP, {ui.labelCurrencyGBP}</option>
|
|
44
|
+
<option value="CAD">CAD, {ui.labelCurrencyCAD}</option>
|
|
45
|
+
<option value="AUD">AUD, {ui.labelCurrencyAUD}</option>
|
|
46
|
+
<option value="JPY">JPY, {ui.labelCurrencyJPY}</option>
|
|
47
|
+
</select>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="savings-rate-field">
|
|
51
|
+
<label for="savings-rate-income">{ui.labelIncome}</label>
|
|
52
|
+
<div class="savings-rate-money-input"><span aria-hidden="true" data-currency-symbol>$</span><input id="savings-rate-income" type="number" min="0.01" step="100" value="4000" inputmode="decimal" required /></div>
|
|
53
|
+
<small>{ui.labelIncomeHelp}</small>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="savings-rate-field">
|
|
57
|
+
<label for="savings-rate-saved">{ui.labelSaved}</label>
|
|
58
|
+
<div class="savings-rate-money-input"><span aria-hidden="true" data-currency-symbol>$</span><input id="savings-rate-saved" type="number" min="0" step="50" value="1000" inputmode="decimal" required /></div>
|
|
59
|
+
<small>{ui.labelSavedHelp}</small>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="savings-rate-field">
|
|
63
|
+
<label for="savings-rate-extra">{ui.labelExtraContribution}</label>
|
|
64
|
+
<div class="savings-rate-money-input"><span aria-hidden="true" data-currency-symbol>$</span><input id="savings-rate-extra" type="number" min="0" step="50" value="200" inputmode="decimal" required /></div>
|
|
65
|
+
<small>{ui.labelExtraContributionHelp}</small>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="savings-rate-field savings-rate-target-field">
|
|
69
|
+
<label for="savings-rate-target">{ui.labelTargetRate}</label>
|
|
70
|
+
<div class="savings-rate-percent-input"><input id="savings-rate-target" type="number" min="0" max="100" step="1" value="30" inputmode="decimal" required /><span aria-hidden="true">%</span></div>
|
|
71
|
+
<small>{ui.labelTargetRateHelp}</small>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div class="savings-rate-scenario-inputs" aria-labelledby="savings-rate-scenario-title">
|
|
75
|
+
<div class="savings-rate-scenario-heading">
|
|
76
|
+
<h3 id="savings-rate-scenario-title">{ui.labelScenario}</h3>
|
|
77
|
+
<p>{ui.labelScenarioHelp}</p>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="savings-rate-two-fields">
|
|
80
|
+
<div class="savings-rate-field">
|
|
81
|
+
<label for="savings-rate-income-change">{ui.labelIncomeChange}</label>
|
|
82
|
+
<div class="savings-rate-percent-input"><input id="savings-rate-income-change" type="number" step="1" value="0" inputmode="decimal" /><span aria-hidden="true">%</span></div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="savings-rate-field">
|
|
85
|
+
<label for="savings-rate-spending-change">{ui.labelSpendingChange}</label>
|
|
86
|
+
<div class="savings-rate-percent-input"><input id="savings-rate-spending-change" type="number" step="1" value="0" inputmode="decimal" /><span aria-hidden="true">%</span></div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div class="savings-rate-actions">
|
|
92
|
+
<button type="button" class="savings-rate-button savings-rate-button-muted" id="savings-rate-reset">{ui.labelReset}</button>
|
|
93
|
+
<button type="button" class="savings-rate-button savings-rate-button-primary" id="savings-rate-copy">{ui.labelCopyResult}</button>
|
|
94
|
+
</div>
|
|
95
|
+
<p class="savings-rate-error" id="savings-rate-error" role="alert" hidden>{ui.labelInvalidInput}</p>
|
|
96
|
+
</section>
|
|
97
|
+
|
|
98
|
+
<section class="savings-rate-results-panel" aria-labelledby="savings-rate-results-title">
|
|
99
|
+
<div class="savings-rate-results-topline">
|
|
100
|
+
<div>
|
|
101
|
+
<div class="savings-rate-kicker">{ui.labelSavingsSnapshot}</div>
|
|
102
|
+
<h2 id="savings-rate-results-title">{ui.labelCurrentRate}</h2>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="savings-rate-period-badge" id="savings-rate-period-badge">{ui.labelMonthly}</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="savings-rate-illustration" aria-hidden="true">
|
|
108
|
+
<svg viewBox="0 0 560 178" role="presentation">
|
|
109
|
+
<path class="savings-rate-illustration-sun" d="M52 53a25 25 0 1 0 0 1Z" />
|
|
110
|
+
<path class="savings-rate-illustration-cloud" d="M80 78c0-8 7-15 16-15 4-11 21-13 28-2 13-4 25 5 25 17H80Z" />
|
|
111
|
+
<path class="savings-rate-illustration-road" d="M24 143c62-30 105-34 161-18 54 15 86 2 132-20 55-27 114-29 219 6" />
|
|
112
|
+
<path class="savings-rate-illustration-road-line" d="M48 132c31-12 58-14 84-9m31 10c26 3 47-4 69-14m33-14c26-12 53-17 81-16m33 3c27 1 52 7 80 16" />
|
|
113
|
+
<path class="savings-rate-illustration-jar" d="M230 83h72l-6 64h-60l-6-64Z" />
|
|
114
|
+
<path class="savings-rate-illustration-lid" d="M226 76h80v13h-80z" />
|
|
115
|
+
<path class="savings-rate-illustration-jar-line" d="M242 105h48m-45 17h42" />
|
|
116
|
+
<circle class="savings-rate-illustration-coin" cx="347" cy="117" r="13" />
|
|
117
|
+
<circle class="savings-rate-illustration-coin" cx="374" cy="133" r="10" />
|
|
118
|
+
<circle class="savings-rate-illustration-coin-light" cx="365" cy="102" r="8" />
|
|
119
|
+
<path class="savings-rate-illustration-flag" d="M449 137V48m0 1 48 14-48 16" />
|
|
120
|
+
<path class="savings-rate-illustration-flag-fill" d="m452 52 39 11-39 11Z" />
|
|
121
|
+
<circle class="savings-rate-illustration-spark" cx="422" cy="74" r="4" />
|
|
122
|
+
<circle class="savings-rate-illustration-spark" cx="516" cy="89" r="3" />
|
|
123
|
+
</svg>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div class="savings-rate-hero">
|
|
127
|
+
<div class="savings-rate-ring" id="savings-rate-ring" aria-hidden="true"><div class="savings-rate-ring-inner"><strong id="savings-rate-rate">30%</strong><span>{ui.labelRate}</span></div></div>
|
|
128
|
+
<div class="savings-rate-hero-copy">
|
|
129
|
+
<p id="savings-rate-status" class="savings-rate-status" role="status" aria-live="polite">{ui.labelTargetMet}</p>
|
|
130
|
+
<p id="savings-rate-target-detail">0 {ui.labelMoreNeeded}</p>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<dl class="savings-rate-metric-grid">
|
|
135
|
+
<div><dt>{ui.labelAnnualSaved}</dt><dd id="savings-rate-annual-saved">$14,400</dd></div>
|
|
136
|
+
<div><dt>{ui.labelAnnualIncome}</dt><dd id="savings-rate-annual-income">$48,000</dd></div>
|
|
137
|
+
<div><dt>{ui.labelSpending}</dt><dd id="savings-rate-spending">$2,800</dd></div>
|
|
138
|
+
<div><dt>{ui.labelTargetGap}</dt><dd id="savings-rate-gap">0 {ui.labelPercentagePoints}</dd></div>
|
|
139
|
+
</dl>
|
|
140
|
+
|
|
141
|
+
<div class="savings-rate-scenario-card">
|
|
142
|
+
<div class="savings-rate-scenario-card-heading">
|
|
143
|
+
<div><div class="savings-rate-kicker">{ui.labelTrySmallChange}</div><h3>{ui.labelScenario}</h3></div>
|
|
144
|
+
<strong id="savings-rate-scenario-rate">30%</strong>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="savings-rate-scenario-bar" aria-hidden="true"><span id="savings-rate-scenario-bar-fill"></span><i id="savings-rate-target-marker"></i></div>
|
|
147
|
+
<div class="savings-rate-scenario-summary"><span id="savings-rate-scenario-change">0 {ui.labelPercentagePoints}</span><span>{ui.labelScenarioChange}</span></div>
|
|
148
|
+
<dl class="savings-rate-scenario-metrics">
|
|
149
|
+
<div><dt>{ui.labelScenarioSaved}</dt><dd id="savings-rate-scenario-saved">$1,200</dd></div>
|
|
150
|
+
<div><dt>{ui.labelScenarioSpending}</dt><dd id="savings-rate-scenario-spending">$2,800</dd></div>
|
|
151
|
+
<div><dt>{ui.labelScenarioTarget}</dt><dd id="savings-rate-scenario-target">{ui.labelScenarioTargetMet}</dd></div>
|
|
152
|
+
</dl>
|
|
153
|
+
</div>
|
|
154
|
+
</section>
|
|
155
|
+
</form>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<script>
|
|
159
|
+
import { calculateSavingsRate, type SavingsPeriod } from './logic';
|
|
160
|
+
|
|
161
|
+
const root = document.getElementById('savings-rate-tool');
|
|
162
|
+
if (root) {
|
|
163
|
+
const getInput = (id: string) => document.getElementById(id) as HTMLInputElement | null;
|
|
164
|
+
const income = getInput('savings-rate-income');
|
|
165
|
+
const saved = getInput('savings-rate-saved');
|
|
166
|
+
const extra = getInput('savings-rate-extra');
|
|
167
|
+
const target = getInput('savings-rate-target');
|
|
168
|
+
const incomeChange = getInput('savings-rate-income-change');
|
|
169
|
+
const spendingChange = getInput('savings-rate-spending-change');
|
|
170
|
+
const currency = document.getElementById('savings-rate-currency') as HTMLSelectElement | null;
|
|
171
|
+
const periodButtons = Array.from(document.querySelectorAll<HTMLButtonElement>('[data-period]'));
|
|
172
|
+
const symbols = Array.from(document.querySelectorAll<HTMLElement>('[data-currency-symbol]'));
|
|
173
|
+
const error = document.getElementById('savings-rate-error');
|
|
174
|
+
const periodBadge = document.getElementById('savings-rate-period-badge');
|
|
175
|
+
let period: SavingsPeriod = 'monthly';
|
|
176
|
+
|
|
177
|
+
const labels = {
|
|
178
|
+
targetMet: root.dataset.labelTargetMet ?? '',
|
|
179
|
+
moreNeeded: root.dataset.labelMoreNeeded ?? '',
|
|
180
|
+
scenarioTargetMet: root.dataset.labelScenarioTargetMet ?? '',
|
|
181
|
+
scenarioMoreNeeded: root.dataset.labelScenarioMoreNeeded ?? '',
|
|
182
|
+
invalid: root.dataset.labelInvalid ?? '',
|
|
183
|
+
rate: root.dataset.labelRate ?? '',
|
|
184
|
+
percentagePoints: root.dataset.labelPercentagePoints ?? '',
|
|
185
|
+
};
|
|
186
|
+
const currencySymbols: Record<string, string> = { USD: '$', EUR: '€', GBP: '£', CAD: '$', AUD: '$', JPY: '¥' };
|
|
187
|
+
|
|
188
|
+
const setText = (id: string, value: string): void => {
|
|
189
|
+
const element = document.getElementById(id);
|
|
190
|
+
if (element) element.textContent = value;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const formatMoney = (value: number): string => {
|
|
194
|
+
const code = currency?.value ?? 'USD';
|
|
195
|
+
return new Intl.NumberFormat(root.dataset.locale ?? 'en', { style: 'currency', currency: code, maximumFractionDigits: code === 'JPY' ? 0 : 2 }).format(value).replace(/\u00a0/g, ' ');
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const read = (element: HTMLInputElement | null): number => Number(element?.value ?? '');
|
|
199
|
+
|
|
200
|
+
const renderError = (): void => {
|
|
201
|
+
if (error) {
|
|
202
|
+
error.hidden = false;
|
|
203
|
+
error.textContent = labels.invalid;
|
|
204
|
+
}
|
|
205
|
+
document.getElementById('savings-rate-form')?.setAttribute('aria-invalid', 'true');
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const clearError = (): void => {
|
|
209
|
+
if (error) error.hidden = true;
|
|
210
|
+
document.getElementById('savings-rate-form')?.removeAttribute('aria-invalid');
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const renderBaseline = (result: NonNullable<ReturnType<typeof calculateSavingsRate>>): void => {
|
|
214
|
+
setText('savings-rate-rate', `${result.rate}%`);
|
|
215
|
+
setText('savings-rate-annual-saved', formatMoney(result.annualSaved));
|
|
216
|
+
setText('savings-rate-annual-income', formatMoney(result.annualIncome));
|
|
217
|
+
setText('savings-rate-spending', formatMoney(result.spending));
|
|
218
|
+
setText('savings-rate-gap', result.targetGap <= 0 ? labels.targetMet : `${result.targetGap} ${labels.percentagePoints}`);
|
|
219
|
+
setText('savings-rate-status', result.targetGap <= 0 ? labels.targetMet : `${result.targetGap} ${labels.percentagePoints} ${labels.moreNeeded}`);
|
|
220
|
+
setText('savings-rate-target-detail', result.additionalToTarget <= 0 ? labels.targetMet : `${formatMoney(result.additionalToTarget)} ${labels.moreNeeded}`);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const renderScenario = (result: NonNullable<ReturnType<typeof calculateSavingsRate>>): void => {
|
|
224
|
+
setText('savings-rate-scenario-rate', `${result.scenario.rate}%`);
|
|
225
|
+
setText('savings-rate-scenario-change', `${result.scenario.changeFromBase >= 0 ? '+' : ''}${result.scenario.changeFromBase} ${labels.percentagePoints}`);
|
|
226
|
+
setText('savings-rate-scenario-saved', formatMoney(result.scenario.saved));
|
|
227
|
+
setText('savings-rate-scenario-spending', formatMoney(result.scenario.spending));
|
|
228
|
+
setText('savings-rate-scenario-target', result.scenario.additionalToTarget <= 0 ? labels.scenarioTargetMet : `${formatMoney(result.scenario.additionalToTarget)} ${labels.scenarioMoreNeeded}`);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const renderGauges = (result: NonNullable<ReturnType<typeof calculateSavingsRate>>): void => {
|
|
232
|
+
const ringRate = Math.min(100, Math.max(0, result.rate));
|
|
233
|
+
const ring = document.getElementById('savings-rate-ring');
|
|
234
|
+
if (ring) ring.style.setProperty('--rate', `${ringRate * 3.6}deg`);
|
|
235
|
+
const bar = document.getElementById('savings-rate-scenario-bar-fill');
|
|
236
|
+
if (bar) bar.style.width = `${Math.min(100, Math.max(0, result.scenario.rate))}%`;
|
|
237
|
+
const marker = document.getElementById('savings-rate-target-marker');
|
|
238
|
+
if (marker) marker.style.left = `${Math.min(100, Math.max(0, result.targetRate))}%`;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const renderValid = (result: NonNullable<ReturnType<typeof calculateSavingsRate>>): void => {
|
|
242
|
+
clearError();
|
|
243
|
+
renderBaseline(result);
|
|
244
|
+
renderScenario(result);
|
|
245
|
+
renderGauges(result);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const updateSymbols = (): void => {
|
|
249
|
+
symbols.forEach((symbol) => { symbol.textContent = currencySymbols[currency?.value ?? 'USD'] ?? '$'; });
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const update = (): void => {
|
|
253
|
+
const result = calculateSavingsRate({
|
|
254
|
+
income: read(income),
|
|
255
|
+
saved: read(saved),
|
|
256
|
+
extraContribution: read(extra),
|
|
257
|
+
targetRate: read(target),
|
|
258
|
+
incomeChange: read(incomeChange),
|
|
259
|
+
spendingChange: read(spendingChange),
|
|
260
|
+
period,
|
|
261
|
+
});
|
|
262
|
+
if (!result) return renderError();
|
|
263
|
+
renderValid(result);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const setPeriod = (nextPeriod: SavingsPeriod): void => {
|
|
267
|
+
period = nextPeriod;
|
|
268
|
+
periodButtons.forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.period === period)));
|
|
269
|
+
periodButtons.forEach((button) => button.classList.toggle('is-active', button.dataset.period === period));
|
|
270
|
+
if (periodBadge) {
|
|
271
|
+
periodBadge.textContent = periodButtons.find((button) => button.dataset.period === period)?.textContent ?? period;
|
|
272
|
+
}
|
|
273
|
+
update();
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const reset = (): void => {
|
|
277
|
+
if (income) income.value = '4000';
|
|
278
|
+
if (saved) saved.value = '1000';
|
|
279
|
+
if (extra) extra.value = '200';
|
|
280
|
+
if (target) target.value = '30';
|
|
281
|
+
if (incomeChange) incomeChange.value = '0';
|
|
282
|
+
if (spendingChange) spendingChange.value = '0';
|
|
283
|
+
if (currency) currency.value = 'USD';
|
|
284
|
+
setPeriod('monthly');
|
|
285
|
+
updateSymbols();
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const readText = (id: string): string => document.getElementById(id)?.textContent ?? '';
|
|
289
|
+
const readValue = (id: string): string => (document.getElementById(id) as HTMLInputElement | null)?.value ?? '';
|
|
290
|
+
const getCopyText = (): string => [
|
|
291
|
+
`Savings rate: ${readText('savings-rate-rate')}`,
|
|
292
|
+
`Annualized savings: ${readText('savings-rate-annual-saved')}`,
|
|
293
|
+
`Target: ${readValue('savings-rate-target')}%`,
|
|
294
|
+
`Scenario rate: ${readText('savings-rate-scenario-rate')}`,
|
|
295
|
+
].join('\n');
|
|
296
|
+
|
|
297
|
+
const showCopied = (): void => {
|
|
298
|
+
const button = document.getElementById('savings-rate-copy');
|
|
299
|
+
if (!button) return;
|
|
300
|
+
const previous = button.textContent;
|
|
301
|
+
button.textContent = root.dataset.labelCopied ?? 'Copied';
|
|
302
|
+
window.setTimeout(() => { button.textContent = previous; }, 1600);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const copy = async (): Promise<void> => {
|
|
306
|
+
try {
|
|
307
|
+
await navigator.clipboard.writeText(getCopyText());
|
|
308
|
+
showCopied();
|
|
309
|
+
} catch {
|
|
310
|
+
renderError();
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
[income, saved, extra, target, incomeChange, spendingChange].forEach((input) => input?.addEventListener('input', update));
|
|
315
|
+
currency?.addEventListener('change', () => { updateSymbols(); update(); });
|
|
316
|
+
periodButtons.forEach((button) => button.addEventListener('click', () => setPeriod((button.dataset.period ?? 'monthly') as SavingsPeriod)));
|
|
317
|
+
document.getElementById('savings-rate-reset')?.addEventListener('click', reset);
|
|
318
|
+
document.getElementById('savings-rate-copy')?.addEventListener('click', copy);
|
|
319
|
+
updateSymbols();
|
|
320
|
+
update();
|
|
321
|
+
}
|
|
322
|
+
</script>
|
|
323
|
+
|
|
324
|
+
<style>
|
|
325
|
+
@import './savings-rate-calculator.css';
|
|
326
|
+
</style>
|